mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Lens] add new metric visualization (#136567)
This commit is contained in:
parent
038bf2d42b
commit
42d396627e
67 changed files with 3817 additions and 369 deletions
|
@ -32,7 +32,7 @@ pageLoadAssetSize:
|
|||
inputControlVis: 172675
|
||||
inspector: 148711
|
||||
kibanaOverview: 56279
|
||||
lens: 35000
|
||||
lens: 36000
|
||||
licenseManagement: 41817
|
||||
licensing: 29004
|
||||
lists: 22900
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
`;
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -11,3 +11,5 @@ import { ExpressionMetricPlugin } from './plugin';
|
|||
export function plugin() {
|
||||
return new ExpressionMetricPlugin();
|
||||
}
|
||||
|
||||
export { getDataBoundsForPalette } from './utils';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -14,6 +14,7 @@ import { EmbeddablePublicPlugin } from './plugin';
|
|||
export type {
|
||||
Adapters,
|
||||
ReferenceOrValueEmbeddable,
|
||||
SelfStyledEmbeddable,
|
||||
ChartActionContext,
|
||||
ContainerInput,
|
||||
ContainerOutput,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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' }]],
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -360,6 +360,7 @@ describe('editor_frame', () => {
|
|||
getVisualDefaults: jest.fn(),
|
||||
getSourceId: jest.fn(),
|
||||
getFilters: jest.fn(),
|
||||
getMaxPossibleNumValues: jest.fn(),
|
||||
};
|
||||
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
|
||||
|
||||
|
|
|
@ -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([]);
|
||||
|
|
|
@ -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!();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -423,4 +423,12 @@ describe('filters', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMaxPossibleNumValues', () => {
|
||||
it('reports number of filters', () => {
|
||||
expect(
|
||||
filtersOperation.getMaxPossibleNumValues!(layer.columns.col1 as FiltersIndexPatternColumn)
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -171,6 +171,8 @@ export const filtersOperation: OperationDefinition<FiltersIndexPatternColumn, 'n
|
|||
</EuiFormRow>
|
||||
);
|
||||
},
|
||||
|
||||
getMaxPossibleNumValues: (column) => column.params.filters.length,
|
||||
};
|
||||
|
||||
export const FilterList = ({
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
})}
|
||||
</>
|
||||
|
|
|
@ -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',
|
||||
})}
|
||||
</>
|
||||
|
|
|
@ -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 [];
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
|
|
|
@ -19,6 +19,7 @@ export function createMockDatasource(id: string): DatasourceMock {
|
|||
getVisualDefaults: jest.fn(),
|
||||
getSourceId: jest.fn(),
|
||||
getFilters: jest.fn(),
|
||||
getMaxPossibleNumValues: jest.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -27,4 +27,4 @@
|
|||
|
||||
.lnsSelectableErrorMessage {
|
||||
user-select: text;
|
||||
}
|
||||
}
|
113
x-pack/plugins/lens/public/visualizations/metric/__snapshots__/visualization.test.ts.snap
generated
Normal file
113
x-pack/plugins/lens/public/visualizations/metric/__snapshots__/visualization.test.ts.snap
generated
Normal 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,
|
||||
},
|
||||
]
|
||||
`;
|
|
@ -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;
|
|
@ -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,
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
26
x-pack/plugins/lens/public/visualizations/metric/index.ts
Normal file
26
x-pack/plugins/lens/public/visualizations/metric/index.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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: [],
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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];
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
61
x-pack/plugins/lens/public/visualizations/metric/toolbar.tsx
Normal file
61
x-pack/plugins/lens/public/visualizations/metric/toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
|
@ -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"},
|
||||
|
|
|
@ -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 d’objectif 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",
|
||||
|
|
|
@ -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": "パレットを編集",
|
||||
|
|
|
@ -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": "编辑调色板",
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue