[Lens] add new metric visualization (#136567)

This commit is contained in:
Andrew Tate 2022-07-26 14:18:20 -05:00 committed by GitHub
parent 038bf2d42b
commit 42d396627e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 3817 additions and 369 deletions

View file

@ -32,7 +32,7 @@ pageLoadAssetSize:
inputControlVis: 172675
inspector: 148711
kibanaOverview: 56279
lens: 35000
lens: 36000
licenseManagement: 41817
licensing: 29004
lists: 22900

View file

@ -31,6 +31,7 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
help: i18n.translate('expressionMetricVis.function.metric.help', {
defaultMessage: 'The primary metric.',
}),
required: true,
},
secondaryMetric: {
types: ['vis_dimension', 'string'],
@ -38,6 +39,12 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
defaultMessage: 'The secondary metric (shown above the primary).',
}),
},
max: {
types: ['vis_dimension', 'string'],
help: i18n.translate('expressionMetricVis.function.max.help.', {
defaultMessage: 'The dimension containing the maximum value.',
}),
},
breakdownBy: {
types: ['vis_dimension', 'string'],
help: i18n.translate('expressionMetricVis.function.breakdownBy.help', {
@ -50,16 +57,10 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
defaultMessage: 'The subtitle for a single metric. Overridden if breakdownBy is supplied.',
}),
},
extraText: {
secondaryPrefix: {
types: ['string'],
help: i18n.translate('expressionMetricVis.function.extra.help', {
defaultMessage: 'Text to be shown above metric value. Overridden by secondaryMetric.',
}),
},
progressMax: {
types: ['vis_dimension', 'string'],
help: i18n.translate('expressionMetricVis.function.progressMax.help.', {
defaultMessage: 'The dimension containing the maximum value.',
help: i18n.translate('expressionMetricVis.function.secondaryPrefix.help', {
defaultMessage: 'Optional text to be show before secondaryMetric.',
}),
},
progressDirection: {
@ -71,6 +72,12 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
}),
strict: true,
},
color: {
types: ['string'],
help: i18n.translate('expressionMetricVis.function.color.help', {
defaultMessage: 'Provides a static visualization color. Overridden by palette.',
}),
},
palette: {
types: ['palette'],
help: i18n.translate('expressionMetricVis.function.palette.help', {
@ -79,7 +86,7 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
},
maxCols: {
types: ['number'],
help: i18n.translate('expressionMetricVis.function.maxCols.help', {
help: i18n.translate('expressionMetricVis.function.numCols.help', {
defaultMessage: 'Specifies the max number of columns in the metric grid.',
}),
default: 5,
@ -128,9 +135,9 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
]);
}
if (args.progressMax) {
if (args.max) {
argsTable.push([
[args.progressMax],
[args.max],
i18n.translate('expressionMetricVis.function.dimension.maximum', {
defaultMessage: 'Maximum',
}),
@ -150,7 +157,8 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
visConfig: {
metric: {
subtitle: args.subtitle,
extraText: args.extraText,
secondaryPrefix: args.secondaryPrefix,
color: args.color,
palette: args.palette?.params,
progressDirection: args.progressDirection,
maxCols: args.maxCols,
@ -159,8 +167,8 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
dimensions: {
metric: args.metric,
secondaryMetric: args.secondaryMetric,
max: args.max,
breakdownBy: args.breakdownBy,
progressMax: args.progressMax,
},
},
},

View file

@ -21,11 +21,12 @@ import { EXPRESSION_METRIC_NAME } from '../constants';
export interface MetricArguments {
metric: ExpressionValueVisDimension | string;
secondaryMetric?: ExpressionValueVisDimension | string;
max?: ExpressionValueVisDimension | string;
breakdownBy?: ExpressionValueVisDimension | string;
subtitle?: string;
extraText?: string;
progressMax?: ExpressionValueVisDimension | string;
secondaryPrefix?: string;
progressDirection: LayoutDirection;
color?: string;
palette?: PaletteOutput<CustomPaletteState>;
maxCols: number;
minTiles?: number;

View file

@ -15,13 +15,14 @@ export const visType = 'metric';
export interface DimensionsVisParam {
metric: ExpressionValueVisDimension | string;
secondaryMetric?: ExpressionValueVisDimension | string;
max?: ExpressionValueVisDimension | string;
breakdownBy?: ExpressionValueVisDimension | string;
progressMax?: ExpressionValueVisDimension | string;
}
export interface MetricVisParam {
subtitle?: string;
extraText?: string;
secondaryPrefix?: string;
color?: string;
palette?: CustomPaletteState;
progressDirection: LayoutDirection;
maxCols: number;

View file

@ -0,0 +1,81 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MetricVisComponent coloring by palette percent-based should set correct data bounds with breakdown-by and max dimension 1`] = `
Array [
Object {
"max": 28.984375,
"min": 0,
"value": 13.6328125,
},
Object {
"max": 28.984375,
"min": 0,
"value": 13.639539930555555,
},
Object {
"max": 25.984375,
"min": 0,
"value": 13.34375,
},
Object {
"max": 25.784375,
"min": 0,
"value": 13.4921875,
},
Object {
"max": 25.348011363636363,
"min": 0,
"value": 13.34375,
},
Object {
"max": 24.984375,
"min": 0,
"value": 13.242513020833334,
},
]
`;
exports[`MetricVisComponent coloring by palette percent-based should set correct data bounds with just breakdown-by dimension 1`] = `
Array [
Object {
"max": 13.639539930555555,
"min": 13.242513020833334,
"value": 13.6328125,
},
Object {
"max": 13.639539930555555,
"min": 13.242513020833334,
"value": 13.639539930555555,
},
Object {
"max": 13.639539930555555,
"min": 13.242513020833334,
"value": 13.34375,
},
Object {
"max": 13.639539930555555,
"min": 13.242513020833334,
"value": 13.4921875,
},
Object {
"max": 13.639539930555555,
"min": 13.242513020833334,
"value": 13.34375,
},
Object {
"max": 13.639539930555555,
"min": 13.242513020833334,
"value": 13.242513020833334,
},
]
`;
exports[`MetricVisComponent coloring by palette percent-based should set correct data bounds with just max dimension 1`] = `
Array [
Object {
"max": 28.984375,
"min": 0,
"value": 13.6328125,
},
]
`;

View file

@ -9,15 +9,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Datatable } from '@kbn/expressions-plugin/common';
import MetricVis, { MetricVisComponentProps } from './metric_vis';
import { LayoutDirection, Metric, MetricWProgress, Settings } from '@elastic/charts';
import { MetricVis, MetricVisComponentProps } from './metric_vis';
import {
LayoutDirection,
Metric,
MetricElementEvent,
MetricWProgress,
Settings,
} from '@elastic/charts';
import { SerializedFieldFormat } from '@kbn/field-formats-plugin/common';
import { SerializableRecord } from '@kbn/utility-types';
import numeral from '@elastic/numeral';
import { HtmlAttributes } from 'csstype';
import { CustomPaletteState } from '@kbn/charts-plugin/common/expressions/palette/types';
import { DimensionsVisParam } from '../../common';
import { euiThemeVars } from '@kbn/ui-theme';
const mockDeserialize = jest.fn(() => ({
getConverterFor: jest.fn(() => () => 'formatted duration'),
}));
const mockDeserialize = jest.fn((params) => {
const converter =
params.id === 'terms'
? (val: string) => (val === '__other__' ? 'Other' : val)
: () => 'formatted duration';
return { getConverterFor: jest.fn(() => converter) };
});
const mockGetColorForValue = jest.fn<undefined | string, any>(() => undefined);
@ -186,14 +200,25 @@ const table: Datatable = {
[minPriceColumnId]: 13.34375,
},
{
[dayOfWeekColumnId]: 'Monday',
[dayOfWeekColumnId]: '__other__',
[basePriceColumnId]: 24.984375,
[minPriceColumnId]: 13.242513020833334,
},
],
};
const defaultProps = {
renderComplete: () => {},
fireEvent: () => {},
filterable: true,
renderMode: 'view',
} as Pick<MetricVisComponentProps, 'renderComplete' | 'fireEvent' | 'filterable' | 'renderMode'>;
describe('MetricVisComponent', function () {
afterEach(() => {
mockDeserialize.mockClear();
});
describe('single metric', () => {
const config: Props['config'] = {
metric: {
@ -206,9 +231,7 @@ describe('MetricVisComponent', function () {
};
it('should render a single metric value', () => {
const component = shallow(
<MetricVis config={config} data={table} renderComplete={() => {}} />
);
const component = shallow(<MetricVis config={config} data={table} {...defaultProps} />);
const { data } = component.find(Metric).props();
@ -219,7 +242,7 @@ describe('MetricVisComponent', function () {
expect(visConfig).toMatchInlineSnapshot(`
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span />,
"subtitle": undefined,
"title": "Median products.base_price",
@ -228,29 +251,26 @@ describe('MetricVisComponent', function () {
}
`);
});
it('should display subtitle and extra text', () => {
it('should display subtitle and secondary prefix', () => {
const component = shallow(
<MetricVis
config={{
...config,
metric: { ...config.metric, subtitle: 'subtitle', extraText: 'extra text' },
metric: { ...config.metric, subtitle: 'subtitle' },
}}
data={table}
renderComplete={() => {}}
{...defaultProps}
/>
);
const [[visConfig]] = component.find(Metric).props().data!;
expect(visConfig!.subtitle).toBe('subtitle');
expect(visConfig!.extra).toEqual(<span>extra text</span>);
expect(visConfig).toMatchInlineSnapshot(`
Object {
"color": "#343741",
"extra": <span>
extra text
</span>,
"color": "#f5f7fa",
"extra": <span />,
"subtitle": "subtitle",
"title": "Median products.base_price",
"value": 28.984375,
@ -263,27 +283,31 @@ describe('MetricVisComponent', function () {
<MetricVis
config={{
...config,
metric: { ...config.metric, subtitle: 'subtitle', extraText: 'extra text' },
metric: { ...config.metric, subtitle: 'subtitle', secondaryPrefix: 'secondary prefix' },
dimensions: { ...config.dimensions, secondaryMetric: minPriceColumnId },
}}
data={table}
renderComplete={() => {}}
{...defaultProps}
/>
);
const [[visConfig]] = component.find(Metric).props().data!;
// overrides subtitle and extra text
expect(visConfig!.subtitle).toBe(table.columns[2].name);
expect(visConfig!.extra).toEqual(<span>13.63</span>);
expect(visConfig!.extra).toEqual(
<span>
{'secondary prefix'}
{' ' + 13.63}
</span>
);
expect(visConfig).toMatchInlineSnapshot(`
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span>
13.63
secondary prefix
13.63
</span>,
"subtitle": "Median products.min_price",
"subtitle": "subtitle",
"title": "Median products.base_price",
"value": 28.984375,
"valueFormatter": [Function],
@ -303,11 +327,11 @@ describe('MetricVisComponent', function () {
},
dimensions: {
...config.dimensions,
progressMax: max,
max,
},
}}
data={table}
renderComplete={() => {}}
{...defaultProps}
/>
)
.find(Metric)
@ -326,7 +350,7 @@ describe('MetricVisComponent', function () {
expect(configWithProgress).toMatchInlineSnapshot(`
Object {
"color": "#343741",
"color": "#f5f7fa",
"domainMax": 28.984375,
"extra": <span />,
"progressBarDirection": "vertical",
@ -341,56 +365,6 @@ describe('MetricVisComponent', function () {
(getConfig(basePriceColumnId, 'horizontal') as MetricWProgress).progressBarDirection
).toBe('horizontal');
});
it('should fetch color from palette if provided', () => {
const colorFromPalette = 'color-from-palette';
mockGetColorForValue.mockReturnValue(colorFromPalette);
const component = shallow(
<MetricVis
config={{
...config,
metric: {
...config.metric,
palette: {
colors: [],
gradient: true,
stops: [],
range: 'number',
rangeMin: 2,
rangeMax: 10,
},
},
}}
data={table}
renderComplete={() => {}}
/>
);
const [[datum]] = component.find(Metric).props().data!;
expect(datum!.color).toBe(colorFromPalette);
expect(mockGetColorForValue.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
28.984375,
Object {
"colors": Array [],
"gradient": true,
"range": "number",
"rangeMax": 10,
"rangeMin": 2,
"stops": Array [],
},
Object {
"max": 10,
"min": 2,
},
],
]
`);
});
});
describe('metric grid', () => {
@ -406,9 +380,7 @@ describe('MetricVisComponent', function () {
};
it('should render a grid if breakdownBy dimension supplied', () => {
const component = shallow(
<MetricVis config={config} data={table} renderComplete={() => {}} />
);
const component = shallow(<MetricVis config={config} data={table} {...defaultProps} />);
const { data } = component.find(Metric).props();
@ -420,7 +392,7 @@ describe('MetricVisComponent', function () {
expect(visConfig).toMatchInlineSnapshot(`
Array [
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span />,
"subtitle": "Median products.base_price",
"title": "Friday",
@ -428,7 +400,7 @@ describe('MetricVisComponent', function () {
"valueFormatter": [Function],
},
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span />,
"subtitle": "Median products.base_price",
"title": "Wednesday",
@ -436,7 +408,7 @@ describe('MetricVisComponent', function () {
"valueFormatter": [Function],
},
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span />,
"subtitle": "Median products.base_price",
"title": "Saturday",
@ -444,7 +416,7 @@ describe('MetricVisComponent', function () {
"valueFormatter": [Function],
},
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span />,
"subtitle": "Median products.base_price",
"title": "Sunday",
@ -452,7 +424,7 @@ describe('MetricVisComponent', function () {
"valueFormatter": [Function],
},
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span />,
"subtitle": "Median products.base_price",
"title": "Thursday",
@ -463,17 +435,17 @@ describe('MetricVisComponent', function () {
`);
});
it('should display extra text or secondary metric', () => {
it('should display secondary prefix or secondary metric', () => {
const componentWithSecondaryDimension = shallow(
<MetricVis
config={{
...config,
dimensions: { ...config.dimensions, secondaryMetric: minPriceColumnId },
// extra text included to make sure it's overridden
metric: { ...config.metric, extraText: 'howdy' },
// secondary prefix included to make sure it's overridden
metric: { ...config.metric, secondaryPrefix: 'howdy' },
}}
data={table}
renderComplete={() => {}}
{...defaultProps}
/>
);
@ -485,19 +457,24 @@ describe('MetricVisComponent', function () {
).toMatchInlineSnapshot(`
Array [
<span>
13.63
howdy
13.63
</span>,
<span>
13.64
howdy
13.64
</span>,
<span>
13.34
howdy
13.34
</span>,
<span>
13.49
howdy
13.49
</span>,
<span>
13.34
howdy
13.34
</span>,
]
`);
@ -506,10 +483,10 @@ describe('MetricVisComponent', function () {
<MetricVis
config={{
...config,
metric: { ...config.metric, extraText: 'howdy' },
metric: { ...config.metric, secondaryPrefix: 'howdy' },
}}
data={table}
renderComplete={() => {}}
{...defaultProps}
/>
);
@ -552,7 +529,7 @@ describe('MetricVisComponent', function () {
},
}}
data={table}
renderComplete={() => {}}
{...defaultProps}
/>
)
.find(Metric)
@ -575,7 +552,7 @@ describe('MetricVisComponent', function () {
Array [
Array [
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span />,
"subtitle": "Median products.base_price",
"title": "Friday",
@ -583,7 +560,7 @@ describe('MetricVisComponent', function () {
"valueFormatter": [Function],
},
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span />,
"subtitle": "Median products.base_price",
"title": "Wednesday",
@ -591,7 +568,7 @@ describe('MetricVisComponent', function () {
"valueFormatter": [Function],
},
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span />,
"subtitle": "Median products.base_price",
"title": "Saturday",
@ -599,7 +576,7 @@ describe('MetricVisComponent', function () {
"valueFormatter": [Function],
},
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span />,
"subtitle": "Median products.base_price",
"title": "Sunday",
@ -607,7 +584,7 @@ describe('MetricVisComponent', function () {
"valueFormatter": [Function],
},
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span />,
"subtitle": "Median products.base_price",
"title": "Thursday",
@ -617,10 +594,10 @@ describe('MetricVisComponent', function () {
],
Array [
Object {
"color": "#343741",
"color": "#f5f7fa",
"extra": <span />,
"subtitle": "Median products.base_price",
"title": "Monday",
"title": "Other",
"value": 24.984375,
"valueFormatter": [Function],
},
@ -644,11 +621,11 @@ describe('MetricVisComponent', function () {
},
dimensions: {
...config.dimensions,
progressMax: basePriceColumnId,
max: basePriceColumnId,
},
}}
data={table}
renderComplete={() => {}}
{...defaultProps}
/>
)
.find(Metric)
@ -657,7 +634,7 @@ describe('MetricVisComponent', function () {
Array [
Array [
Object {
"color": "#343741",
"color": "#f5f7fa",
"domainMax": 28.984375,
"extra": <span />,
"progressBarDirection": "vertical",
@ -667,7 +644,7 @@ describe('MetricVisComponent', function () {
"valueFormatter": [Function],
},
Object {
"color": "#343741",
"color": "#f5f7fa",
"domainMax": 28.984375,
"extra": <span />,
"progressBarDirection": "vertical",
@ -677,7 +654,7 @@ describe('MetricVisComponent', function () {
"valueFormatter": [Function],
},
Object {
"color": "#343741",
"color": "#f5f7fa",
"domainMax": 25.984375,
"extra": <span />,
"progressBarDirection": "vertical",
@ -687,7 +664,7 @@ describe('MetricVisComponent', function () {
"valueFormatter": [Function],
},
Object {
"color": "#343741",
"color": "#f5f7fa",
"domainMax": 25.784375,
"extra": <span />,
"progressBarDirection": "vertical",
@ -697,7 +674,7 @@ describe('MetricVisComponent', function () {
"valueFormatter": [Function],
},
Object {
"color": "#343741",
"color": "#f5f7fa",
"domainMax": 25.348011363636363,
"extra": <span />,
"progressBarDirection": "vertical",
@ -709,12 +686,12 @@ describe('MetricVisComponent', function () {
],
Array [
Object {
"color": "#343741",
"color": "#f5f7fa",
"domainMax": 24.984375,
"extra": <span />,
"progressBarDirection": "vertical",
"subtitle": "Median products.base_price",
"title": "Monday",
"title": "Other",
"value": 24.984375,
"valueFormatter": [Function],
},
@ -722,6 +699,88 @@ describe('MetricVisComponent', function () {
]
`);
});
it('renders with no data', () => {
const component = shallow(
<MetricVis
config={{ ...config, metric: { ...config.metric, minTiles: 6 } }}
data={{ type: 'datatable', rows: [], columns: table.columns }}
{...defaultProps}
/>
);
const { data } = component.find(Metric).props();
expect(data).toBeDefined();
expect(data).toMatchInlineSnapshot(`
Array [
Array [
undefined,
undefined,
undefined,
undefined,
undefined,
],
Array [
undefined,
],
]
`);
});
});
describe('rendering with no data', () => {});
it('should constrain dimensions in edit mode', () => {
const getContainerStyles = (editMode: boolean, multipleTiles: boolean) =>
(
shallow(
<MetricVis
data={table}
config={{
metric: {
progressDirection: 'vertical',
maxCols: 5,
},
dimensions: {
metric: basePriceColumnId,
breakdownBy: multipleTiles ? dayOfWeekColumnId : undefined,
},
}}
{...defaultProps}
renderMode={editMode ? 'edit' : 'view'}
/>
)
.find('div')
.props() as HtmlAttributes & { css: { styles: string } }
).css.styles;
expect(getContainerStyles(false, false)).toMatchInlineSnapshot(`
"
height: 100%;
width: 100%;
max-height: 100%;
max-width: 100%;
"
`);
expect(getContainerStyles(true, false)).toMatchInlineSnapshot(`
"
height: 300px;
width: 300px;
max-height: 100%;
max-width: 100%;
"
`);
expect(getContainerStyles(true, true)).toMatchInlineSnapshot(`
"
height: 400px;
width: 1000px;
max-height: 100%;
max-width: 100%;
"
`);
});
it('should report render complete', () => {
@ -738,6 +797,7 @@ describe('MetricVisComponent', function () {
},
}}
data={table}
{...defaultProps}
renderComplete={renderCompleteSpy}
/>
);
@ -750,6 +810,271 @@ describe('MetricVisComponent', function () {
expect(renderCompleteSpy).toHaveBeenCalledTimes(1);
});
describe('filter events', () => {
const fireEventSpy = jest.fn();
afterEach(() => fireEventSpy.mockClear());
const fireFilter = (event: MetricElementEvent, filterable: boolean, breakdown?: boolean) => {
const component = shallow(
<MetricVis
config={{
metric: {
progressDirection: 'vertical',
maxCols: 5,
},
dimensions: {
metric: basePriceColumnId,
breakdownBy: breakdown ? dayOfWeekColumnId : undefined,
},
}}
data={table}
{...defaultProps}
filterable={filterable}
fireEvent={fireEventSpy}
/>
);
component.find(Settings).props().onElementClick!([event]);
};
test('without breakdown', () => {
const event: MetricElementEvent = {
type: 'metricElementEvent',
rowIndex: 0,
columnIndex: 0,
};
fireFilter(event, true, false);
expect(fireEventSpy).toHaveBeenCalledTimes(1);
expect(fireEventSpy).toHaveBeenCalledWith({
name: 'filter',
data: {
data: [
{
table,
column: 1,
row: 0,
},
],
},
});
});
test('with breakdown', () => {
const event: MetricElementEvent = {
type: 'metricElementEvent',
rowIndex: 1,
columnIndex: 0,
};
fireFilter(event, true, true);
expect(fireEventSpy).toHaveBeenCalledTimes(1);
expect(fireEventSpy).toHaveBeenCalledWith({
name: 'filter',
data: {
data: [
{
table,
column: 0,
row: 5,
},
],
},
});
});
it('should do nothing if primary metric is not filterable', () => {
const event: MetricElementEvent = {
type: 'metricElementEvent',
rowIndex: 1,
columnIndex: 0,
};
fireFilter(event, false, true);
expect(fireEventSpy).not.toHaveBeenCalled();
});
});
describe('coloring', () => {
afterEach(() => mockGetColorForValue.mockClear());
describe('by palette', () => {
const colorFromPalette = 'color-from-palette';
mockGetColorForValue.mockReturnValue(colorFromPalette);
it('should fetch color from palette if provided', () => {
const component = shallow(
<MetricVis
config={{
dimensions: {
metric: basePriceColumnId,
},
metric: {
progressDirection: 'vertical',
maxCols: 5,
// should be overridden
color: 'static-color',
palette: {
colors: [],
gradient: true,
stops: [],
range: 'number',
rangeMin: 2,
rangeMax: 10,
},
},
}}
data={table}
{...defaultProps}
/>
);
const [[datum]] = component.find(Metric).props().data!;
expect(datum!.color).toBe(colorFromPalette);
expect(mockGetColorForValue.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
28.984375,
Object {
"colors": Array [],
"gradient": true,
"range": "number",
"rangeMax": 10,
"rangeMin": 2,
"stops": Array [],
},
Object {
"max": 28.984375,
"min": 0,
},
],
]
`);
});
describe('percent-based', () => {
const renderWithPalette = (
palette: CustomPaletteState,
dimensions: MetricVisComponentProps['config']['dimensions']
) =>
shallow(
<MetricVis
config={{
dimensions,
metric: {
palette,
progressDirection: 'vertical',
maxCols: 5,
},
}}
data={table}
{...defaultProps}
/>
);
const dimensionsAndExpectedBounds = [
[
'breakdown-by and max',
{
metric: minPriceColumnId,
max: basePriceColumnId,
breakdownBy: dayOfWeekColumnId,
},
],
['just breakdown-by', { metric: minPriceColumnId, breakdownBy: dayOfWeekColumnId }],
['just max', { metric: minPriceColumnId, max: basePriceColumnId }],
];
it.each(dimensionsAndExpectedBounds)(
'should set correct data bounds with %s dimension',
// @ts-expect-error
(label, dimensions) => {
mockGetColorForValue.mockClear();
renderWithPalette(
{
range: 'percent',
// the rest of these params don't matter
colors: [],
gradient: false,
stops: [],
rangeMin: 2,
rangeMax: 10,
},
dimensions as DimensionsVisParam
);
expect(
mockGetColorForValue.mock.calls.map(([value, _palette, bounds]) => ({
value,
...bounds,
}))
).toMatchSnapshot();
}
);
});
});
describe('by static color', () => {
it('uses static color if no palette', () => {
const staticColor = 'static-color';
const component = shallow(
<MetricVis
config={{
dimensions: {
metric: basePriceColumnId,
},
metric: {
progressDirection: 'vertical',
maxCols: 5,
color: staticColor,
palette: undefined,
},
}}
data={table}
{...defaultProps}
/>
);
const [[datum]] = component.find(Metric).props().data!;
expect(datum!.color).toBe(staticColor);
expect(mockGetColorForValue).not.toHaveBeenCalled();
});
it('defaults if no static color', () => {
const component = shallow(
<MetricVis
config={{
dimensions: {
metric: basePriceColumnId,
},
metric: {
progressDirection: 'vertical',
maxCols: 5,
color: undefined,
palette: undefined,
},
}}
data={table}
{...defaultProps}
/>
);
const [[datum]] = component.find(Metric).props().data!;
expect(datum!.color).toBe(euiThemeVars.euiColorLightestShade);
expect(mockGetColorForValue).not.toHaveBeenCalled();
});
});
});
describe('metric value formatting', () => {
const getFormattedMetrics = (
value: number,
@ -786,7 +1111,7 @@ describe('MetricVisComponent', function () {
],
rows: [{ '1': value, '2': secondaryValue }],
}}
renderComplete={() => {}}
{...defaultProps}
/>
);
@ -796,7 +1121,7 @@ describe('MetricVisComponent', function () {
extra,
} = component.find(Metric).props().data?.[0][0]!;
return { primary: valueFormatter(primaryMetric), secondary: extra?.props.children };
return { primary: valueFormatter(primaryMetric), secondary: extra?.props.children[1] };
};
it('correctly formats plain numbers', () => {

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
@ -14,21 +14,26 @@ import {
Chart,
Metric,
MetricSpec,
MetricWProgress,
isMetricElementEvent,
RenderChangeListener,
Settings,
MetricWProgress,
} from '@elastic/charts';
import { getColumnByAccessor, getFormatByAccessor } from '@kbn/visualizations-plugin/common/utils';
import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common';
import {
import type {
Datatable,
DatatableColumn,
DatatableRow,
IInterpreterRenderHandlers,
RenderMode,
} from '@kbn/expressions-plugin/common';
import { CustomPaletteState } from '@kbn/charts-plugin/public';
import { euiLightVars } from '@kbn/ui-theme';
import { FORMATS_UI_SETTINGS } from '@kbn/field-formats-plugin/common';
import type { FieldFormatConvertFunction } from '@kbn/field-formats-plugin/common';
import { CUSTOM_PALETTE } from '@kbn/coloring';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { VisParams } from '../../common';
import {
getPaletteService,
@ -37,9 +42,9 @@ import {
getUiSettingsService,
} from '../services';
import { getCurrencyCode } from './currency_codes';
import { getDataBoundsForPalette } from '../utils';
const defaultColor = euiLightVars.euiColorDarkestShade;
export const defaultColor = euiThemeVars.euiColorLightestShade;
const getBytesUnit = (value: number) => {
const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte', 'petabyte'];
const abs = Math.abs(value);
@ -69,7 +74,7 @@ const getBytesUnit = (value: number) => {
return { value, unit };
};
const getFormatter = (
const getMetricFormatter = (
accessor: ExpressionValueVisDimension | string,
columns: Datatable['columns']
) => {
@ -79,7 +84,7 @@ const getFormatter = (
if (!['number', 'currency', 'percent', 'bytes', 'duration'].includes(formatId)) {
throw new Error(
i18n.translate('expressionMetricVis.errors.unsupportedColumnFormat', {
defaultMessage: 'Metric Visualization - Unsupported column format: "{id}"',
defaultMessage: 'Metric visualization expression - Unsupported column format: "{id}"',
values: {
id: formatId,
},
@ -140,55 +145,97 @@ const getFormatter = (
: new Intl.NumberFormat(locale, intlOptions).format;
};
const getColor = (value: number, paletteParams: CustomPaletteState | undefined) =>
paletteParams
? getPaletteService().get('custom')?.getColorForValue?.(value, paletteParams, {
min: paletteParams.rangeMin,
max: paletteParams.rangeMax,
}) || defaultColor
: defaultColor;
const getColor = (
value: number,
paletteParams: CustomPaletteState,
accessors: { metric: string; max?: string; breakdownBy?: string },
data: Datatable,
rowNumber: number
) => {
let minBound = paletteParams.rangeMin;
let maxBound = paletteParams.rangeMax;
const { min, max } = getDataBoundsForPalette(accessors, data, rowNumber);
minBound = min;
maxBound = max;
return getPaletteService().get(CUSTOM_PALETTE)?.getColorForValue?.(value, paletteParams, {
min: minBound,
max: maxBound,
});
};
const buildFilterEvent = (rowIdx: number, columnIdx: number, table: Datatable) => {
return {
name: 'filter',
data: {
data: [
{
table,
column: columnIdx,
row: rowIdx,
},
],
},
};
};
export interface MetricVisComponentProps {
data: Datatable;
config: Pick<VisParams, 'metric' | 'dimensions'>;
renderComplete: IInterpreterRenderHandlers['done'];
fireEvent: IInterpreterRenderHandlers['event'];
renderMode: RenderMode;
filterable: boolean;
}
const MetricVisComponent = ({ data, config, renderComplete }: MetricVisComponentProps) => {
export const MetricVis = ({
data,
config,
renderComplete,
fireEvent,
renderMode,
filterable,
}: MetricVisComponentProps) => {
const primaryMetricColumn = getColumnByAccessor(config.dimensions.metric, data.columns)!;
const formatPrimaryMetric = getFormatter(config.dimensions.metric, data.columns);
const formatPrimaryMetric = getMetricFormatter(config.dimensions.metric, data.columns);
let secondaryMetricColumn: DatatableColumn | undefined;
let formatSecondaryMetric: ReturnType<typeof getFormatter>;
let formatSecondaryMetric: ReturnType<typeof getMetricFormatter>;
if (config.dimensions.secondaryMetric) {
secondaryMetricColumn = getColumnByAccessor(config.dimensions.secondaryMetric, data.columns);
formatSecondaryMetric = getFormatter(config.dimensions.secondaryMetric, data.columns);
formatSecondaryMetric = getMetricFormatter(config.dimensions.secondaryMetric, data.columns);
}
const breakdownByColumn = config.dimensions.breakdownBy
? getColumnByAccessor(config.dimensions.breakdownBy, data.columns)
: undefined;
let breakdownByColumn: DatatableColumn | undefined;
let formatBreakdownValue: FieldFormatConvertFunction;
if (config.dimensions.breakdownBy) {
breakdownByColumn = getColumnByAccessor(config.dimensions.breakdownBy, data.columns);
formatBreakdownValue = getFormatService()
.deserialize(getFormatByAccessor(config.dimensions.breakdownBy, data.columns))
.getConverterFor('text');
}
let getProgressBarConfig = (_row: DatatableRow): Partial<MetricWProgress> => ({});
if (config.dimensions.progressMax) {
const maxColId = getColumnByAccessor(config.dimensions.progressMax, data.columns)?.id;
if (maxColId) {
getProgressBarConfig = (_row: DatatableRow): Partial<MetricWProgress> => ({
domainMax: _row[maxColId],
progressBarDirection: config.metric.progressDirection,
});
}
const maxColId = config.dimensions.max
? getColumnByAccessor(config.dimensions.max, data.columns)?.id
: undefined;
if (maxColId) {
getProgressBarConfig = (_row: DatatableRow): Partial<MetricWProgress> => ({
domainMax: _row[maxColId],
progressBarDirection: config.metric.progressDirection,
});
}
const metricConfigs: MetricSpec['data'][number] = (
breakdownByColumn ? data.rows : data.rows.slice(0, 1)
).map((row) => {
).map((row, rowIdx) => {
const value = row[primaryMetricColumn.id];
const title = breakdownByColumn ? row[breakdownByColumn.id] : primaryMetricColumn.name;
const subtitle = breakdownByColumn
? primaryMetricColumn.name
: secondaryMetricColumn?.name ?? config.metric.subtitle;
const title = breakdownByColumn
? formatBreakdownValue(row[breakdownByColumn.id])
: primaryMetricColumn.name;
const subtitle = breakdownByColumn ? primaryMetricColumn.name : config.metric.subtitle;
return {
value,
valueFormatter: formatPrimaryMetric,
@ -196,12 +243,28 @@ const MetricVisComponent = ({ data, config, renderComplete }: MetricVisComponent
subtitle,
extra: (
<span>
{config.metric.secondaryPrefix}
{secondaryMetricColumn
? formatSecondaryMetric!(row[secondaryMetricColumn.id])
: config.metric.extraText}
? `${config.metric.secondaryPrefix ? ' ' : ''}${formatSecondaryMetric!(
row[secondaryMetricColumn.id]
)}`
: undefined}
</span>
),
color: getColor(value, config.metric.palette),
color:
config.metric.palette && value != null
? getColor(
value,
config.metric.palette,
{
metric: primaryMetricColumn.id,
max: maxColId,
breakdownBy: breakdownByColumn?.id,
},
data,
rowIdx
) ?? defaultColor
: config.metric.color ?? defaultColor,
...getProgressBarConfig(row),
};
});
@ -228,17 +291,62 @@ const MetricVisComponent = ({ data, config, renderComplete }: MetricVisComponent
[renderComplete]
);
let pixelHeight;
let pixelWidth;
if (renderMode === 'edit') {
// In the editor, we constrain the maximum size of the tiles for aesthetic reasons
const maxTileSideLength = metricConfigs.flat().length > 1 ? 200 : 300;
pixelHeight = grid.length * maxTileSideLength;
pixelWidth = grid[0].length * maxTileSideLength;
}
// force chart to re-render to circumvent a charts bug
const magicKey = useRef(0);
useEffect(() => {
magicKey.current++;
}, [data]);
return (
<Chart>
<Settings
theme={[{ background: { color: 'transparent' } }, chartTheme]}
onRenderChange={onRenderChange}
/>
<Metric id="metric" data={grid} />
</Chart>
<div
css={css`
height: ${pixelHeight ? `${pixelHeight}px` : '100%'};
width: ${pixelWidth ? `${pixelWidth}px` : '100%'};
max-height: 100%;
max-width: 100%;
`}
>
<Chart key={magicKey.current}>
<Settings
theme={[
{
background: { color: 'transparent' },
metric: {
background: defaultColor,
barBackground: euiThemeVars.euiColorLightShade,
},
},
chartTheme,
]}
onRenderChange={onRenderChange}
onElementClick={(events) => {
if (!filterable) {
return;
}
events.forEach((event) => {
if (isMetricElementEvent(event)) {
const colIdx = breakdownByColumn
? data.columns.findIndex((col) => col === breakdownByColumn)
: data.columns.findIndex((col) => col === primaryMetricColumn);
const rowLength = grid[0].length;
fireEvent(
buildFilterEvent(event.rowIndex * rowLength + event.columnIndex, colIdx, data)
);
}
});
}}
/>
<Metric id="metric" data={grid} />
</Chart>
</div>
);
};
// default export required for React.Lazy
// eslint-disable-next-line import/no-default-export
export { MetricVisComponent as default };

View file

@ -6,21 +6,42 @@
* Side Public License, v 1.
*/
import React, { lazy } from 'react';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { ExpressionRenderDefinition } from '@kbn/expressions-plugin/common/expression_renderers';
import { VisualizationContainer } from '@kbn/visualizations-plugin/public';
import { css } from '@emotion/react';
import { StartServicesGetter } from '@kbn/kibana-utils-plugin/public';
import { METRIC_TYPE } from '@kbn/analytics';
import type { IInterpreterRenderHandlers, Datatable } from '@kbn/expressions-plugin/common';
import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils';
import { ExpressionMetricPluginStart } from '../plugin';
import { EXPRESSION_METRIC_NAME, MetricVisRenderConfig } from '../../common';
import { EXPRESSION_METRIC_NAME, MetricVisRenderConfig, VisParams } from '../../common';
import { extractContainerType, extractVisualizationType } from '../../../common';
const MetricVis = lazy(() => import('../components/metric_vis'));
async function metricFilterable(
dimensions: VisParams['dimensions'],
table: Datatable,
hasCompatibleActions?: IInterpreterRenderHandlers['hasCompatibleActions']
) {
const column = getColumnByAccessor(dimensions.breakdownBy ?? dimensions.metric, table.columns);
const colIndex = table.columns.indexOf(column!);
return Boolean(
await hasCompatibleActions?.({
name: 'filter',
data: {
data: [
{
table,
column: colIndex,
row: 0,
},
],
},
})
);
}
interface ExpressionMetricVisRendererDependencies {
getStartDeps: StartServicesGetter<ExpressionMetricPluginStart>;
}
@ -39,6 +60,11 @@ export const getMetricVisRenderer = (
unmountComponentAtNode(domNode);
});
const filterable = await metricFilterable(
visConfig.dimensions,
visData,
handlers.hasCompatibleActions?.bind(handlers)
);
const renderComplete = () => {
const executionContext = handlers.getExecutionContext();
const containerType = extractContainerType(executionContext);
@ -53,20 +79,28 @@ export const getMetricVisRenderer = (
handlers.done();
};
const { MetricVis } = await import('../components/metric_vis');
render(
<KibanaThemeProvider theme$={core.theme.theme$}>
<VisualizationContainer
<div
data-test-subj="mtrVis"
css={css`
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
`}
showNoResult={!visData.rows.length}
renderComplete={renderComplete}
handlers={handlers}
>
<MetricVis data={visData} config={visConfig} renderComplete={renderComplete} />
</VisualizationContainer>
<MetricVis
data={visData}
config={visConfig}
renderComplete={renderComplete}
fireEvent={handlers.event}
renderMode={handlers.getRenderMode()}
filterable={filterable}
/>
</div>
</KibanaThemeProvider>,
domNode
);

View file

@ -11,3 +11,5 @@ import { ExpressionMetricPlugin } from './plugin';
export function plugin() {
return new ExpressionMetricPlugin();
}
export { getDataBoundsForPalette } from './utils';

View file

@ -5,5 +5,5 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { parseRgbString, shouldApplyColor, needsLightText } from './palette';
export { formatValue } from './format';
export { getDataBoundsForPalette } from './palette_data_bounds';

View file

@ -1,41 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { isColorDark } from '@elastic/eui';
export const parseRgbString = (rgb: string) => {
const groups = rgb.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*?(,\s*(\d+)\s*)?\)/) ?? [];
if (!groups) {
return null;
}
const red = parseFloat(groups[1]);
const green = parseFloat(groups[2]);
const blue = parseFloat(groups[3]);
const opacity = groups[5] ? parseFloat(groups[5]) : undefined;
return { red, green, blue, opacity };
};
export const shouldApplyColor = (color: string) => {
const rgb = parseRgbString(color);
const { opacity } = rgb ?? {};
// if opacity === 0, it means there is no color to apply to the metric
return !rgb || (rgb && opacity !== 0);
};
export const needsLightText = (bgColor: string = '') => {
const rgb = parseRgbString(bgColor);
if (!rgb) {
return false;
}
const { red, green, blue, opacity } = rgb;
return isColorDark(red, green, blue) && opacity !== 0;
};

View file

@ -0,0 +1,37 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Datatable } from '@kbn/expressions-plugin/common';
export const getDataBoundsForPalette = (
accessors: { metric: string; max?: string; breakdownBy?: string },
data?: Datatable,
rowNumber?: number
) => {
if (!data) {
return { min: -Infinity, max: Infinity };
}
const smallestMetric = Math.min(...data.rows.map((row) => row[accessors.metric]));
const greatestMetric = Math.max(...data.rows.map((row) => row[accessors.metric]));
const greatestMaximum = accessors.max
? rowNumber
? data.rows[rowNumber][accessors.max]
: Math.max(...data.rows.map((row) => row[accessors.max!]))
: greatestMetric;
const dataMin = accessors.breakdownBy && !accessors.max ? smallestMetric : 0;
const dataMax = accessors.breakdownBy
? accessors.max
? greatestMaximum
: greatestMetric
: greatestMaximum;
return { min: dataMin, max: dataMax };
};

View file

@ -14,6 +14,7 @@ import { EmbeddablePublicPlugin } from './plugin';
export type {
Adapters,
ReferenceOrValueEmbeddable,
SelfStyledEmbeddable,
ChartActionContext,
ContainerInput,
ContainerOutput,

View file

@ -15,3 +15,4 @@ export * from './containers';
export * from './panel';
export * from './state_transfer';
export * from './reference_or_value_embeddable';
export * from './self_styled_embeddable';

View file

@ -573,6 +573,52 @@ test('Updates when hidePanelTitles is toggled', async () => {
expect(title.length).toBe(1);
});
test('Respects options from SelfStyledEmbeddable', async () => {
const inspector = inspectorPluginMock.createStartContract();
const container = new HelloWorldContainer(
{ id: '123', panels: {}, viewMode: ViewMode.VIEW, hidePanelTitles: false },
{ getEmbeddableFactory } as any
);
const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Rob',
lastName: 'Stark',
});
const selfStyledEmbeddable = embeddablePluginMock.mockSelfStyledEmbeddable(
contactCardEmbeddable,
{ hideTitle: true }
);
// make sure the title is being hidden because of the self styling, not the container
container.updateInput({ hidePanelTitles: false });
const component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={selfStyledEmbeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
overlays={{} as any}
application={applicationMock}
inspector={inspector}
SavedObjectFinder={() => null}
theme={theme}
/>
</I18nProvider>
);
const title = findTestSubject(component, `embeddablePanelHeading-HelloRobStark`);
expect(title.length).toBe(0);
});
test('Check when hide header option is false', async () => {
const inspector = inspectorPluginMock.createStartContract();

View file

@ -40,7 +40,7 @@ import { InspectPanelAction } from './panel_header/panel_actions/inspect_panel_a
import { EditPanelAction } from '../actions';
import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal';
import { EmbeddableStart } from '../../plugin';
import { EmbeddableStateTransfer, ErrorEmbeddable } from '..';
import { EmbeddableStateTransfer, ErrorEmbeddable, isSelfStyledEmbeddable } from '..';
const sortByOrderField = (
{ order: orderA }: { order?: number },
@ -296,6 +296,10 @@ export class EmbeddablePanel extends React.Component<Props, State> {
const title = this.props.embeddable.getTitle();
const headerId = this.generateId();
const selfStyledOptions = isSelfStyledEmbeddable(this.props.embeddable)
? this.props.embeddable.getSelfStyledOptions()
: undefined;
return (
<EuiPanel
className={classes}
@ -309,7 +313,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
{!this.props.hideHeader && (
<PanelHeader
getActionContextMenuPanel={this.getActionContextMenuPanel}
hidePanelTitle={this.state.hidePanelTitle}
hidePanelTitle={this.state.hidePanelTitle || !!selfStyledOptions?.hideTitle}
isViewMode={viewOnlyMode}
customizeTitle={
'customizePanelTitle' in this.state.universalActions

View file

@ -6,16 +6,5 @@
* Side Public License, v 1.
*/
import { FieldFormatsContentType, IFieldFormat } from '@kbn/field-formats-plugin/common';
export const formatValue = (
value: number | string,
fieldFormatter: IFieldFormat,
format: FieldFormatsContentType = 'text'
) => {
if (typeof value === 'number' && isNaN(value)) {
return '-';
}
return fieldFormatter.convert(value, format);
};
export type { SelfStyledEmbeddable } from './types';
export { isSelfStyledEmbeddable } from './types';

View file

@ -0,0 +1,27 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export interface SelfStyledOptions {
hideTitle?: boolean;
}
/**
* All embeddables that implement this interface will be able to configure
* the style of their containing panels
* @public
*/
export interface SelfStyledEmbeddable {
/**
* Gets the embeddable's style configuration
*/
getSelfStyledOptions: () => SelfStyledOptions;
}
export function isSelfStyledEmbeddable(incoming: unknown): incoming is SelfStyledEmbeddable {
return !!(incoming as SelfStyledEmbeddable).getSelfStyledOptions;
}

View file

@ -26,7 +26,9 @@ import {
EmbeddableInput,
SavedObjectEmbeddableInput,
ReferenceOrValueEmbeddable,
SelfStyledEmbeddable,
} from '.';
import { SelfStyledOptions } from './lib/self_styled_embeddable/types';
export { mockAttributeService } from './lib/attribute_service/attribute_service.mock';
export type Setup = jest.Mocked<EmbeddableSetup>;
@ -101,6 +103,15 @@ export const mockRefOrValEmbeddable = <
return newEmbeddable as OriginalEmbeddableType & ReferenceOrValueEmbeddable;
};
export function mockSelfStyledEmbeddable<OriginalEmbeddableType>(
embeddable: OriginalEmbeddableType,
selfStyledOptions: SelfStyledOptions
): OriginalEmbeddableType & SelfStyledEmbeddable {
const newEmbeddable: SelfStyledEmbeddable = embeddable as unknown as SelfStyledEmbeddable;
newEmbeddable.getSelfStyledOptions = () => selfStyledOptions;
return newEmbeddable as OriginalEmbeddableType & SelfStyledEmbeddable;
}
const createSetupContract = (): Setup => {
const setupContract: Setup = {
registerEmbeddableFactory: jest.fn(),
@ -147,4 +158,5 @@ export const embeddablePluginMock = {
createStartContract,
createInstance,
mockRefOrValEmbeddable,
mockSelfStyledEmbeddable,
};

View file

@ -39,6 +39,61 @@ describe('collapse_fn', () => {
expect(result.rows).toEqual([{ val: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 }]);
});
it('can use different functions for each different metric', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
{ id: 'val', name: 'val', meta: { type: 'number' } },
{ id: 'val2', name: 'val2', meta: { type: 'number' } },
{ id: 'val3', name: 'val3', meta: { type: 'number' } },
{ id: 'split', name: 'split', meta: { type: 'string' } },
],
rows: [
{ val: 1, val2: 1, val3: 1, split: 'A' },
{ val: 2, val2: 2, val3: 2, split: 'B' },
{ val: 3, val2: 3, val3: 3, split: 'B' },
{ val: 4, val2: 4, val3: 4, split: 'A' },
{ val: 5, val2: 5, val3: 5, split: 'A' },
{ val: 6, val2: 6, val3: 6, split: 'A' },
{ val: 7, val2: 7, val3: 7, split: 'B' },
{ val: 8, val2: 8, val3: 8, split: 'B' },
],
},
{ metric: ['val', 'val2', 'val3'], fn: ['sum', 'min', 'avg'] }
);
expect(result.rows).toEqual([
{
val: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8,
val2: Math.min(1, 2, 3, 4, 5, 6, 7, 8),
val3: (1 + 2 + 3 + 4 + 5 + 6 + 7 + 8) / 8,
},
]);
});
it('throws error if number of functions and metrics do not match', async () => {
expect(() =>
runFn(
{
type: 'datatable',
columns: [
{ id: 'val', name: 'val', meta: { type: 'number' } },
{ id: 'val2', name: 'val2', meta: { type: 'number' } },
{ id: 'val3', name: 'val3', meta: { type: 'number' } },
{ id: 'split', name: 'split', meta: { type: 'string' } },
],
rows: [{ val: 1, val2: 1, val3: 1, split: 'A' }],
},
{ metric: ['val', 'val2', 'val3'], fn: ['sum', 'min'] }
)
).rejects.toMatchInlineSnapshot(`
[Error: lens_collapse - Called with 3 metrics and 2 collapse functions.
Must be called with either a single collapse function for all metrics,
or a number of collapse functions matching the number of metrics.]
`);
});
const twoSplitTable: Datatable = {
type: 'datatable',
columns: [

View file

@ -17,22 +17,36 @@ function getValueAsNumberArray(value: unknown) {
}
export const collapseFn: CollapseExpressionFunction['fn'] = (input, { by, metric, fn }) => {
const collapseFunctionsByMetricIndex = Array.isArray(fn)
? fn
: metric
? new Array(metric.length).fill(fn)
: [];
if (metric && metric.length !== collapseFunctionsByMetricIndex.length) {
throw Error(`lens_collapse - Called with ${metric.length} metrics and ${fn.length} collapse functions.
Must be called with either a single collapse function for all metrics,
or a number of collapse functions matching the number of metrics.`);
}
const accumulators: Record<string, Partial<Record<string, number>>> = {};
const valueCounter: Record<string, Partial<Record<string, number>>> = {};
metric?.forEach((m) => {
accumulators[m] = {};
valueCounter[m] = {};
});
const setMarker: Partial<Record<string, boolean>> = {};
input.rows.forEach((row) => {
const bucketIdentifier = getBucketIdentifier(row, by);
metric?.forEach((m) => {
metric?.forEach((m, i) => {
const accumulatorValue = accumulators[m][bucketIdentifier];
const currentValue = row[m];
if (currentValue != null) {
const currentNumberValues = getValueAsNumberArray(currentValue);
switch (fn) {
switch (collapseFunctionsByMetricIndex[i]) {
case 'avg':
valueCounter[m][bucketIdentifier] =
(valueCounter[m][bucketIdentifier] ?? 0) + currentNumberValues.length;
@ -66,8 +80,9 @@ export const collapseFn: CollapseExpressionFunction['fn'] = (input, { by, metric
}
});
});
if (fn === 'avg') {
metric?.forEach((m) => {
metric?.forEach((m, i) => {
if (collapseFunctionsByMetricIndex[i] === 'avg') {
Object.keys(accumulators[m]).forEach((bucketIdentifier) => {
const accumulatorValue = accumulators[m][bucketIdentifier];
const valueCount = valueCounter[m][bucketIdentifier];
@ -75,8 +90,8 @@ export const collapseFn: CollapseExpressionFunction['fn'] = (input, { by, metric
accumulators[m][bucketIdentifier] = accumulatorValue / valueCount;
}
});
});
}
}
});
return {
...input,

View file

@ -9,10 +9,11 @@ import { i18n } from '@kbn/i18n';
import type { CollapseExpressionFunction } from './types';
type CollapseFunction = 'sum' | 'avg' | 'min' | 'max';
export interface CollapseArgs {
by?: string[];
metric?: string[];
fn: 'sum' | 'avg' | 'min' | 'max';
fn: CollapseFunction | CollapseFunction[];
}
/**
@ -56,6 +57,7 @@ export const collapse: CollapseExpressionFunction = {
defaultMessage: 'The aggregate function to apply',
}),
types: ['string'],
multi: true,
required: true,
},
},

View file

@ -23,6 +23,7 @@
"dataViewFieldEditor",
"dataViewEditor",
"expressionGauge",
"expressionMetricVis",
"expressionHeatmap",
"eventAnnotation",
"unifiedSearch"
@ -36,13 +37,8 @@
"spaces",
"discover"
],
"configPath": [
"xpack",
"lens"
],
"extraPublicDirs": [
"common/constants"
],
"configPath": ["xpack", "lens"],
"extraPublicDirs": ["common/constants"],
"requiredBundles": [
"unifiedSearch",
"savedObjects",

View file

@ -76,6 +76,7 @@ describe('getLayerMetaInfo', () => {
getTableSpec: jest.fn(),
getVisualDefaults: jest.fn(),
getSourceId: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
getFilters: jest.fn(),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
@ -93,6 +94,7 @@ describe('getLayerMetaInfo', () => {
getTableSpec: jest.fn(() => [{ columnId: 'col1', fields: ['bytes'] }]),
getVisualDefaults: jest.fn(),
getSourceId: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
getFilters: jest.fn(() => ({ error: 'filters error' })),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
@ -149,6 +151,7 @@ describe('getLayerMetaInfo', () => {
getTableSpec: jest.fn(() => [{ columnId: 'col1', fields: ['bytes'] }]),
getVisualDefaults: jest.fn(),
getSourceId: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
getFilters: jest.fn(() => ({
enabled: {
kuery: [[{ language: 'kuery', query: 'memory > 40000' }]],

View file

@ -18,6 +18,8 @@ export * from './datatable_visualization/datatable_visualization';
export * from './datatable_visualization';
export * from './metric_visualization/metric_visualization';
export * from './metric_visualization';
export * from './visualizations/metric/metric_visualization';
export * from './visualizations/metric';
export * from './pie_visualization/pie_visualization';
export * from './pie_visualization';
export * from './xy_visualization/xy_visualization';

View file

@ -360,6 +360,7 @@ describe('editor_frame', () => {
getVisualDefaults: jest.fn(),
getSourceId: jest.fn(),
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);

View file

@ -708,6 +708,7 @@ describe('suggestion helpers', () => {
getVisualDefaults: jest.fn(),
getSourceId: jest.fn(),
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
},
},
{ activeId: 'testVis', state: {} },
@ -742,6 +743,7 @@ describe('suggestion helpers', () => {
getVisualDefaults: jest.fn(),
getSourceId: jest.fn(),
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
},
};
defaultParams[3] = {
@ -803,6 +805,7 @@ describe('suggestion helpers', () => {
getVisualDefaults: jest.fn(),
getSourceId: jest.fn(),
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
},
};
mockVisualization1.getSuggestions.mockReturnValue([]);

View file

@ -40,6 +40,7 @@ import {
IContainer,
SavedObjectEmbeddableInput,
ReferenceOrValueEmbeddable,
SelfStyledEmbeddable,
} from '@kbn/embeddable-plugin/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { DataViewsContract, DataView } from '@kbn/data-views-plugin/public';
@ -71,7 +72,7 @@ import { getEditPath, DOC_TYPE } from '../../common';
import { LensAttributeService } from '../lens_attribute_service';
import type { ErrorMessage, TableInspectorAdapter } from '../editor_frame_service/types';
import { getLensInspectorService, LensInspector } from '../lens_inspector_service';
import { SharingSavedObjectProps } from '../types';
import { SharingSavedObjectProps, VisualizationDisplayOptions } from '../types';
import { getActiveDatasourceIdFromDoc, getIndexPatternsObjects, inferTimeField } from '../utils';
import { getLayerMetaInfo, combineQueryAndFilters } from '../app_plugin/show_underlying_data';
@ -210,7 +211,9 @@ function getViewUnderlyingDataArgs({
export class Embeddable
extends AbstractEmbeddable<LensEmbeddableInput, LensEmbeddableOutput>
implements ReferenceOrValueEmbeddable<LensByValueInput, LensByReferenceInput>
implements
ReferenceOrValueEmbeddable<LensByValueInput, LensByReferenceInput>,
SelfStyledEmbeddable
{
type = DOC_TYPE;
@ -600,6 +603,7 @@ export class Embeddable
onRuntimeError={() => {
this.logError('runtime');
}}
noPadding={this.visDisplayOptions?.noPadding}
/>
</KibanaThemeProvider>,
domNode
@ -885,4 +889,20 @@ export class Embeddable
this.subscription.unsubscribe();
}
}
public getSelfStyledOptions() {
return {
hideTitle: this.visDisplayOptions?.noPanelTitle,
};
}
private get visDisplayOptions(): VisualizationDisplayOptions | undefined {
if (
!this.savedVis?.visualizationType ||
!this.deps.visualizationMap[this.savedVis.visualizationType].getDisplayOptions
) {
return;
}
return this.deps.visualizationMap[this.savedVis.visualizationType].getDisplayOptions!();
}
}

View file

@ -46,6 +46,7 @@ export interface ExpressionWrapperProps {
onRuntimeError: () => void;
executionContext?: KibanaExecutionContext;
lensInspector: LensInspector;
noPadding?: boolean;
}
interface VisualizationErrorProps {
@ -120,6 +121,7 @@ export function ExpressionWrapper({
onRuntimeError,
executionContext,
lensInspector,
noPadding,
}: ExpressionWrapperProps) {
return (
<I18nProvider>
@ -129,7 +131,7 @@ export function ExpressionWrapper({
<div className={classNames('lnsExpressionRenderer', className)} style={style}>
<ExpressionRendererComponent
className="lnsExpressionRenderer__component"
padding="s"
padding={noPadding ? undefined : 's'}
variables={variables}
expression={expression}
interactive={interactive}

View file

@ -2334,6 +2334,25 @@ describe('IndexPattern Data Source', () => {
});
});
});
describe('getMaxPossibleNumValues', () => {
it('should pass it on to the operation when available', () => {
const prediction = 23;
const operationPredictSpy = jest
.spyOn(operationDefinitionMap.terms, 'getMaxPossibleNumValues')
.mockReturnValue(prediction);
const columnId = 'col1';
expect(publicAPI.getMaxPossibleNumValues(columnId)).toEqual(prediction);
expect(operationPredictSpy).toHaveBeenCalledWith(
expect.objectContaining({ operationType: 'terms' })
);
});
it('should default to null', () => {
expect(publicAPI.getMaxPossibleNumValues('non-existant')).toEqual(null);
});
});
});
describe('#getErrorMessages', () => {

View file

@ -63,6 +63,7 @@ import {
GenericIndexPatternColumn,
getErrorMessages,
insertNewColumn,
operationDefinitionMap,
TermsIndexPatternColumn,
} from './operations';
import { getReferenceRoot } from './operations/layer_helpers';
@ -574,6 +575,15 @@ export function getIndexPatternDatasource({
timeRange
),
getVisualDefaults: () => getVisualDefaultsForLayer(layer),
getMaxPossibleNumValues: (columnId) => {
if (layer && layer.columns[columnId]) {
const column = layer.columns[columnId];
return (
operationDefinitionMap[column.operationType].getMaxPossibleNumValues?.(column) ?? null
);
}
return null;
},
};
},
getDatasourceSuggestionsForField(state, draggedField, filterLayers) {

View file

@ -423,4 +423,12 @@ describe('filters', () => {
});
});
});
describe('getMaxPossibleNumValues', () => {
it('reports number of filters', () => {
expect(
filtersOperation.getMaxPossibleNumValues!(layer.columns.col1 as FiltersIndexPatternColumn)
).toBe(2);
});
});
});

View file

@ -171,6 +171,8 @@ export const filtersOperation: OperationDefinition<FiltersIndexPatternColumn, 'n
</EuiFormRow>
);
},
getMaxPossibleNumValues: (column) => column.params.filters.length,
};
export const FilterList = ({

View file

@ -411,6 +411,13 @@ interface BaseOperationDefinitionProps<
aggs: ExpressionAstExpressionBuilder[];
esAggsIdMap: Record<string, OriginalColumn[]>;
};
/**
* Returns the maximum possible number of values for this column
* (e.g. with a top 5 values operation, we can be sure that there will never be
* more than 5 values returned or 6 if the "Other" bucket is enabled)
*/
getMaxPossibleNumValues?: (column: C) => number;
}
interface BaseBuildColumnArgs {

View file

@ -1047,6 +1047,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
</>
);
},
getMaxPossibleNumValues: (column) => column.params.size + (column.params.otherBucket ? 1 : 0),
};
function getLabelForRankFunctions(operationType: string) {
switch (operationType) {

View file

@ -34,6 +34,7 @@ import { DateHistogramIndexPatternColumn } from '../date_histogram';
import { getOperationSupportMatrix } from '../../../dimension_panel/operation_support';
import { FieldSelect } from '../../../dimension_panel/field_select';
import { ReferenceEditor } from '../../../dimension_panel/reference_editor';
import { cloneDeep } from 'lodash';
import { IncludeExcludeRow } from './include_exclude_options';
// mocking random id generator function
@ -3074,4 +3075,20 @@ describe('terms', () => {
).toEqual(['unsupported']);
});
});
describe('getMaxPossibleNumValues', () => {
it('reports correct number of values', () => {
const termsSize = 5;
const withoutOther = cloneDeep(layer.columns.col1 as TermsIndexPatternColumn);
withoutOther.params.size = termsSize;
withoutOther.params.otherBucket = false;
const withOther = cloneDeep(withoutOther);
withOther.params.otherBucket = true;
expect(termsOperation.getMaxPossibleNumValues!(withoutOther)).toBe(termsSize);
expect(termsOperation.getMaxPossibleNumValues!(withOther)).toBe(termsSize + 1);
});
});
});

View file

@ -79,13 +79,13 @@ export function MetricDimensionEditor(
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.metric.dynamicColoring.label', {
label={i18n.translate('xpack.lens.legacyMetric.dynamicColoring.label', {
defaultMessage: 'Color by value',
})}
>
<EuiButtonGroup
isFullWidth
legend={i18n.translate('xpack.lens.metric.dynamicColoring.label', {
legend={i18n.translate('xpack.lens.legacyMetric.dynamicColoring.label', {
defaultMessage: 'Color by value',
})}
data-test-subj="lnsMetric_dynamicColoring_groups"
@ -94,21 +94,21 @@ export function MetricDimensionEditor(
options={[
{
id: `${idPrefix}None`,
label: i18n.translate('xpack.lens.metric.dynamicColoring.none', {
label: i18n.translate('xpack.lens.legacyMetric.dynamicColoring.none', {
defaultMessage: 'None',
}),
'data-test-subj': 'lnsMetric_dynamicColoring_groups_none',
},
{
id: `${idPrefix}Background`,
label: i18n.translate('xpack.lens.metric.dynamicColoring.background', {
label: i18n.translate('xpack.lens.legacyMetric.dynamicColoring.background', {
defaultMessage: 'Fill',
}),
'data-test-subj': 'lnsMetric_dynamicColoring_groups_background',
},
{
id: `${idPrefix}Labels`,
label: i18n.translate('xpack.lens.metric.dynamicColoring.text', {
label: i18n.translate('xpack.lens.legacyMetric.dynamicColoring.text', {
defaultMessage: 'Text',
}),
'data-test-subj': 'lnsMetric_dynamicColoring_groups_labels',

View file

@ -9,15 +9,17 @@ import type { CoreSetup } from '@kbn/core/public';
import type { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import type { EditorFrameSetup } from '../types';
export interface MetricVisualizationPluginSetupPlugins {
export interface LegacyMetricVisualizationPluginSetupPlugins {
editorFrame: EditorFrameSetup;
charts: ChartsPluginSetup;
}
export class MetricVisualization {
setup(core: CoreSetup, { editorFrame, charts }: MetricVisualizationPluginSetupPlugins) {
export class LegacyMetricVisualization {
setup(core: CoreSetup, { editorFrame, charts }: LegacyMetricVisualizationPluginSetupPlugins) {
editorFrame.registerVisualization(async () => {
const { getMetricVisualization } = await import('../async_services');
const { getLegacyMetricVisualization: getMetricVisualization } = await import(
'../async_services'
);
const palettes = await charts.palettes.getPalettes();
return getMetricVisualization({ paletteService: palettes, theme: core.theme });

View file

@ -20,21 +20,21 @@ export const DEFAULT_TEXT_ALIGNMENT = 'left';
const alignButtonIcons = [
{
id: `left`,
label: i18n.translate('xpack.lens.metricChart.alignLabel.left', {
label: i18n.translate('xpack.lens.legacyMetric.alignLabel.left', {
defaultMessage: 'Align left',
}),
iconType: 'editorAlignLeft',
},
{
id: `center`,
label: i18n.translate('xpack.lens.metricChart.alignLabel.center', {
label: i18n.translate('xpack.lens.legacyMetric.alignLabel.center', {
defaultMessage: 'Align center',
}),
iconType: 'editorAlignCenter',
},
{
id: `right`,
label: i18n.translate('xpack.lens.metricChart.alignLabel.right', {
label: i18n.translate('xpack.lens.legacyMetric.alignLabel.right', {
defaultMessage: 'Align right',
}),
iconType: 'editorAlignRight',
@ -44,7 +44,7 @@ const alignButtonIcons = [
export const AlignOptions: React.FC<TitlePositionProps> = ({ state, setState }) => {
return (
<EuiButtonGroup
legend={i18n.translate('xpack.lens.metricChart.titleAlignLabel', {
legend={i18n.translate('xpack.lens.legacyMetric.titleAlignLabel', {
defaultMessage: 'Align',
})}
options={alignButtonIcons}

View file

@ -20,37 +20,37 @@ export const DEFAULT_TITLE_SIZE = 'm';
const titleSizes = [
{
id: 'xs',
label: i18n.translate('xpack.lens.metricChart.metricSize.extraSmall', {
label: i18n.translate('xpack.lens.legacyMetric.metricSize.extraSmall', {
defaultMessage: 'XS',
}),
},
{
id: 's',
label: i18n.translate('xpack.lens.metricChart.metricSize.small', {
label: i18n.translate('xpack.lens.legacyMetric.metricSize.small', {
defaultMessage: 'S',
}),
},
{
id: 'm',
label: i18n.translate('xpack.lens.metricChart.metricSize.medium', {
label: i18n.translate('xpack.lens.legacyMetric.metricSize.medium', {
defaultMessage: 'M',
}),
},
{
id: 'l',
label: i18n.translate('xpack.lens.metricChart.metricSize.large', {
label: i18n.translate('xpack.lens.legacyMetric.metricSize.large', {
defaultMessage: 'L',
}),
},
{
id: 'xl',
label: i18n.translate('xpack.lens.metricChart.metricSize.extraLarge', {
label: i18n.translate('xpack.lens.legacyMetric.metricSize.extraLarge', {
defaultMessage: 'XL',
}),
},
{
id: 'xxl',
label: i18n.translate('xpack.lens.metricChart.metricSize.xxl', {
label: i18n.translate('xpack.lens.legacyMetric.metricSize.xxl', {
defaultMessage: 'XXL',
}),
},

View file

@ -24,7 +24,7 @@ export const TextFormattingOptions: React.FC<TitlePositionProps> = ({ state, set
display="columnCompressed"
label={
<>
{i18n.translate('xpack.lens.metricChart.textFormattingLabel', {
{i18n.translate('xpack.lens.legacyMetric.textFormattingLabel', {
defaultMessage: 'Text formatting',
})}
</>

View file

@ -20,11 +20,13 @@ export const DEFAULT_TITLE_POSITION = 'top';
const titlePositions = [
{
id: 'top',
label: i18n.translate('xpack.lens.metricChart.titlePositions.top', { defaultMessage: 'Top' }),
label: i18n.translate('xpack.lens.legacyMetric.titlePositions.top', {
defaultMessage: 'Top',
}),
},
{
id: 'bottom',
label: i18n.translate('xpack.lens.metricChart.titlePositions.bottom', {
label: i18n.translate('xpack.lens.legacyMetric.titlePositions.bottom', {
defaultMessage: 'Bottom',
}),
},
@ -37,7 +39,7 @@ export const TitlePositionOptions: React.FC<TitlePositionProps> = ({ state, setS
fullWidth
label={
<>
{i18n.translate('xpack.lens.metricChart.titlePositionLabel', {
{i18n.translate('xpack.lens.legacyMetric.titlePositionLabel', {
defaultMessage: 'Title position',
})}
</>

View file

@ -9,7 +9,7 @@ import { SuggestionRequest, VisualizationSuggestion, TableSuggestion } from '../
import type { MetricState } from '../../common/types';
import { layerTypes } from '../../common';
import { LensIconChartMetric } from '../assets/chart_metric';
import { supportedTypes } from './visualization';
import { legacyMetricSupportedTypes } from './visualization';
/**
* Generate suggestions for the metric chart.
@ -28,7 +28,7 @@ export function getSuggestions({
(keptLayerIds.length && table.layerId !== keptLayerIds[0]) ||
table.columns.length !== 1 ||
table.columns[0].operation.isBucketed ||
!supportedTypes.has(table.columns[0].operation.dataType) ||
!legacyMetricSupportedTypes.has(table.columns[0].operation.dataType) ||
table.columns[0].operation.isStaticValue
) {
return [];

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { getMetricVisualization } from './visualization';
import { getLegacyMetricVisualization } from './visualization';
import type { MetricState } from '../../common/types';
import { layerTypes } from '../../common';
import { createMockDatasource, createMockFramePublicAPI } from '../mocks';
@ -35,7 +35,7 @@ function mockFrame(): FramePublicAPI {
};
}
const metricVisualization = getMetricVisualization({
const metricVisualization = getLegacyMetricVisualization({
paletteService: chartPluginMock.createPaletteRegistry(),
theme: themeServiceMock.createStartContract(),
});

View file

@ -3,9 +3,7 @@
* 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 React from 'react';
import { i18n } from '@kbn/i18n';
import { I18nProvider } from '@kbn/i18n-react';
import { euiThemeVars } from '@kbn/ui-theme';
@ -36,7 +34,7 @@ interface MetricConfig extends Omit<MetricState, 'palette' | 'colorMode'> {
palette: PaletteOutput<CustomPaletteState>;
}
export const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']);
export const legacyMetricSupportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']);
const getFontSizeAndUnit = (fontSize: string) => {
const [size, sizeUnit] = fontSize.split(/(\d+)/).filter(Boolean);
@ -171,7 +169,7 @@ const toExpression = (
};
};
export const getMetricVisualization = ({
export const getLegacyMetricVisualization = ({
paletteService,
theme,
}: {
@ -184,13 +182,12 @@ export const getMetricVisualization = ({
{
id: 'lnsMetric',
icon: LensIconChartMetric,
label: i18n.translate('xpack.lens.metric.label', {
defaultMessage: 'Metric',
label: i18n.translate('xpack.lens.legacyMetric.label', {
defaultMessage: 'Legacy Metric',
}),
groupLabel: i18n.translate('xpack.lens.metric.groupLabel', {
groupLabel: i18n.translate('xpack.lens.legacyMetric.groupLabel', {
defaultMessage: 'Goal and single value',
}),
sortPriority: 3,
},
],
@ -212,8 +209,8 @@ export const getMetricVisualization = ({
getDescription() {
return {
icon: LensIconChartMetric,
label: i18n.translate('xpack.lens.metric.label', {
defaultMessage: 'Metric',
label: i18n.translate('xpack.lens.legacyMetric.label', {
defaultMessage: 'Legacy Metric',
}),
};
},
@ -238,7 +235,9 @@ export const getMetricVisualization = ({
groups: [
{
groupId: 'metric',
groupLabel: i18n.translate('xpack.lens.metric.label', { defaultMessage: 'Metric' }),
groupLabel: i18n.translate('xpack.lens.legacyMetric.label', {
defaultMessage: 'Legacy Metric',
}),
layerId: props.state.layerId,
accessors: props.state.accessor
? [
@ -251,7 +250,7 @@ export const getMetricVisualization = ({
: [],
supportsMoreColumns: !props.state.accessor,
filterOperations: (op: OperationMetadata) =>
!op.isBucketed && supportedTypes.has(op.dataType),
!op.isBucketed && legacyMetricSupportedTypes.has(op.dataType),
enableDimensionEditor: true,
required: true,
},
@ -263,7 +262,7 @@ export const getMetricVisualization = ({
return [
{
type: layerTypes.DATA,
label: i18n.translate('xpack.lens.metric.addLayer', {
label: i18n.translate('xpack.lens.legacyMetric.addLayer', {
defaultMessage: 'Visualization',
}),
},

View file

@ -19,6 +19,7 @@ export function createMockDatasource(id: string): DatasourceMock {
getVisualDefaults: jest.fn(),
getSourceId: jest.fn(),
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
};
return {

View file

@ -56,9 +56,10 @@ import type {
XyVisualizationPluginSetupPlugins,
} from './xy_visualization';
import type {
MetricVisualization as MetricVisualizationType,
MetricVisualizationPluginSetupPlugins,
LegacyMetricVisualization as LegacyMetricVisualizationType,
LegacyMetricVisualizationPluginSetupPlugins,
} from './metric_visualization';
import type { MetricVisualization as MetricVisualizationType } from './visualizations/metric';
import type {
DatatableVisualization as DatatableVisualizationType,
DatatableVisualizationPluginSetupPlugins,
@ -223,6 +224,7 @@ export class LensPlugin {
private queuedVisualizations: Array<Visualization | (() => Promise<Visualization>)> = [];
private indexpatternDatasource: IndexPatternDatasourceType | undefined;
private xyVisualization: XyVisualizationType | undefined;
private legacyMetricVisualization: LegacyMetricVisualizationType | undefined;
private metricVisualization: MetricVisualizationType | undefined;
private pieVisualization: PieVisualizationType | undefined;
private heatmapVisualization: HeatmapVisualizationType | undefined;
@ -400,6 +402,7 @@ export class LensPlugin {
EditorFrameService,
IndexPatternDatasource,
XyVisualization,
LegacyMetricVisualization,
MetricVisualization,
PieVisualization,
HeatmapVisualization,
@ -409,6 +412,7 @@ export class LensPlugin {
this.editorFrameService = new EditorFrameService();
this.indexpatternDatasource = new IndexPatternDatasource();
this.xyVisualization = new XyVisualization();
this.legacyMetricVisualization = new LegacyMetricVisualization();
this.metricVisualization = new MetricVisualization();
this.pieVisualization = new PieVisualization();
this.heatmapVisualization = new HeatmapVisualization();
@ -419,7 +423,7 @@ export class LensPlugin {
const dependencies: IndexPatternDatasourceSetupPlugins &
XyVisualizationPluginSetupPlugins &
DatatableVisualizationPluginSetupPlugins &
MetricVisualizationPluginSetupPlugins &
LegacyMetricVisualizationPluginSetupPlugins &
PieVisualizationPluginSetupPlugins = {
expressions,
data,
@ -432,6 +436,7 @@ export class LensPlugin {
this.indexpatternDatasource.setup(core, dependencies);
this.xyVisualization.setup(core, dependencies);
this.datatableVisualization.setup(core, dependencies);
this.legacyMetricVisualization.setup(core, dependencies);
this.metricVisualization.setup(core, dependencies);
this.pieVisualization.setup(core, dependencies);
this.heatmapVisualization.setup(core, dependencies);

View file

@ -394,6 +394,13 @@ export interface DatasourcePublicAPI {
lucene: Query[][];
}
>;
/**
* Returns the maximum possible number of values for this column when it can be known, otherwise null
* (e.g. with a top 5 values operation, we can be sure that there will never be more than 5 values returned
* or 6 if the "Other" bucket is enabled)
*/
getMaxPossibleNumValues: (columnId: string) => number | null;
}
export interface DatasourceDataPanelProps<T = unknown> {
@ -762,6 +769,11 @@ export interface VisualizationType {
showExperimentalBadge?: boolean;
}
export interface VisualizationDisplayOptions {
noPanelTitle?: boolean;
noPadding?: boolean;
}
export interface Visualization<T = unknown> {
/** Plugin ID, such as "lnsXY" */
id: string;
@ -959,6 +971,11 @@ export interface Visualization<T = unknown> {
*/
onEditAction?: (state: T, event: LensEditEvent<LensEditSupportedActions>) => T;
/**
* Gets custom display options for showing the visualization.
*/
getDisplayOptions?: () => VisualizationDisplayOptions;
/**
* Get RenderEventCounters events for telemetry
*/

View file

@ -27,4 +27,4 @@
.lnsSelectableErrorMessage {
user-select: text;
}
}

View file

@ -0,0 +1,113 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`metric visualization dimension groups configuration generates configuration 1`] = `
Object {
"groups": Array [
Object {
"accessors": Array [
Object {
"columnId": "metric-col-id",
"palette": Array [],
"triggerIcon": "colorBy",
},
],
"enableDimensionEditor": true,
"filterOperations": [Function],
"groupId": "metric",
"groupLabel": "Primary metric",
"layerId": "first",
"required": true,
"supportFieldFormat": false,
"supportsMoreColumns": false,
},
Object {
"accessors": Array [
Object {
"columnId": "secondary-metric-col-id",
},
],
"enableDimensionEditor": true,
"filterOperations": [Function],
"groupId": "secondaryMetric",
"groupLabel": "Secondary metric",
"layerId": "first",
"required": false,
"supportFieldFormat": false,
"supportsMoreColumns": false,
},
Object {
"accessors": Array [
Object {
"columnId": "max-metric-col-id",
},
],
"enableDimensionEditor": true,
"filterOperations": [Function],
"groupId": "max",
"groupLabel": "Maximum value",
"groupTooltip": "If the maximum value is specified, the minimum value is fixed at zero.",
"layerId": "first",
"required": false,
"supportFieldFormat": false,
"supportStaticValue": true,
"supportsMoreColumns": false,
},
Object {
"accessors": Array [
Object {
"columnId": "breakdown-col-id",
"triggerIcon": "aggregate",
},
],
"enableDimensionEditor": true,
"filterOperations": [Function],
"groupId": "breakdownBy",
"groupLabel": "Break down by",
"layerId": "first",
"required": false,
"supportFieldFormat": false,
"supportsMoreColumns": false,
},
],
}
`;
exports[`metric visualization dimension groups configuration operation filtering breakdownBy supports correct operations 1`] = `
Array [
Object {
"dataType": "number",
"isBucketed": true,
},
Object {
"dataType": "string",
"isBucketed": true,
},
]
`;
exports[`metric visualization dimension groups configuration operation filtering max supports correct operations 1`] = `
Array [
Object {
"dataType": "number",
"isBucketed": false,
},
]
`;
exports[`metric visualization dimension groups configuration operation filtering metric supports correct operations 1`] = `
Array [
Object {
"dataType": "number",
"isBucketed": false,
},
]
`;
exports[`metric visualization dimension groups configuration operation filtering secondaryMetric supports correct operations 1`] = `
Array [
Object {
"dataType": "number",
"isBucketed": false,
},
]
`;

View file

@ -0,0 +1,15 @@
/*
* 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 const LENS_METRIC_ID = 'lnsMetricNew'; // TODO - rename old one to "legacy"
export const GROUP_ID = {
METRIC: 'metric',
SECONDARY_METRIC: 'secondaryMetric',
MAX: 'max',
BREAKDOWN_BY: 'breakdownBy',
} as const;

View file

@ -0,0 +1,397 @@
/*
* 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.
*/
/* eslint-disable max-classes-per-file */
import React, { ChangeEvent, FormEvent } from 'react';
import { VisualizationDimensionEditorProps } from '../../types';
import { CustomPaletteParams, PaletteOutput, PaletteRegistry } from '@kbn/coloring';
import { MetricVisualizationState } from './visualization';
import { DimensionEditor } from './dimension_editor';
import { HTMLAttributes, ReactWrapper, shallow } from 'enzyme';
import { CollapseSetting } from '../../shared_components/collapse_setting';
import { EuiButtonGroup, EuiColorPicker, EuiFieldText } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { LayoutDirection } from '@elastic/charts';
import { act } from 'react-dom/test-utils';
import { EuiColorPickerOutput } from '@elastic/eui/src/components/color_picker/color_picker';
import { createMockFramePublicAPI } from '../../mocks';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
jest.mock('lodash', () => {
const original = jest.requireActual('lodash');
return {
...original,
debounce: (fn: unknown) => fn,
};
});
const SELECTORS = {
PRIMARY_METRIC_EDITOR: '[data-test-subj="lnsMetricDimensionEditor_primary_metric"]',
SECONDARY_METRIC_EDITOR: '[data-test-subj="lnsMetricDimensionEditor_secondary_metric"]',
BREAKDOWN_EDITOR: '[data-test-subj="lnsMetricDimensionEditor_breakdown"]',
};
describe('dimension editor', () => {
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',
palette,
};
const mockedFrame = createMockFramePublicAPI();
const props: VisualizationDimensionEditorProps<MetricVisualizationState> & {
paletteService: PaletteRegistry;
} = {
layerId: 'first',
groupId: 'some-group',
accessor: 'some-accessor',
state: fullState,
frame: mockedFrame,
setState: jest.fn(),
panelRef: {} as React.MutableRefObject<HTMLDivElement | null>,
paletteService: chartPluginMock.createPaletteRegistry(),
};
describe('primary metric dimension', () => {
const accessor = 'primary-metric-col-id';
props.frame.activeData = {
first: {
type: 'datatable',
columns: [
{
id: accessor,
name: 'foo',
meta: {
type: 'number',
},
},
],
rows: [],
},
};
class Harness {
public _wrapper;
constructor(
wrapper: ReactWrapper<HTMLAttributes, unknown, React.Component<{}, {}, unknown>>
) {
this._wrapper = wrapper;
}
private get rootComponent() {
return this._wrapper.find(DimensionEditor);
}
public get colorPicker() {
return this._wrapper.find(EuiColorPicker);
}
public get currentState() {
return this.rootComponent.props().state;
}
public setColor(color: string) {
act(() => {
this.colorPicker.props().onChange!(color, {} as EuiColorPickerOutput);
});
}
}
const mockSetState = jest.fn();
const getHarnessWithState = (state: MetricVisualizationState) =>
new Harness(
mountWithIntl(
<DimensionEditor
{...props}
state={{ ...state, metricAccessor: accessor }}
setState={mockSetState}
accessor={accessor}
/>
)
);
it('renders when the accessor matches', () => {
const component = shallow(
<DimensionEditor
{...props}
state={{ ...fullState, metricAccessor: accessor }}
accessor={accessor}
/>
);
expect(component.exists(SELECTORS.PRIMARY_METRIC_EDITOR)).toBeTruthy();
expect(component.exists(SELECTORS.SECONDARY_METRIC_EDITOR)).toBeFalsy();
expect(component.exists(SELECTORS.BREAKDOWN_EDITOR)).toBeFalsy();
});
describe('static color controls', () => {
it('is hidden when dynamic coloring is enabled', () => {
const harnessWithPalette = getHarnessWithState({ ...fullState, palette });
expect(harnessWithPalette.colorPicker.exists()).toBeFalsy();
const harnessNoPalette = getHarnessWithState({ ...fullState, palette: undefined });
expect(harnessNoPalette.colorPicker.exists()).toBeTruthy();
});
it('fills placeholder with default value', () => {
const localHarness = getHarnessWithState({
...fullState,
palette: undefined,
color: undefined,
});
expect(localHarness.colorPicker.props().placeholder).toBe('Auto');
});
it('sets color', () => {
const localHarness = getHarnessWithState({
...fullState,
palette: undefined,
color: 'some-color',
});
const newColor = 'new-color';
localHarness.setColor(newColor + 1);
localHarness.setColor(newColor + 2);
localHarness.setColor(newColor + 3);
localHarness.setColor('');
expect(mockSetState).toHaveBeenCalledTimes(4);
expect(mockSetState.mock.calls.map((args) => args[0].color)).toMatchInlineSnapshot(`
Array [
"new-color1",
"new-color2",
"new-color3",
undefined,
]
`);
});
});
});
describe('secondary metric dimension', () => {
const accessor = 'secondary-metric-col-id';
it('renders when the accessor matches', () => {
const component = shallow(
<DimensionEditor
{...props}
state={{ ...fullState, secondaryMetricAccessor: accessor }}
accessor={accessor}
/>
);
expect(component.exists(SELECTORS.SECONDARY_METRIC_EDITOR)).toBeTruthy();
expect(component.exists(SELECTORS.BREAKDOWN_EDITOR)).toBeFalsy();
expect(component.exists(SELECTORS.PRIMARY_METRIC_EDITOR)).toBeFalsy();
});
it('sets metric prefix', () => {
const setState = jest.fn();
const localState = { ...fullState, secondaryMetricAccessor: accessor };
const component = shallow(
<DimensionEditor {...props} state={localState} setState={setState} accessor={accessor} />
);
const newVal = 'Metric explanation';
component.find(EuiFieldText).props().onChange!({
target: { value: newVal },
} as ChangeEvent<HTMLInputElement>);
expect(setState).toHaveBeenCalledWith({ ...localState, secondaryPrefix: newVal });
});
});
describe('maximum dimension', () => {
const accessor = 'maximum-col-id';
class Harness {
public _wrapper;
constructor(
wrapper: ReactWrapper<HTMLAttributes, unknown, React.Component<{}, {}, unknown>>
) {
this._wrapper = wrapper;
}
private get rootComponent() {
return this._wrapper.find(DimensionEditor);
}
private get progressDirectionControl() {
return this._wrapper.find(EuiButtonGroup);
}
public get currentState() {
return this.rootComponent.props().state;
}
public setProgressDirection(direction: LayoutDirection) {
this.progressDirectionControl.props().onChange(direction);
this._wrapper.update();
}
public get progressDirectionDisabled() {
return this.progressDirectionControl.find(EuiButtonGroup).props().isDisabled;
}
public setMaxCols(max: number) {
act(() => {
this._wrapper.find('EuiFieldNumber[data-test-subj="lnsMetric_max_cols"]').props()
.onChange!({
target: { value: String(max) },
} as unknown as FormEvent);
});
}
}
let harness: Harness;
const mockSetState = jest.fn();
beforeEach(() => {
harness = new Harness(
mountWithIntl(
<DimensionEditor
{...props}
state={{ ...fullState, maxAccessor: accessor }}
accessor={accessor}
setState={mockSetState}
/>
)
);
});
afterEach(() => mockSetState.mockClear());
it('toggles progress direction', () => {
expect(harness.currentState.progressDirection).toBe('vertical');
harness.setProgressDirection('horizontal');
harness.setProgressDirection('vertical');
harness.setProgressDirection('horizontal');
expect(mockSetState).toHaveBeenCalledTimes(3);
expect(mockSetState.mock.calls.map((args) => args[0].progressDirection))
.toMatchInlineSnapshot(`
Array [
"horizontal",
"vertical",
"horizontal",
]
`);
});
});
describe('breakdown-by dimension', () => {
const accessor = 'breakdown-col-id';
class Harness {
public _wrapper;
constructor(
wrapper: ReactWrapper<HTMLAttributes, unknown, React.Component<{}, {}, unknown>>
) {
this._wrapper = wrapper;
}
private get collapseSetting() {
return this._wrapper.find(CollapseSetting);
}
public get currentCollapseFn() {
return this.collapseSetting.props().value;
}
public setCollapseFn(fn: string) {
return this.collapseSetting.props().onChange(fn);
}
public setMaxCols(max: number) {
act(() => {
this._wrapper.find('EuiFieldNumber[data-test-subj="lnsMetric_max_cols"]').props()
.onChange!({
target: { value: String(max) },
} as unknown as FormEvent);
});
}
}
let harness: Harness;
const mockSetState = jest.fn();
beforeEach(() => {
harness = new Harness(
mountWithIntl(
<DimensionEditor
{...props}
state={{ ...fullState, breakdownByAccessor: accessor }}
accessor={accessor}
setState={mockSetState}
/>
)
);
});
afterEach(() => mockSetState.mockClear());
it('renders when the accessor matches', () => {
const component = shallow(
<DimensionEditor
{...props}
state={{ ...fullState, breakdownByAccessor: accessor }}
accessor={accessor}
/>
);
expect(component.exists(SELECTORS.BREAKDOWN_EDITOR)).toBeTruthy();
expect(component.exists(SELECTORS.SECONDARY_METRIC_EDITOR)).toBeFalsy();
expect(component.exists(SELECTORS.PRIMARY_METRIC_EDITOR)).toBeFalsy();
});
it('supports setting a collapse function', () => {
expect(harness.currentCollapseFn).toBe(fullState.collapseFn);
const newCollapseFunc = 'min';
harness.setCollapseFn(newCollapseFunc);
expect(mockSetState).toHaveBeenCalledWith({ ...fullState, collapseFn: newCollapseFunc });
});
it('sets max columns', () => {
harness.setMaxCols(1);
harness.setMaxCols(2);
harness.setMaxCols(3);
expect(mockSetState).toHaveBeenCalledTimes(3);
expect(mockSetState.mock.calls.map((args) => args[0].maxCols)).toMatchInlineSnapshot(`
Array [
1,
2,
3,
]
`);
});
});
});

View file

@ -0,0 +1,405 @@
/*
* 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 {
EuiButtonEmpty,
EuiFlexGroup,
EuiColorPaletteDisplay,
EuiFormRow,
EuiFlexItem,
EuiFieldText,
EuiButtonGroup,
EuiFieldNumber,
htmlIdGenerator,
EuiColorPicker,
euiPaletteColorBlind,
} from '@elastic/eui';
import { LayoutDirection } from '@elastic/charts';
import React, { useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
PaletteRegistry,
CustomizablePalette,
FIXED_PROGRESSION,
DEFAULT_MAX_STOP,
DEFAULT_MIN_STOP,
} from '@kbn/coloring';
import { getDataBoundsForPalette } from '@kbn/expression-metric-vis-plugin/public';
import { css } from '@emotion/react';
import { isNumericFieldForDatatable } from '../../../common/expressions';
import {
applyPaletteParams,
PalettePanelContainer,
useDebouncedValue,
} from '../../shared_components';
import type { VisualizationDimensionEditorProps } from '../../types';
import { defaultNumberPaletteParams, defaultPercentagePaletteParams } from './palette_config';
import { DEFAULT_MAX_COLUMNS, MetricVisualizationState } from './visualization';
import { CollapseSetting } from '../../shared_components/collapse_setting';
type Props = VisualizationDimensionEditorProps<MetricVisualizationState> & {
paletteService: PaletteRegistry;
};
export function DimensionEditor(props: Props) {
const { state, setState, accessor } = props;
const setPrefix = useCallback(
(prefix: string) => setState({ ...state, secondaryPrefix: prefix }),
[setState, state]
);
const { inputValue: prefixInputVal, handleInputChange: handlePrefixChange } =
useDebouncedValue<string>(
{
onChange: setPrefix,
value: state.secondaryPrefix || '',
},
{ allowFalsyValue: true }
);
switch (accessor) {
case state?.metricAccessor:
return (
<div data-test-subj="lnsMetricDimensionEditor_primary_metric">
<PrimaryMetricEditor {...props} />
</div>
);
case state.secondaryMetricAccessor:
return (
<div data-test-subj="lnsMetricDimensionEditor_secondary_metric">
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.metric.prefixText.label', {
defaultMessage: 'Prefix',
})}
>
<EuiFieldText
compressed
value={prefixInputVal}
onChange={({ target: { value } }) => handlePrefixChange(value)}
/>
</EuiFormRow>
</div>
);
case state.maxAccessor:
return (
<div data-test-subj="lnsMetricDimensionEditor_secondary_metric">
<MaximumEditor {...props} />
</div>
);
case state.breakdownByAccessor:
return (
<div data-test-subj="lnsMetricDimensionEditor_breakdown">
<BreakdownByEditor {...props} />
</div>
);
default:
return null;
}
}
function BreakdownByEditor({ setState, state }: Props) {
const setMaxCols = useCallback(
(columns: string) => {
setState({ ...state, maxCols: parseInt(columns, 10) });
},
[setState, state]
);
const { inputValue: currentMaxCols, handleInputChange: handleMaxColsChange } =
useDebouncedValue<string>({
onChange: setMaxCols,
value: String(state.maxCols ?? DEFAULT_MAX_COLUMNS),
});
return (
<>
<EuiFormRow
label={i18n.translate('xpack.lens.metric.maxColumns', {
defaultMessage: 'Layout columns',
})}
fullWidth
display="columnCompressed"
>
<EuiFieldNumber
compressed={true}
min={1}
data-test-subj="lnsMetric_max_cols"
value={currentMaxCols}
onChange={({ target: { value } }) => handleMaxColsChange(value)}
/>
</EuiFormRow>
<CollapseSetting
value={state.collapseFn || ''}
onChange={(collapseFn: string) => {
setState({
...state,
collapseFn,
});
}}
/>
</>
);
}
function MaximumEditor({ setState, state }: Props) {
const idPrefix = htmlIdGenerator()();
return (
<EuiFormRow
label={i18n.translate('xpack.lens.metric.progressDirectionLabel', {
defaultMessage: 'Bar direction',
})}
fullWidth
display="columnCompressed"
>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={i18n.translate('xpack.lens.metric.progressDirectionLabel', {
defaultMessage: 'Bar direction',
})}
data-test-subj="lnsMetric_progress_direction_buttons"
name="alignment"
options={[
{
id: `${idPrefix}vertical`,
label: i18n.translate('xpack.lens.metric.progressDirection.vertical', {
defaultMessage: 'Vertical',
}),
'data-test-subj': 'lnsMetric_progress_bar_vertical',
},
{
id: `${idPrefix}horizontal`,
label: i18n.translate('xpack.lens.metric.progressDirection.horizontal', {
defaultMessage: 'Horizontal',
}),
'data-test-subj': 'lnsMetric_progress_bar_horizontal',
},
]}
idSelected={`${idPrefix}${state.progressDirection ?? 'vertical'}`}
onChange={(id) => {
const newDirection = id.replace(idPrefix, '') as LayoutDirection;
setState({
...state,
progressDirection: newDirection,
});
}}
/>
</EuiFormRow>
);
}
function PrimaryMetricEditor(props: Props) {
const { state, setState, frame, accessor } = props;
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const currentData = frame.activeData?.[state.layerId];
if (accessor == null || !isNumericFieldForDatatable(currentData, accessor)) {
return null;
}
const hasDynamicColoring = Boolean(state?.palette);
const startWithPercentPalette = Boolean(state.maxAccessor || state.breakdownByAccessor);
const activePalette = state?.palette || {
type: 'palette',
name: (startWithPercentPalette ? defaultPercentagePaletteParams : defaultNumberPaletteParams)
.name,
params: {
...(startWithPercentPalette ? defaultPercentagePaletteParams : defaultNumberPaletteParams),
},
};
const currentMinMax = getDataBoundsForPalette(
{
metric: state.metricAccessor!,
max: state.maxAccessor,
breakdownBy: state.breakdownByAccessor,
},
frame.activeData?.[state.layerId]
);
const displayStops = applyPaletteParams(props.paletteService, activePalette, {
min: currentMinMax.min ?? DEFAULT_MIN_STOP,
max: currentMinMax.max ?? DEFAULT_MAX_STOP,
});
const togglePalette = () => setIsPaletteOpen(!isPaletteOpen);
const idPrefix = htmlIdGenerator()();
return (
<>
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.metric.dynamicColoring.label', {
defaultMessage: 'Color mode',
})}
css={css`
align-items: center;
`}
>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={i18n.translate('xpack.lens.metric.colorMode.label', {
defaultMessage: 'Color mode',
})}
data-test-subj="lnsMetric_color_mode_buttons"
options={[
{
id: `${idPrefix}static`,
label: i18n.translate('xpack.lens.metric.colorMode.static', {
defaultMessage: 'Static',
}),
'data-test-subj': 'lnsMetric_color_mode_static',
},
{
id: `${idPrefix}dynamic`,
label: i18n.translate('xpack.lens.metric.colorMode.dynamic', {
defaultMessage: 'Dynamic',
}),
'data-test-subj': 'lnsMetric_color_mode_dynamic',
},
]}
idSelected={`${idPrefix}${state.palette ? 'dynamic' : 'static'}`}
onChange={(id) => {
const colorMode = id.replace(idPrefix, '') as 'static' | 'dynamic';
const params =
colorMode === 'dynamic'
? {
palette: {
...activePalette,
params: {
...activePalette.params,
stops: displayStops,
},
},
}
: {
palette: undefined,
};
setState({
...state,
...params,
});
}}
/>
</EuiFormRow>
{!hasDynamicColoring && <StaticColorControls {...props} />}
{hasDynamicColoring && (
<>
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.paletteMetricGradient.label', {
defaultMessage: 'Color',
})}
>
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
className="lnsDynamicColoringClickable"
>
<EuiFlexItem>
<EuiColorPaletteDisplay
data-test-subj="lnsMetric_dynamicColoring_palette"
palette={displayStops.map(({ color }) => color)}
type={FIXED_PROGRESSION}
onClick={togglePalette}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="lnsMetric_dynamicColoring_trigger"
iconType="controlsHorizontal"
onClick={togglePalette}
size="xs"
flush="both"
>
{i18n.translate('xpack.lens.paletteTableGradient.customize', {
defaultMessage: 'Edit',
})}
</EuiButtonEmpty>
<PalettePanelContainer
siblingRef={props.panelRef}
isOpen={isPaletteOpen}
handleClose={togglePalette}
>
<CustomizablePalette
palettes={props.paletteService}
activePalette={activePalette}
dataBounds={currentMinMax}
showRangeTypeSelector={Boolean(
state.breakdownByAccessor ||
state.maxAccessor ||
activePalette.params?.rangeType === 'percent'
)}
setPalette={(newPalette) => {
setState({
...state,
palette: newPalette,
});
}}
/>
</PalettePanelContainer>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</>
)}
</>
);
}
function StaticColorControls({ state, setState }: Pick<Props, 'state' | 'setState'>) {
const colorLabel = i18n.translate('xpack.lens.metric.color', {
defaultMessage: 'Color',
});
const setColor = useCallback(
(color: string) => {
setState({ ...state, color: color === '' ? undefined : color });
},
[setState, state]
);
const { inputValue: currentColor, handleInputChange: handleColorChange } =
useDebouncedValue<string>(
{
onChange: setColor,
value: state.color || '',
},
{ allowFalsyValue: true }
);
return (
<EuiFormRow display="columnCompressed" fullWidth label={colorLabel}>
<EuiColorPicker
fullWidth
data-test-subj="lnsMetric_colorpicker"
compressed
isClearable={true}
onChange={(color: string) => handleColorChange(color)}
color={currentColor}
placeholder={i18n.translate('xpack.lens.metric.colorPlaceholder', {
defaultMessage: 'Auto',
})}
aria-label={colorLabel}
showAlpha={false}
swatches={euiPaletteColorBlind()}
/>
</EuiFormRow>
);
}

View file

@ -0,0 +1,26 @@
/*
* 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 type { CoreSetup } from '@kbn/core/public';
import type { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import type { EditorFrameSetup } from '../../types';
export interface MetricVisualizationPluginSetupPlugins {
editorFrame: EditorFrameSetup;
charts: ChartsPluginSetup;
}
export class MetricVisualization {
setup(core: CoreSetup, { editorFrame, charts }: MetricVisualizationPluginSetupPlugins) {
editorFrame.registerVisualization(async () => {
const { getMetricVisualization } = await import('../../async_services');
const palettes = await charts.palettes.getPalettes();
return getMetricVisualization({ paletteService: palettes });
});
}
}

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 * from './visualization';

View file

@ -0,0 +1,35 @@
/*
* 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 { RequiredPaletteParamTypes } from '@kbn/coloring';
import { defaultPaletteParams as sharedDefaultParams } from '../../shared_components';
export const RANGE_MIN = 0;
export const defaultPercentagePaletteParams: RequiredPaletteParamTypes = {
...sharedDefaultParams,
name: 'status',
rangeType: 'percent',
steps: 3,
maxSteps: 5,
continuity: 'all',
colorStops: [],
stops: [],
};
export const defaultNumberPaletteParams: RequiredPaletteParamTypes = {
...sharedDefaultParams,
name: 'status',
rangeType: 'number',
rangeMin: -Infinity,
rangeMax: Infinity,
steps: 3,
maxSteps: 5,
continuity: 'all',
colorStops: [],
stops: [],
};

View file

@ -0,0 +1,359 @@
/*
* 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 { getSuggestions } from './suggestions';
import { layerTypes } from '../../../common';
import { MetricVisualizationState } from './visualization';
import { LensIconChartMetric } from '../../assets/chart_metric';
const metricColumn = {
columnId: 'metric-column',
operation: {
isBucketed: false,
dataType: 'number' as const,
scale: 'ratio' as const,
label: 'Metric',
},
};
const bucketColumn = {
columnId: 'top-values-col',
operation: {
isBucketed: true,
dataType: 'string' as const,
label: 'Top Values',
},
};
describe('metric suggestions', () => {
describe('no suggestions', () => {
test('layer mismatch', () => {
expect(
getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [metricColumn],
changeType: 'unchanged',
},
keptLayerIds: ['unknown-layer'],
})
).toHaveLength(0);
});
test('too many bucketed columns', () => {
expect(
getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [
bucketColumn,
// one too many
{
...bucketColumn,
columnId: 'metric-column2',
},
],
changeType: 'unchanged',
},
keptLayerIds: ['first'],
})
).toHaveLength(0);
});
test('too many metric columns', () => {
expect(
getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [
metricColumn,
// one too many
{
...metricColumn,
columnId: 'metric-column2',
},
],
changeType: 'unchanged',
},
keptLayerIds: ['first'],
})
).toHaveLength(0);
});
test('table includes a column of an unsupported format', () => {
expect(
getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [
metricColumn,
{
...metricColumn,
columnId: 'metric-column2',
},
],
changeType: 'unchanged',
},
keptLayerIds: ['first'],
})
).toHaveLength(0);
});
test('unchanged data when active visualization', () => {
const unchangedSuggestion = {
table: {
layerId: 'first',
isMultiRow: true,
columns: [metricColumn],
changeType: 'unchanged' as const,
},
state: {
layerId: 'first',
layerType: layerTypes.DATA,
} as MetricVisualizationState,
keptLayerIds: ['first'],
};
expect(getSuggestions(unchangedSuggestion)).toHaveLength(0);
});
});
describe('when active visualization', () => {
describe('initial change (e.g. dragging first field to workspace)', () => {
test('maps metric column to primary metric', () => {
expect(
getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [metricColumn],
changeType: 'initial',
},
state: {
layerId: 'first',
layerType: layerTypes.DATA,
} as MetricVisualizationState,
keptLayerIds: ['first'],
})
).toEqual([
{
state: {
layerId: 'first',
layerType: layerTypes.DATA,
metricAccessor: metricColumn.columnId,
// should ignore bucketed column for initial drag
},
title: 'Metric',
hide: true,
previewIcon: LensIconChartMetric,
score: 0.51,
},
]);
});
test('maps bucketed column to breakdown-by dimension', () => {
expect(
getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [bucketColumn],
changeType: 'initial',
},
state: {
layerId: 'first',
layerType: layerTypes.DATA,
} as MetricVisualizationState,
keptLayerIds: ['first'],
})
).toEqual([
{
state: {
layerId: 'first',
layerType: layerTypes.DATA,
breakdownByAccessor: bucketColumn.columnId,
},
title: 'Metric',
hide: true,
previewIcon: LensIconChartMetric,
score: 0.51,
},
]);
});
test('drops mapped columns that do not exist anymore on the table', () => {
expect(
getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [bucketColumn],
changeType: 'initial',
},
state: {
layerId: 'first',
layerType: layerTypes.DATA,
metricAccessor: 'non_existent',
} as MetricVisualizationState,
keptLayerIds: ['first'],
})
).toEqual([
{
state: {
layerId: 'first',
layerType: layerTypes.DATA,
metricAccessor: undefined,
breakdownByAccessor: bucketColumn.columnId,
},
title: 'Metric',
hide: true,
previewIcon: LensIconChartMetric,
score: 0.51,
},
]);
});
test('drops excludes max and secondary metric dimensions from suggestions', () => {
const suggestedState = getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [metricColumn],
changeType: 'extended',
},
state: {
layerId: 'first',
layerType: layerTypes.DATA,
secondaryMetricAccessor: 'some-accessor',
maxAccessor: 'some-accessor',
} as MetricVisualizationState,
keptLayerIds: ['first'],
})[0].state;
expect(suggestedState.secondaryMetricAccessor).toBeUndefined();
expect(suggestedState.maxAccessor).toBeUndefined();
});
test('no suggestions for tables with both metric and bucket', () => {
expect(
getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [metricColumn, bucketColumn],
changeType: 'initial',
},
state: {
layerId: 'first',
layerType: layerTypes.DATA,
} as MetricVisualizationState,
keptLayerIds: ['first'],
})
).toHaveLength(0);
});
});
describe('extending (e.g. dragging subsequent fields to workspace)', () => {
test('maps metric column to primary metric', () => {
expect(
getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [bucketColumn, metricColumn],
changeType: 'extended',
},
state: {
layerId: 'first',
layerType: layerTypes.DATA,
breakdownByAccessor: bucketColumn.columnId,
} as MetricVisualizationState,
keptLayerIds: ['first'],
})
).toEqual([
{
state: {
layerId: 'first',
layerType: layerTypes.DATA,
metricAccessor: metricColumn.columnId,
breakdownByAccessor: bucketColumn.columnId,
},
title: 'Metric',
hide: true,
previewIcon: LensIconChartMetric,
score: 0.52,
},
]);
});
test('maps bucketed column to breakdown-by dimension', () => {
expect(
getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [metricColumn, bucketColumn],
changeType: 'extended',
},
state: {
layerId: 'first',
layerType: layerTypes.DATA,
metricAccessor: metricColumn.columnId,
} as MetricVisualizationState,
keptLayerIds: ['first'],
})
).toEqual([
{
state: {
layerId: 'first',
layerType: layerTypes.DATA,
metricAccessor: metricColumn.columnId,
breakdownByAccessor: bucketColumn.columnId,
},
title: 'Metric',
hide: true,
previewIcon: LensIconChartMetric,
score: 0.52,
},
]);
});
});
});
describe('when NOT active visualization', () => {
test('maps metric and bucket columns to primary metric and breakdown', () => {
expect(
getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [metricColumn, bucketColumn],
changeType: 'unchanged', // doesn't matter
},
state: undefined,
keptLayerIds: ['first'],
})
).toEqual([
{
state: {
layerId: 'first',
layerType: layerTypes.DATA,
metricAccessor: metricColumn.columnId,
breakdownByAccessor: bucketColumn.columnId,
},
title: 'Metric',
hide: true,
previewIcon: LensIconChartMetric,
score: 0.52,
},
]);
});
});
});

View file

@ -0,0 +1,87 @@
/*
* 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 type { TableSuggestion, Visualization } from '../../types';
import { LensIconChartMetric } from '../../assets/chart_metric';
import { layerTypes } from '../../../common';
import { metricLabel, MetricVisualizationState, supportedDataTypes } from './visualization';
const MAX_BUCKETED_COLUMNS = 1;
const MAX_METRIC_COLUMNS = 1;
const hasLayerMismatch = (keptLayerIds: string[], table: TableSuggestion) =>
keptLayerIds.length > 1 || (keptLayerIds.length && table.layerId !== keptLayerIds[0]);
export const getSuggestions: Visualization<MetricVisualizationState>['getSuggestions'] = ({
table,
state,
keptLayerIds,
}) => {
const isActive = Boolean(state);
const metricColumns = table.columns.filter(
({ operation }) => supportedDataTypes.has(operation.dataType) && !operation.isBucketed
);
const bucketedColumns = table.columns.filter(({ operation }) => operation.isBucketed);
const unsupportedColumns = table.columns.filter(
({ operation }) => !supportedDataTypes.has(operation.dataType) && !operation.isBucketed
);
const couldNeverFit =
unsupportedColumns.length ||
bucketedColumns.length > MAX_BUCKETED_COLUMNS ||
metricColumns.length > MAX_METRIC_COLUMNS;
if (
!table.columns.length ||
hasLayerMismatch(keptLayerIds, table) ||
couldNeverFit ||
// dragging the first field
(isActive &&
table.changeType === 'initial' &&
metricColumns.length &&
bucketedColumns.length) ||
(isActive && table.changeType === 'unchanged')
) {
return [];
}
const baseSuggestion = {
state: {
...state,
layerId: table.layerId,
layerType: layerTypes.DATA,
},
title: metricLabel,
previewIcon: LensIconChartMetric,
score: 0.5,
// don't show suggestions since we're in tech preview
hide: true,
};
const accessorMappings: Pick<MetricVisualizationState, 'metricAccessor' | 'breakdownByAccessor'> =
{
metricAccessor: metricColumns[0]?.columnId,
breakdownByAccessor: bucketedColumns[0]?.columnId,
};
baseSuggestion.score += 0.01 * Object.values(accessorMappings).filter(Boolean).length;
const suggestion = {
...baseSuggestion,
state: {
...baseSuggestion.state,
...accessorMappings,
secondaryMetricAccessor: undefined,
maxAccessor: undefined,
},
};
return [suggestion];
};

View file

@ -0,0 +1,119 @@
/*
* 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, { ChangeEvent } from 'react';
import { CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { Toolbar } from './toolbar';
import { MetricVisualizationState } from './visualization';
import { createMockFramePublicAPI } from '../../mocks';
import { HTMLAttributes, ReactWrapper } from 'enzyme';
import { EuiFieldText } from '@elastic/eui';
import { ToolbarButton } from '@kbn/kibana-react-plugin/public';
import { act } from 'react-dom/test-utils';
jest.mock('lodash', () => {
const original = jest.requireActual('lodash');
return {
...original,
debounce: (fn: unknown) => fn,
};
});
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',
palette,
};
const frame = createMockFramePublicAPI();
class Harness {
public _wrapper;
constructor(wrapper: ReactWrapper<HTMLAttributes, unknown, React.Component<{}, {}, unknown>>) {
this._wrapper = wrapper;
}
private get subtitleField() {
return this._wrapper.find(EuiFieldText);
}
public get textOptionsButton() {
const toolbarButtons = this._wrapper.find(ToolbarButton);
return toolbarButtons.at(0);
}
public toggleOpenTextOptions() {
this.textOptionsButton.simulate('click');
}
public setSubtitle(subtitle: string) {
act(() => {
this.subtitleField.props().onChange!({
target: { value: subtitle },
} as unknown as ChangeEvent<HTMLInputElement>);
});
}
}
const mockSetState = jest.fn();
const getHarnessWithState = (state: MetricVisualizationState) =>
new Harness(mountWithIntl(<Toolbar state={state} setState={mockSetState} frame={frame} />));
afterEach(() => mockSetState.mockClear());
describe('text options', () => {
it('sets a subtitle', () => {
const localHarness = getHarnessWithState({ ...fullState, breakdownByAccessor: undefined });
localHarness.toggleOpenTextOptions();
const newSubtitle = 'new subtitle hey';
localHarness.setSubtitle(newSubtitle + ' 1');
localHarness.setSubtitle(newSubtitle + ' 2');
localHarness.setSubtitle(newSubtitle + ' 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', () => {
expect(
getHarnessWithState({
...fullState,
breakdownByAccessor: 'some-accessor',
}).textOptionsButton.exists()
).toBeFalsy();
});
});
});

View file

@ -0,0 +1,61 @@
/*
* 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 { VisualizationToolbarProps } from '../../types';
import { ToolbarPopover, useDebouncedValue } 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,635 @@
/*
* 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 { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import { ExpressionAstExpression, ExpressionAstFunction } from '@kbn/expressions-plugin/common';
import { euiLightVars } from '@kbn/ui-theme';
import { layerTypes } from '../..';
import { createMockDatasource, createMockFramePublicAPI } from '../../mocks';
import {
DatasourceLayers,
DatasourcePublicAPI,
OperationDescriptor,
OperationMetadata,
Visualization,
} from '../../types';
import { GROUP_ID } from './constants';
import { getMetricVisualization, MetricVisualizationState } from './visualization';
const paletteService = chartPluginMock.createPaletteRegistry();
describe('metric visualization', () => {
const visualization = getMetricVisualization({
paletteService,
});
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',
palette,
};
const mockFrameApi = createMockFramePublicAPI();
describe('initialization', () => {
test('returns a default state', () => {
expect(visualization.initialize(() => 'some-id')).toEqual({
layerId: 'some-id',
layerType: layerTypes.DATA,
});
});
test('returns persisted state', () => {
expect(visualization.initialize(() => fullState.layerId, fullState)).toEqual(fullState);
});
});
describe('dimension groups configuration', () => {
test('generates configuration', () => {
expect(
visualization.getConfiguration({
state: fullState,
layerId: fullState.layerId,
frame: mockFrameApi,
})
).toMatchSnapshot();
});
test('color-by-value', () => {
expect(
visualization.getConfiguration({
state: fullState,
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[0].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "metric-col-id",
"palette": Array [],
"triggerIcon": "colorBy",
},
]
`);
expect(
visualization.getConfiguration({
state: { ...fullState, palette: undefined },
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[0].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "metric-col-id",
"palette": undefined,
"triggerIcon": undefined,
},
]
`);
});
test('collapse function', () => {
expect(
visualization.getConfiguration({
state: fullState,
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[3].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "breakdown-col-id",
"triggerIcon": "aggregate",
},
]
`);
expect(
visualization.getConfiguration({
state: { ...fullState, collapseFn: undefined },
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[3].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "breakdown-col-id",
"triggerIcon": undefined,
},
]
`);
});
describe('operation filtering', () => {
const unsupportedDataType = 'string';
const operations: OperationMetadata[] = [
{
isBucketed: true,
dataType: 'number',
},
{
isBucketed: true,
dataType: unsupportedDataType,
},
{
isBucketed: false,
dataType: 'number',
},
{
isBucketed: false,
dataType: unsupportedDataType,
},
];
const testConfig = visualization
.getConfiguration({
state: fullState,
layerId: fullState.layerId,
frame: mockFrameApi,
})
.groups.map(({ groupId, filterOperations }) => [groupId, filterOperations]);
it.each(testConfig)('%s supports correct operations', (_, filterFn) => {
expect(
operations.filter(filterFn as (operation: OperationMetadata) => boolean)
).toMatchSnapshot();
});
});
});
describe('generating an expression', () => {
const maxPossibleNumValues = 7;
let datasourceLayers: DatasourceLayers;
beforeEach(() => {
const mockDatasource = createMockDatasource('testDatasource');
mockDatasource.publicAPIMock.getMaxPossibleNumValues.mockReturnValue(maxPossibleNumValues);
mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
isStaticValue: false,
} as OperationDescriptor);
datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
});
it('is null when no metric accessor', () => {
const state: MetricVisualizationState = {
layerId: 'first',
layerType: 'data',
metricAccessor: undefined,
};
expect(visualization.toExpression(state, datasourceLayers)).toBeNull();
});
it('builds single metric', () => {
expect(
visualization.toExpression(
{
...fullState,
breakdownByAccessor: undefined,
collapseFn: undefined,
},
datasourceLayers
)
).toMatchInlineSnapshot(`
Object {
"chain": Array [
Object {
"arguments": Object {
"breakdownBy": Array [],
"color": Array [
"static-color",
],
"max": Array [
"max-metric-col-id",
],
"maxCols": Array [
5,
],
"metric": Array [
"metric-col-id",
],
"minTiles": Array [],
"palette": Array [
Object {
"chain": Array [
Object {
"arguments": Object {
"name": Array [
"mocked",
],
},
"function": "system_palette",
"type": "function",
},
],
"type": "expression",
},
],
"progressDirection": Array [
"vertical",
],
"secondaryMetric": Array [
"secondary-metric-col-id",
],
"secondaryPrefix": Array [
"extra-text",
],
"subtitle": Array [
"subtitle",
],
},
"function": "metricVis",
"type": "function",
},
],
"type": "expression",
}
`);
});
it('builds breakdown by metric', () => {
expect(visualization.toExpression({ ...fullState, collapseFn: undefined }, datasourceLayers))
.toMatchInlineSnapshot(`
Object {
"chain": Array [
Object {
"arguments": Object {
"breakdownBy": Array [
"breakdown-col-id",
],
"color": Array [
"static-color",
],
"max": Array [
"max-metric-col-id",
],
"maxCols": Array [
5,
],
"metric": Array [
"metric-col-id",
],
"minTiles": Array [
7,
],
"palette": Array [
Object {
"chain": Array [
Object {
"arguments": Object {
"name": Array [
"mocked",
],
},
"function": "system_palette",
"type": "function",
},
],
"type": "expression",
},
],
"progressDirection": Array [
"vertical",
],
"secondaryMetric": Array [
"secondary-metric-col-id",
],
"secondaryPrefix": Array [
"extra-text",
],
"subtitle": Array [
"subtitle",
],
},
"function": "metricVis",
"type": "function",
},
],
"type": "expression",
}
`);
});
describe('with collapse function', () => {
it('builds breakdown by metric with collapse function', () => {
const ast = visualization.toExpression(
{
...fullState,
collapseFn: 'sum',
// Turning off an accessor to make sure it gets filtered out from the collapse arguments
secondaryMetricAccessor: undefined,
},
datasourceLayers
) as ExpressionAstExpression;
expect(ast.chain).toHaveLength(2);
expect(ast.chain[0]).toMatchInlineSnapshot(`
Object {
"arguments": Object {
"by": Array [],
"fn": Array [
"sum",
"sum",
],
"metric": Array [
"metric-col-id",
"max-metric-col-id",
],
},
"function": "lens_collapse",
"type": "function",
}
`);
expect(ast.chain[1].arguments.minTiles).toHaveLength(0);
expect(ast.chain[1].arguments.breakdownBy).toHaveLength(0);
});
it('always applies max function to static max dimensions', () => {
(
datasourceLayers.first as jest.Mocked<DatasourcePublicAPI>
).getOperationForColumnId.mockReturnValueOnce({
isStaticValue: true,
} as OperationDescriptor);
const ast = visualization.toExpression(
{
...fullState,
collapseFn: 'sum', // this should be overridden for the max dimension
},
datasourceLayers
) as ExpressionAstExpression;
expect(ast.chain).toHaveLength(2);
expect(ast.chain[0]).toMatchInlineSnapshot(`
Object {
"arguments": Object {
"by": Array [],
"fn": Array [
"sum",
"sum",
"max",
],
"metric": Array [
"metric-col-id",
"secondary-metric-col-id",
"max-metric-col-id",
],
},
"function": "lens_collapse",
"type": "function",
}
`);
});
});
it('incorporates datasource expression if provided', () => {
const datasourceFn: ExpressionAstFunction = {
type: 'function',
function: 'some-data-function',
arguments: {},
};
const datasourceExpressionsByLayers: Record<string, ExpressionAstExpression> = {
first: { type: 'expression', chain: [datasourceFn] },
};
const ast = visualization.toExpression(
fullState,
datasourceLayers,
{},
datasourceExpressionsByLayers
) as ExpressionAstExpression;
expect(ast.chain).toHaveLength(3);
expect(ast.chain[0]).toEqual(datasourceFn);
});
describe('static color', () => {
it('uses color from state', () => {
const color = 'color-fun';
expect(
(
visualization.toExpression(
{
...fullState,
color,
},
datasourceLayers
) as ExpressionAstExpression
).chain[1].arguments.color[0]
).toBe(color);
});
it('can use a default color', () => {
expect(
(
visualization.toExpression(
{
...fullState,
color: undefined,
},
datasourceLayers
) as ExpressionAstExpression
).chain[1].arguments.color[0]
).toBe(euiLightVars.euiColorPrimary);
expect(
(
visualization.toExpression(
{
...fullState,
maxAccessor: undefined,
color: undefined,
},
datasourceLayers
) as ExpressionAstExpression
).chain[1].arguments.color
).toEqual([]);
});
});
});
it('clears a layer', () => {
expect(visualization.clearLayer(fullState, 'some-id')).toMatchInlineSnapshot(`
Object {
"color": "static-color",
"layerId": "first",
"layerType": "data",
"maxCols": 5,
"progressDirection": "vertical",
"subtitle": "subtitle",
}
`);
});
test('getLayerIds returns the single layer ID', () => {
expect(visualization.getLayerIds(fullState)).toEqual([fullState.layerId]);
});
it('gives a description', () => {
expect(visualization.getDescription(fullState)).toMatchInlineSnapshot(`
Object {
"icon": [Function],
"label": "Metric",
}
`);
});
describe('getting supported layers', () => {
it('works without state', () => {
const supportedLayers = visualization.getSupportedLayers();
expect(supportedLayers[0].initialDimensions).toBeUndefined();
expect(supportedLayers).toMatchInlineSnapshot(`
Array [
Object {
"initialDimensions": undefined,
"label": "Visualization",
"type": "data",
},
]
`);
});
it('includes max static value dimension when state provided', () => {
const supportedLayers = visualization.getSupportedLayers(fullState);
expect(supportedLayers[0].initialDimensions).toHaveLength(1);
expect(supportedLayers[0].initialDimensions![0]).toEqual(
expect.objectContaining({
groupId: GROUP_ID.MAX,
staticValue: 0,
})
);
});
});
it('sets dimensions', () => {
const state = {} as MetricVisualizationState;
const columnId = 'col-id';
expect(
visualization.setDimension({
prevState: state,
columnId,
groupId: GROUP_ID.METRIC,
layerId: 'some-id',
frame: mockFrameApi,
})
).toEqual({
metricAccessor: columnId,
});
expect(
visualization.setDimension({
prevState: state,
columnId,
groupId: GROUP_ID.SECONDARY_METRIC,
layerId: 'some-id',
frame: mockFrameApi,
})
).toEqual({
secondaryMetricAccessor: columnId,
});
expect(
visualization.setDimension({
prevState: state,
columnId,
groupId: GROUP_ID.MAX,
layerId: 'some-id',
frame: mockFrameApi,
})
).toEqual({
maxAccessor: columnId,
});
expect(
visualization.setDimension({
prevState: state,
columnId,
groupId: GROUP_ID.BREAKDOWN_BY,
layerId: 'some-id',
frame: mockFrameApi,
})
).toEqual({
breakdownByAccessor: columnId,
});
});
describe('removing a dimension', () => {
const removeDimensionParam: Parameters<
Visualization<MetricVisualizationState>['removeDimension']
>[0] = {
layerId: 'some-id',
columnId: '',
frame: mockFrameApi,
prevState: fullState,
};
it('removes metric dimension', () => {
const removed = visualization.removeDimension({
...removeDimensionParam,
columnId: fullState.metricAccessor!,
});
expect(removed).not.toHaveProperty('metricAccessor');
expect(removed).not.toHaveProperty('palette');
});
it('removes secondary metric dimension', () => {
const removed = visualization.removeDimension({
...removeDimensionParam,
columnId: fullState.secondaryMetricAccessor!,
});
expect(removed).not.toHaveProperty('secondaryMetricAccessor');
expect(removed).not.toHaveProperty('secondaryPrefix');
});
it('removes max dimension', () => {
const removed = visualization.removeDimension({
...removeDimensionParam,
columnId: fullState.maxAccessor!,
});
expect(removed).not.toHaveProperty('maxAccessor');
});
it('removes breakdown-by dimension', () => {
const removed = visualization.removeDimension({
...removeDimensionParam,
columnId: fullState.breakdownByAccessor!,
});
expect(removed).not.toHaveProperty('breakdownByAccessor');
expect(removed).not.toHaveProperty('collapseFn');
});
});
it('implements custom display options', () => {
expect(visualization.getDisplayOptions!()).toEqual({
noPanelTitle: true,
noPadding: true,
});
});
});

View file

@ -0,0 +1,414 @@
/*
* 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 { I18nProvider } from '@kbn/i18n-react';
import { render } from 'react-dom';
import { Ast, AstFunction } from '@kbn/interpreter';
import { PaletteOutput, PaletteRegistry, CUSTOM_PALETTE, CustomPaletteParams } from '@kbn/coloring';
import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public';
import { LayoutDirection } from '@elastic/charts';
import { euiLightVars } from '@kbn/ui-theme';
import { LayerType } from '../../../common';
import { getSuggestions } from './suggestions';
import { LensIconChartMetric } from '../../assets/chart_metric';
import { Visualization, OperationMetadata, DatasourceLayers } from '../../types';
import { layerTypes } from '../../../common';
import { GROUP_ID, LENS_METRIC_ID } from './constants';
import { DimensionEditor } from './dimension_editor';
import { Toolbar } from './toolbar';
import { generateId } from '../../id_generator';
export const DEFAULT_MAX_COLUMNS = 3;
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?: string;
subtitle?: string;
secondaryPrefix?: string;
progressDirection?: LayoutDirection;
color?: string;
palette?: PaletteOutput<CustomPaletteParams>;
maxCols?: number;
}
export const supportedDataTypes = new Set(['number']);
// TODO - deduplicate with gauges?
function computePaletteParams(params: CustomPaletteParams) {
return {
...params,
// rewrite colors and stops as two distinct arguments
colors: (params?.stops || []).map(({ color }) => color),
stops: params?.name === 'custom' ? (params?.stops || []).map(({ stop }) => stop) : [],
reverse: false, // managed at UI level
};
}
const toExpression = (
paletteService: PaletteRegistry,
state: MetricVisualizationState,
datasourceLayers: DatasourceLayers,
datasourceExpressionsByLayers: Record<string, Ast> | undefined = {}
): Ast | null => {
if (!state.metricAccessor) {
return null;
}
const datasource = datasourceLayers[state.layerId];
const datasourceExpression = datasourceExpressionsByLayers[state.layerId];
const maxPossibleTiles =
// if there's a collapse function, no need to calculate since we're dealing with a single tile
state.breakdownByAccessor && !state.collapseFn
? datasource.getMaxPossibleNumValues(state.breakdownByAccessor)
: null;
const getCollapseFnArguments = () => {
const metric = [state.metricAccessor, state.secondaryMetricAccessor, state.maxAccessor].filter(
Boolean
);
const fn = metric.map((accessor) => {
if (accessor !== state.maxAccessor) {
return state.collapseFn;
} else {
const isMaxStatic = Boolean(
datasource.getOperationForColumnId(state.maxAccessor!)?.isStaticValue
);
// we do this because the user expects the static value they set to be the same
// even if they define a collapse on the breakdown by
return isMaxStatic ? 'max' : state.collapseFn;
}
});
return {
by: [],
metric,
fn,
};
};
return {
type: 'expression',
chain: [
...(datasourceExpression?.chain ?? []),
...(state.collapseFn
? [
{
type: 'function',
function: 'lens_collapse',
arguments: getCollapseFnArguments(),
} as AstFunction,
]
: []),
{
type: 'function',
function: 'metricVis', // TODO import from plugin
arguments: {
metric: state.metricAccessor ? [state.metricAccessor] : [],
secondaryMetric: state.secondaryMetricAccessor ? [state.secondaryMetricAccessor] : [],
secondaryPrefix: state.secondaryPrefix ? [state.secondaryPrefix] : [],
max: state.maxAccessor ? [state.maxAccessor] : [],
breakdownBy:
state.breakdownByAccessor && !state.collapseFn ? [state.breakdownByAccessor] : [],
subtitle: state.subtitle ? [state.subtitle] : [],
progressDirection: state.progressDirection ? [state.progressDirection] : [],
color: state.color
? [state.color]
: state.maxAccessor
? [euiLightVars.euiColorPrimary]
: [],
palette: state.palette?.params
? [
paletteService
.get(CUSTOM_PALETTE)
.toExpression(computePaletteParams(state.palette.params as CustomPaletteParams)),
]
: [],
maxCols: [state.maxCols ?? DEFAULT_MAX_COLUMNS],
minTiles: maxPossibleTiles ? [maxPossibleTiles] : [],
},
},
],
};
};
export const metricLabel = i18n.translate('xpack.lens.metric.label', {
defaultMessage: 'Metric',
});
const metricGroupLabel = i18n.translate('xpack.lens.metric.groupLabel', {
defaultMessage: 'Goal and single value',
});
export const getMetricVisualization = ({
paletteService,
}: {
paletteService: PaletteRegistry;
}): Visualization<MetricVisualizationState> => ({
id: LENS_METRIC_ID,
visualizationTypes: [
{
id: LENS_METRIC_ID,
icon: LensIconChartMetric,
label: metricLabel,
groupLabel: metricGroupLabel,
showExperimentalBadge: true,
sortPriority: 3,
},
],
getVisualizationTypeId() {
return LENS_METRIC_ID;
},
clearLayer(state) {
const newState = { ...state };
delete newState.metricAccessor;
delete newState.secondaryMetricAccessor;
delete newState.secondaryPrefix;
delete newState.breakdownByAccessor;
delete newState.collapseFn;
delete newState.maxAccessor;
delete newState.palette;
// TODO - clear more?
return newState;
},
getLayerIds(state) {
return [state.layerId];
},
getDescription() {
return {
icon: LensIconChartMetric,
label: metricLabel,
};
},
getSuggestions,
initialize(addNewLayer, state, mainPalette) {
return (
state ?? {
layerId: addNewLayer(),
layerType: layerTypes.DATA,
palette: mainPalette,
}
);
},
triggers: [VIS_EVENT_TO_TRIGGER.filter],
getConfiguration(props) {
const hasColoring = props.state.palette != null;
const stops = props.state.palette?.params?.stops || [];
const isSupportedMetric = (op: OperationMetadata) =>
!op.isBucketed && supportedDataTypes.has(op.dataType);
const isSupportedDynamicMetric = (op: OperationMetadata) =>
!op.isBucketed && supportedDataTypes.has(op.dataType) && !op.isStaticValue;
const isBucketed = (op: OperationMetadata) => op.isBucketed;
return {
groups: [
{
groupId: GROUP_ID.METRIC,
groupLabel: i18n.translate('xpack.lens.primaryMetric.label', {
defaultMessage: 'Primary metric',
}),
layerId: props.state.layerId,
accessors: props.state.metricAccessor
? [
{
columnId: props.state.metricAccessor,
triggerIcon: hasColoring ? 'colorBy' : undefined,
palette: hasColoring ? stops.map(({ color }) => color) : undefined,
},
]
: [],
supportsMoreColumns: !props.state.metricAccessor,
filterOperations: isSupportedDynamicMetric,
enableDimensionEditor: true,
supportFieldFormat: false,
required: true,
},
{
groupId: GROUP_ID.SECONDARY_METRIC,
groupLabel: i18n.translate('xpack.lens.metric.secondaryMetric', {
defaultMessage: 'Secondary metric',
}),
layerId: props.state.layerId,
accessors: props.state.secondaryMetricAccessor
? [
{
columnId: props.state.secondaryMetricAccessor,
},
]
: [],
supportsMoreColumns: !props.state.secondaryMetricAccessor,
filterOperations: isSupportedDynamicMetric,
enableDimensionEditor: true,
supportFieldFormat: false,
required: false,
},
{
groupId: GROUP_ID.MAX,
groupLabel: i18n.translate('xpack.lens.metric.max', { defaultMessage: 'Maximum value' }),
layerId: props.state.layerId,
accessors: props.state.maxAccessor
? [
{
columnId: props.state.maxAccessor,
},
]
: [],
supportsMoreColumns: !props.state.maxAccessor,
filterOperations: isSupportedMetric,
enableDimensionEditor: true,
supportFieldFormat: false,
supportStaticValue: true,
required: false,
groupTooltip: i18n.translate('xpack.lens.metric.maxTooltip', {
defaultMessage:
'If the maximum value is specified, the minimum value is fixed at zero.',
}),
},
{
groupId: GROUP_ID.BREAKDOWN_BY,
groupLabel: i18n.translate('xpack.lens.metric.breakdownBy', {
defaultMessage: 'Break down by',
}),
layerId: props.state.layerId,
accessors: props.state.breakdownByAccessor
? [
{
columnId: props.state.breakdownByAccessor,
triggerIcon: props.state.collapseFn ? ('aggregate' as const) : undefined,
},
]
: [],
supportsMoreColumns: !props.state.breakdownByAccessor,
filterOperations: isBucketed,
enableDimensionEditor: true,
supportFieldFormat: false,
required: false,
},
],
};
},
getSupportedLayers(state) {
return [
{
type: layerTypes.DATA,
label: i18n.translate('xpack.lens.metric.addLayer', {
defaultMessage: 'Visualization',
}),
initialDimensions: state
? [
{
groupId: 'max',
columnId: generateId(),
staticValue: 0,
},
]
: undefined,
},
];
},
getLayerType(layerId, state) {
if (state?.layerId === layerId) {
return state.layerType;
}
},
toExpression: (state, datasourceLayers, attributes, datasourceExpressionsByLayers) =>
toExpression(paletteService, state, datasourceLayers, datasourceExpressionsByLayers),
setDimension({ prevState, columnId, groupId }) {
const updated = { ...prevState };
switch (groupId) {
case GROUP_ID.METRIC:
updated.metricAccessor = columnId;
break;
case GROUP_ID.SECONDARY_METRIC:
updated.secondaryMetricAccessor = columnId;
break;
case GROUP_ID.MAX:
updated.maxAccessor = columnId;
break;
case GROUP_ID.BREAKDOWN_BY:
updated.breakdownByAccessor = columnId;
break;
}
return updated;
},
removeDimension({ prevState, layerId, columnId }) {
const updated = { ...prevState };
if (prevState.metricAccessor === columnId) {
delete updated.metricAccessor;
delete updated.palette;
}
if (prevState.secondaryMetricAccessor === columnId) {
delete updated.secondaryMetricAccessor;
delete updated.secondaryPrefix;
}
if (prevState.maxAccessor === columnId) {
delete updated.maxAccessor;
}
if (prevState.breakdownByAccessor === columnId) {
delete updated.breakdownByAccessor;
delete updated.collapseFn;
}
return updated;
},
renderToolbar(domElement, props) {
render(
<I18nProvider>
<Toolbar {...props} />
</I18nProvider>,
domElement
);
},
renderDimensionEditor(domElement, props) {
render(
<I18nProvider>
<DimensionEditor {...props} paletteService={paletteService} />
</I18nProvider>,
domElement
);
},
getErrorMessages(state) {
// Is it possible to break it?
return undefined;
},
getDisplayOptions() {
return {
noPanelTitle: true,
noPadding: true,
};
},
});

View file

@ -34,6 +34,7 @@
{ "path": "../../../src/plugins/field_formats/tsconfig.json"},
{ "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json"},
{ "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json"},
{ "path": "../../../src/plugins/chart_expressions/expression_metric/tsconfig.json"},
{ "path": "../../../src/plugins/data_view_editor/tsconfig.json"},
{ "path": "../../../src/plugins/event_annotation/tsconfig.json"},
{ "path": "../../../src/plugins/chart_expressions/expression_xy/tsconfig.json"},

View file

@ -721,26 +721,9 @@
"xpack.lens.lineMarker.positionRequirementTooltip": "Vous devez sélectionner une icône ou afficher le nom pour pouvoir en modifier la position.",
"xpack.lens.lineMarker.textVisibility": "Décoration du texte",
"xpack.lens.metric.addLayer": "Visualisation",
"xpack.lens.metric.dynamicColoring.background": "Remplir",
"xpack.lens.metric.dynamicColoring.label": "Couleur par valeur",
"xpack.lens.metric.dynamicColoring.none": "Aucun",
"xpack.lens.metric.dynamicColoring.text": "Texte",
"xpack.lens.metric.groupLabel": "Valeur dobjectif et unique",
"xpack.lens.metric.label": "Indicateur",
"xpack.lens.metricChart.alignLabel.center": "Aligner au centre",
"xpack.lens.metricChart.alignLabel.left": "Aligner à gauche",
"xpack.lens.metricChart.alignLabel.right": "Aligner à droite",
"xpack.lens.metricChart.metricSize.extraLarge": "XL",
"xpack.lens.metricChart.metricSize.extraSmall": "XS",
"xpack.lens.metricChart.metricSize.large": "L",
"xpack.lens.metricChart.metricSize.medium": "M",
"xpack.lens.metricChart.metricSize.small": "S",
"xpack.lens.metricChart.metricSize.xxl": "XXL",
"xpack.lens.metricChart.textFormattingLabel": "Formatage du texte",
"xpack.lens.metricChart.titleAlignLabel": "Aligner",
"xpack.lens.metricChart.titlePositionLabel": "Position du titre",
"xpack.lens.metricChart.titlePositions.bottom": "Bas",
"xpack.lens.metricChart.titlePositions.top": "Haut",
"xpack.lens.pageTitle": "Lens",
"xpack.lens.paletteHeatmapGradient.customize": "Modifier",
"xpack.lens.paletteHeatmapGradient.customizeLong": "Modifier la palette",

View file

@ -722,26 +722,9 @@
"xpack.lens.lineMarker.positionRequirementTooltip": "位置を変更するには、アイコンを選択するか、名前を表示する必要があります",
"xpack.lens.lineMarker.textVisibility": "テキスト装飾",
"xpack.lens.metric.addLayer": "ビジュアライゼーション",
"xpack.lens.metric.dynamicColoring.background": "塗りつぶし",
"xpack.lens.metric.dynamicColoring.label": "値別の色",
"xpack.lens.metric.dynamicColoring.none": "なし",
"xpack.lens.metric.dynamicColoring.text": "テキスト",
"xpack.lens.metric.groupLabel": "目標値と単一の値",
"xpack.lens.metric.label": "メトリック",
"xpack.lens.metricChart.alignLabel.center": "中央に合わせる",
"xpack.lens.metricChart.alignLabel.left": "左に合わせる",
"xpack.lens.metricChart.alignLabel.right": "右に合わせる",
"xpack.lens.metricChart.metricSize.extraLarge": "XL",
"xpack.lens.metricChart.metricSize.extraSmall": "XS",
"xpack.lens.metricChart.metricSize.large": "L",
"xpack.lens.metricChart.metricSize.medium": "M",
"xpack.lens.metricChart.metricSize.small": "S",
"xpack.lens.metricChart.metricSize.xxl": "XXL",
"xpack.lens.metricChart.textFormattingLabel": "テキスト書式",
"xpack.lens.metricChart.titleAlignLabel": "配置",
"xpack.lens.metricChart.titlePositionLabel": "タイトル位置",
"xpack.lens.metricChart.titlePositions.bottom": "一番下",
"xpack.lens.metricChart.titlePositions.top": "トップ",
"xpack.lens.pageTitle": "レンズ",
"xpack.lens.paletteHeatmapGradient.customize": "編集",
"xpack.lens.paletteHeatmapGradient.customizeLong": "パレットを編集",

View file

@ -722,26 +722,8 @@
"xpack.lens.lineMarker.positionRequirementTooltip": "必须选择图标或显示名称才能更改其位置",
"xpack.lens.lineMarker.textVisibility": "文本装饰",
"xpack.lens.metric.addLayer": "可视化",
"xpack.lens.metric.dynamicColoring.background": "填充",
"xpack.lens.metric.dynamicColoring.label": "按值上色",
"xpack.lens.metric.dynamicColoring.none": "无",
"xpack.lens.metric.dynamicColoring.text": "文本",
"xpack.lens.metric.groupLabel": "目标值和单值",
"xpack.lens.metric.label": "指标",
"xpack.lens.metricChart.alignLabel.center": "中间对齐",
"xpack.lens.metricChart.alignLabel.left": "左对齐",
"xpack.lens.metricChart.alignLabel.right": "右对齐",
"xpack.lens.metricChart.metricSize.extraLarge": "XL",
"xpack.lens.metricChart.metricSize.extraSmall": "XS",
"xpack.lens.metricChart.metricSize.large": "L",
"xpack.lens.metricChart.metricSize.medium": "M",
"xpack.lens.metricChart.metricSize.small": "S",
"xpack.lens.metricChart.metricSize.xxl": "XXL",
"xpack.lens.metricChart.textFormattingLabel": "文本格式",
"xpack.lens.metricChart.titleAlignLabel": "对齐",
"xpack.lens.metricChart.titlePositionLabel": "标题位置",
"xpack.lens.metricChart.titlePositions.bottom": "底部",
"xpack.lens.metricChart.titlePositions.top": "顶部",
"xpack.lens.pageTitle": "Lens",
"xpack.lens.paletteHeatmapGradient.customize": "编辑",
"xpack.lens.paletteHeatmapGradient.customizeLong": "编辑调色板",

View file

@ -35,7 +35,7 @@ export default function ({
aggs={aggCount id="1" enabled=true schema="metric"}
aggs={aggMax id="1" enabled=true schema="metric" field="bytes"}
aggs={aggTerms id="2" enabled=true schema="segment" field="response.raw" size=4 order="desc" orderBy="1"}
| metricVis metric={visdimension 0}
| legacyMetricVis metric={visdimension 0}
`
);
await testSubjects.click('run');