[Maps] Add percentile (#85367)
This commit is contained in:
parent
89bd0fbf1e
commit
47e2663473
|
@ -150,6 +150,7 @@ export enum AGG_TYPE {
|
|||
MIN = 'min',
|
||||
SUM = 'sum',
|
||||
TERMS = 'terms',
|
||||
PERCENTILE = 'percentile',
|
||||
UNIQUE_COUNT = 'cardinality',
|
||||
}
|
||||
|
||||
|
@ -171,6 +172,7 @@ export const GEOTILE_GRID_AGG_NAME = 'gridSplit';
|
|||
export const GEOCENTROID_AGG_NAME = 'gridCentroid';
|
||||
|
||||
export const TOP_TERM_PERCENTAGE_SUFFIX = '__percentage';
|
||||
export const DEFAULT_PERCENTILE = 50;
|
||||
|
||||
export const COUNT_PROP_LABEL = i18n.translate('xpack.maps.aggs.defaultCountLabel', {
|
||||
defaultMessage: 'count',
|
||||
|
|
|
@ -60,7 +60,13 @@ export type FieldedAggDescriptor = AbstractAggDescriptor & {
|
|||
field?: string;
|
||||
};
|
||||
|
||||
export type AggDescriptor = CountAggDescriptor | FieldedAggDescriptor;
|
||||
export type PercentileAggDescriptor = AbstractAggDescriptor & {
|
||||
type: AGG_TYPE.PERCENTILE;
|
||||
field?: string;
|
||||
percentile?: number;
|
||||
};
|
||||
|
||||
export type AggDescriptor = CountAggDescriptor | FieldedAggDescriptor | PercentileAggDescriptor;
|
||||
|
||||
export type AbstractESAggSourceDescriptor = AbstractESSourceDescriptor & {
|
||||
metrics: AggDescriptor[];
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import _ from 'lodash';
|
||||
import { IndexPattern, IFieldType } from '../../../../../src/plugins/data/common';
|
||||
import { TOP_TERM_PERCENTAGE_SUFFIX } from '../constants';
|
||||
import { AGG_TYPE, JOIN_FIELD_NAME_PREFIX, TOP_TERM_PERCENTAGE_SUFFIX } from '../constants';
|
||||
|
||||
export type BucketProperties = Record<string | number, unknown>;
|
||||
export type PropertiesMap = Map<string, BucketProperties>;
|
||||
|
@ -46,6 +46,7 @@ export function extractPropertiesFromBucket(
|
|||
continue;
|
||||
}
|
||||
|
||||
// todo: push these implementations in the IAggFields
|
||||
if (_.has(bucket[key], 'value')) {
|
||||
properties[key] = bucket[key].value;
|
||||
} else if (_.has(bucket[key], 'buckets')) {
|
||||
|
@ -63,7 +64,20 @@ export function extractPropertiesFromBucket(
|
|||
);
|
||||
}
|
||||
} else {
|
||||
properties[key] = bucket[key];
|
||||
if (
|
||||
key.startsWith(AGG_TYPE.PERCENTILE) ||
|
||||
key.startsWith(JOIN_FIELD_NAME_PREFIX + AGG_TYPE.PERCENTILE)
|
||||
) {
|
||||
const values = bucket[key].values;
|
||||
for (const k in values) {
|
||||
if (values.hasOwnProperty(k)) {
|
||||
properties[key] = values[k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
properties[key] = bucket[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return properties;
|
||||
|
|
|
@ -8,7 +8,11 @@ import { esAggFieldsFactory } from './es_agg_factory';
|
|||
import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants';
|
||||
import { IESAggSource } from '../../sources/es_agg_source';
|
||||
|
||||
const mockEsAggSource = ({} as unknown) as IESAggSource;
|
||||
const mockEsAggSource = ({
|
||||
getAggKey() {
|
||||
return 'foobar';
|
||||
},
|
||||
} as unknown) as IESAggSource;
|
||||
|
||||
describe('esAggFieldsFactory', () => {
|
||||
test('Should only create top terms field when term field is not provided', () => {
|
||||
|
@ -28,4 +32,26 @@ describe('esAggFieldsFactory', () => {
|
|||
);
|
||||
expect(fields.length).toBe(2);
|
||||
});
|
||||
|
||||
describe('percentile-fields', () => {
|
||||
test('Should create percentile agg fields with default', () => {
|
||||
const fields = esAggFieldsFactory(
|
||||
{ type: AGG_TYPE.PERCENTILE, field: 'myField' },
|
||||
mockEsAggSource,
|
||||
FIELD_ORIGIN.SOURCE
|
||||
);
|
||||
expect(fields.length).toBe(1);
|
||||
expect(fields[0].getName()).toBe('foobar_50');
|
||||
});
|
||||
|
||||
test('Should create percentile agg fields with param', () => {
|
||||
const fields = esAggFieldsFactory(
|
||||
{ type: AGG_TYPE.PERCENTILE, field: 'myField', percentile: 90 },
|
||||
mockEsAggSource,
|
||||
FIELD_ORIGIN.SOURCE
|
||||
);
|
||||
expect(fields.length).toBe(1);
|
||||
expect(fields[0].getName()).toBe('foobar_90');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,12 +6,13 @@
|
|||
|
||||
import { AggDescriptor } from '../../../../common/descriptor_types';
|
||||
import { IESAggSource } from '../../sources/es_agg_source';
|
||||
import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants';
|
||||
import { AGG_TYPE, DEFAULT_PERCENTILE, FIELD_ORIGIN } from '../../../../common/constants';
|
||||
import { ESDocField } from '../es_doc_field';
|
||||
import { TopTermPercentageField } from './top_term_percentage_field';
|
||||
import { CountAggField } from './count_agg_field';
|
||||
import { IESAggField } from './agg_field_types';
|
||||
import { AggField } from './agg_field';
|
||||
import { PercentileAggField } from './percentile_agg_field';
|
||||
|
||||
export function esAggFieldsFactory(
|
||||
aggDescriptor: AggDescriptor,
|
||||
|
@ -27,6 +28,21 @@ export function esAggFieldsFactory(
|
|||
origin,
|
||||
canReadFromGeoJson,
|
||||
});
|
||||
} else if (aggDescriptor.type === AGG_TYPE.PERCENTILE) {
|
||||
aggField = new PercentileAggField({
|
||||
label: aggDescriptor.label,
|
||||
esDocField:
|
||||
'field' in aggDescriptor && aggDescriptor.field
|
||||
? new ESDocField({ fieldName: aggDescriptor.field, source, origin })
|
||||
: undefined,
|
||||
percentile:
|
||||
'percentile' in aggDescriptor && typeof aggDescriptor.percentile === 'number'
|
||||
? aggDescriptor.percentile
|
||||
: DEFAULT_PERCENTILE,
|
||||
source,
|
||||
origin,
|
||||
canReadFromGeoJson,
|
||||
});
|
||||
} else {
|
||||
aggField = new AggField({
|
||||
label: aggDescriptor.label,
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants';
|
||||
import { IESAggSource } from '../../sources/es_agg_source';
|
||||
import { IndexPattern } from 'src/plugins/data/public';
|
||||
import { PercentileAggField } from './percentile_agg_field';
|
||||
import { ESDocField } from '../es_doc_field';
|
||||
|
||||
const mockFields = [
|
||||
{
|
||||
name: 'foo*',
|
||||
},
|
||||
];
|
||||
// @ts-expect-error
|
||||
mockFields.getByName = (name: string) => {
|
||||
return {
|
||||
name,
|
||||
};
|
||||
};
|
||||
|
||||
const mockIndexPattern = {
|
||||
title: 'wildIndex',
|
||||
fields: mockFields,
|
||||
};
|
||||
|
||||
const mockEsAggSource = {
|
||||
getAggKey: (aggType: AGG_TYPE, fieldName: string) => {
|
||||
return 'agg_key';
|
||||
},
|
||||
getAggLabel: (aggType: AGG_TYPE, fieldName: string) => {
|
||||
return 'agg_label';
|
||||
},
|
||||
getIndexPattern: async () => {
|
||||
return mockIndexPattern;
|
||||
},
|
||||
} as IESAggSource;
|
||||
|
||||
const mockEsDocField = {
|
||||
getName() {
|
||||
return 'foobar';
|
||||
},
|
||||
};
|
||||
|
||||
const defaultParams = {
|
||||
source: mockEsAggSource,
|
||||
origin: FIELD_ORIGIN.SOURCE,
|
||||
};
|
||||
|
||||
describe('percentile agg field', () => {
|
||||
test('should include percentile in name', () => {
|
||||
const field = new PercentileAggField({
|
||||
...defaultParams,
|
||||
esDocField: mockEsDocField as ESDocField,
|
||||
percentile: 80,
|
||||
});
|
||||
expect(field.getName()).toEqual('agg_key_80');
|
||||
});
|
||||
|
||||
test('should create percentile dsl', () => {
|
||||
const field = new PercentileAggField({
|
||||
...defaultParams,
|
||||
esDocField: mockEsDocField as ESDocField,
|
||||
percentile: 80,
|
||||
});
|
||||
|
||||
expect(field.getValueAggDsl(mockIndexPattern as IndexPattern)).toEqual({
|
||||
percentiles: { field: 'foobar', percents: [80] },
|
||||
});
|
||||
});
|
||||
|
||||
test('label', async () => {
|
||||
const field = new PercentileAggField({
|
||||
...defaultParams,
|
||||
esDocField: mockEsDocField as ESDocField,
|
||||
percentile: 80,
|
||||
});
|
||||
|
||||
expect(await field.getLabel()).toEqual('80th agg_label');
|
||||
});
|
||||
|
||||
test('label (median)', async () => {
|
||||
const field = new PercentileAggField({
|
||||
...defaultParams,
|
||||
label: '',
|
||||
esDocField: mockEsDocField as ESDocField,
|
||||
percentile: 50,
|
||||
});
|
||||
|
||||
expect(await field.getLabel()).toEqual('median foobar');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { IndexPattern } from 'src/plugins/data/common/index_patterns/index_patterns';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { AGG_TYPE } from '../../../../common/constants';
|
||||
import { IESAggField, CountAggFieldParams } from './agg_field_types';
|
||||
import { addFieldToDSL, getField } from '../../../../common/elasticsearch_util';
|
||||
import { ESDocField } from '../es_doc_field';
|
||||
import { getOrdinalSuffix } from '../../util/ordinal_suffix';
|
||||
import { AggField } from './agg_field';
|
||||
|
||||
interface PercentileAggParams extends CountAggFieldParams {
|
||||
esDocField?: ESDocField;
|
||||
percentile: number;
|
||||
}
|
||||
|
||||
export class PercentileAggField extends AggField implements IESAggField {
|
||||
private readonly _percentile: number;
|
||||
constructor(params: PercentileAggParams) {
|
||||
super({
|
||||
...params,
|
||||
...{
|
||||
aggType: AGG_TYPE.PERCENTILE,
|
||||
},
|
||||
});
|
||||
this._percentile = params.percentile;
|
||||
}
|
||||
|
||||
supportsFieldMeta(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
canValueBeFormatted(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getLabel(): Promise<string> {
|
||||
if (this._label) {
|
||||
return this._label;
|
||||
}
|
||||
|
||||
if (this._percentile === 50) {
|
||||
const median = i18n.translate('xpack.maps.fields.percentileMedianLabek', {
|
||||
defaultMessage: 'median',
|
||||
});
|
||||
return `${median} ${this.getRootName()}`;
|
||||
}
|
||||
|
||||
const suffix = getOrdinalSuffix(this._percentile);
|
||||
return `${this._percentile}${suffix} ${this._source.getAggLabel(
|
||||
this._getAggType(),
|
||||
this.getRootName()
|
||||
)}`;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return `${super.getName()}_${this._percentile}`;
|
||||
}
|
||||
|
||||
getValueAggDsl(indexPattern: IndexPattern): unknown {
|
||||
const field = getField(indexPattern, this.getRootName());
|
||||
const dsl: Record<string, unknown> = addFieldToDSL({}, field);
|
||||
dsl.percents = [this._percentile];
|
||||
return {
|
||||
percentiles: dsl,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -48,7 +48,9 @@ interface CountData {
|
|||
isSyncClustered: boolean;
|
||||
}
|
||||
|
||||
function getAggType(dynamicProperty: IDynamicStyleProperty<DynamicStylePropertyOptions>): AGG_TYPE {
|
||||
function getAggType(
|
||||
dynamicProperty: IDynamicStyleProperty<DynamicStylePropertyOptions>
|
||||
): AGG_TYPE.AVG | AGG_TYPE.TERMS {
|
||||
return dynamicProperty.isOrdinal() ? AGG_TYPE.AVG : AGG_TYPE.TERMS;
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,6 @@ import {
|
|||
import { VectorStyle } from '../styles/vector/vector_style';
|
||||
import { EMSFileSource } from '../sources/ems_file_source';
|
||||
// @ts-ignore
|
||||
import { ESGeoGridSource } from '../sources/es_geo_grid_source';
|
||||
import { VectorLayer } from './vector_layer/vector_layer';
|
||||
import { getDefaultDynamicProperties } from '../styles/vector/vector_style_defaults';
|
||||
import { NUMERICAL_COLOR_PALETTES } from '../styles/color_palettes';
|
||||
|
@ -35,9 +34,13 @@ export function createAggDescriptor(metricAgg: string, metricFieldName?: string)
|
|||
});
|
||||
const aggType = aggTypeKey ? AGG_TYPE[aggTypeKey as keyof typeof AGG_TYPE] : undefined;
|
||||
|
||||
return aggType && metricFieldName
|
||||
? { type: aggType, field: metricFieldName }
|
||||
: { type: AGG_TYPE.COUNT };
|
||||
if (!aggType || aggType === AGG_TYPE.COUNT || !metricFieldName) {
|
||||
return { type: AGG_TYPE.COUNT };
|
||||
} else if (aggType === AGG_TYPE.PERCENTILE) {
|
||||
return { type: aggType, field: metricFieldName, percentile: 50 };
|
||||
} else {
|
||||
return { type: aggType, field: metricFieldName };
|
||||
}
|
||||
}
|
||||
|
||||
export function createRegionMapLayerDescriptor({
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
import {
|
||||
AGG_TYPE,
|
||||
COLOR_MAP_TYPE,
|
||||
DEFAULT_PERCENTILE,
|
||||
FIELD_ORIGIN,
|
||||
GRID_RESOLUTION,
|
||||
RENDER_AS,
|
||||
|
@ -59,9 +60,18 @@ export function createAggDescriptor(
|
|||
});
|
||||
const aggType = aggTypeKey ? AGG_TYPE[aggTypeKey as keyof typeof AGG_TYPE] : undefined;
|
||||
|
||||
return aggType && metricFieldName && (!isHeatmap(mapType) || isMetricCountable(aggType))
|
||||
? { type: aggType, field: metricFieldName }
|
||||
: { type: AGG_TYPE.COUNT };
|
||||
if (
|
||||
!aggType ||
|
||||
aggType === AGG_TYPE.COUNT ||
|
||||
!metricFieldName ||
|
||||
(isHeatmap(mapType) && !isMetricCountable(aggType))
|
||||
) {
|
||||
return { type: AGG_TYPE.COUNT };
|
||||
}
|
||||
|
||||
return aggType === AGG_TYPE.PERCENTILE
|
||||
? { type: aggType, field: metricFieldName, percentile: DEFAULT_PERCENTILE }
|
||||
: { type: aggType, field: metricFieldName };
|
||||
}
|
||||
|
||||
export function createTileMapLayerDescriptor({
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
*/
|
||||
|
||||
import { Map as MbMap } from 'mapbox-gl';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { EuiTextColor } from '@elastic/eui';
|
||||
import { DynamicStyleProperty } from './dynamic_style_property';
|
||||
|
@ -25,35 +24,11 @@ import {
|
|||
import { Break, BreakedLegend } from '../components/legend/breaked_legend';
|
||||
import { ColorDynamicOptions, OrdinalColorStop } from '../../../../../common/descriptor_types';
|
||||
import { LegendProps } from './style_property';
|
||||
import { getOrdinalSuffix } from '../../../util/ordinal_suffix';
|
||||
|
||||
const EMPTY_STOPS = { stops: [], defaultColor: null };
|
||||
const RGBA_0000 = 'rgba(0,0,0,0)';
|
||||
|
||||
function getOrdinalSuffix(value: number) {
|
||||
const lastDigit = value % 10;
|
||||
if (lastDigit === 1 && value !== 11) {
|
||||
return i18n.translate('xpack.maps.styles.firstOrdinalSuffix', {
|
||||
defaultMessage: 'st',
|
||||
});
|
||||
}
|
||||
|
||||
if (lastDigit === 2 && value !== 12) {
|
||||
return i18n.translate('xpack.maps.styles.secondOrdinalSuffix', {
|
||||
defaultMessage: 'nd',
|
||||
});
|
||||
}
|
||||
|
||||
if (lastDigit === 3 && value !== 13) {
|
||||
return i18n.translate('xpack.maps.styles.thirdOrdinalSuffix', {
|
||||
defaultMessage: 'rd',
|
||||
});
|
||||
}
|
||||
|
||||
return i18n.translate('xpack.maps.styles.ordinalSuffix', {
|
||||
defaultMessage: 'th',
|
||||
});
|
||||
}
|
||||
|
||||
export class DynamicColorProperty extends DynamicStyleProperty<ColorDynamicOptions> {
|
||||
syncCircleColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
|
||||
const color = this._getMbColor();
|
||||
|
|
32
x-pack/plugins/maps/public/classes/util/ordinal_suffix.ts
Normal file
32
x-pack/plugins/maps/public/classes/util/ordinal_suffix.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export function getOrdinalSuffix(value: number) {
|
||||
const lastDigit = value % 10;
|
||||
if (lastDigit === 1 && value !== 11) {
|
||||
return i18n.translate('xpack.maps.styles.firstOrdinalSuffix', {
|
||||
defaultMessage: 'st',
|
||||
});
|
||||
}
|
||||
|
||||
if (lastDigit === 2 && value !== 12) {
|
||||
return i18n.translate('xpack.maps.styles.secondOrdinalSuffix', {
|
||||
defaultMessage: 'nd',
|
||||
});
|
||||
}
|
||||
|
||||
if (lastDigit === 3 && value !== 13) {
|
||||
return i18n.translate('xpack.maps.styles.thirdOrdinalSuffix', {
|
||||
defaultMessage: 'rd',
|
||||
});
|
||||
}
|
||||
|
||||
return i18n.translate('xpack.maps.styles.ordinalSuffix', {
|
||||
defaultMessage: 'th',
|
||||
});
|
||||
}
|
51
x-pack/plugins/maps/public/components/__snapshots__/validated_number_input.test.tsx.snap
generated
Normal file
51
x-pack/plugins/maps/public/components/__snapshots__/validated_number_input.test.tsx.snap
generated
Normal file
|
@ -0,0 +1,51 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`should render with error 1`] = `
|
||||
<EuiFormRow
|
||||
describedByIds={Array []}
|
||||
display="columnCompressed"
|
||||
error={
|
||||
Array [
|
||||
"Must be between 0 and 20",
|
||||
]
|
||||
}
|
||||
fullWidth={false}
|
||||
hasChildLabel={true}
|
||||
hasEmptyLabelSpace={false}
|
||||
isInvalid={true}
|
||||
label="foobar"
|
||||
labelType="label"
|
||||
>
|
||||
<EuiFieldNumber
|
||||
aria-label="foobar number input"
|
||||
isInvalid={true}
|
||||
max={20}
|
||||
min={0}
|
||||
onChange={[Function]}
|
||||
value={30}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
`;
|
||||
|
||||
exports[`should render without error 1`] = `
|
||||
<EuiFormRow
|
||||
describedByIds={Array []}
|
||||
display="columnCompressed"
|
||||
error={Array []}
|
||||
fullWidth={false}
|
||||
hasChildLabel={true}
|
||||
hasEmptyLabelSpace={false}
|
||||
isInvalid={false}
|
||||
label="foobar"
|
||||
labelType="label"
|
||||
>
|
||||
<EuiFieldNumber
|
||||
aria-label="foobar number input"
|
||||
isInvalid={false}
|
||||
max={20}
|
||||
min={0}
|
||||
onChange={[Function]}
|
||||
value={10}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
`;
|
|
@ -13,9 +13,10 @@ import { FormattedMessage } from '@kbn/i18n/react';
|
|||
import { MetricSelect } from './metric_select';
|
||||
import { SingleFieldSelect } from '../single_field_select';
|
||||
import { AggDescriptor } from '../../../common/descriptor_types';
|
||||
import { AGG_TYPE } from '../../../common/constants';
|
||||
import { AGG_TYPE, DEFAULT_PERCENTILE } from '../../../common/constants';
|
||||
import { getTermsFields } from '../../index_pattern_util';
|
||||
import { IFieldType } from '../../../../../../src/plugins/data/public';
|
||||
import { ValidatedNumberInput } from '../validated_number_input';
|
||||
|
||||
function filterFieldsForAgg(fields: IFieldType[], aggType: AGG_TYPE) {
|
||||
if (!fields) {
|
||||
|
@ -70,10 +71,18 @@ export function MetricEditor({
|
|||
|
||||
const fieldsForNewAggType = filterFieldsForAgg(fields, metricAggregationType);
|
||||
const found = fieldsForNewAggType.find((field) => field.name === metric.field);
|
||||
onChange({
|
||||
const newDescriptor = {
|
||||
...descriptor,
|
||||
field: found ? metric.field : undefined,
|
||||
});
|
||||
};
|
||||
if (metricAggregationType === AGG_TYPE.PERCENTILE) {
|
||||
onChange({
|
||||
...newDescriptor,
|
||||
percentile: 'percentile' in metric ? metric.percentile : DEFAULT_PERCENTILE,
|
||||
});
|
||||
} else {
|
||||
onChange(newDescriptor);
|
||||
}
|
||||
};
|
||||
const onFieldChange = (fieldName?: string) => {
|
||||
if (!fieldName || metric.type === AGG_TYPE.COUNT) {
|
||||
|
@ -85,6 +94,16 @@ export function MetricEditor({
|
|||
field: fieldName,
|
||||
});
|
||||
};
|
||||
|
||||
const onPercentileChange = (percentile: number) => {
|
||||
if (metric.type !== AGG_TYPE.PERCENTILE) {
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...metric,
|
||||
percentile,
|
||||
});
|
||||
};
|
||||
const onLabelChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
...metric,
|
||||
|
@ -121,6 +140,24 @@ export function MetricEditor({
|
|||
);
|
||||
}
|
||||
|
||||
let percentileSelect;
|
||||
if (metric.type === AGG_TYPE.PERCENTILE) {
|
||||
const label = i18n.translate('xpack.maps.metricsEditor.selectPercentileLabel', {
|
||||
defaultMessage: 'Percentile',
|
||||
});
|
||||
percentileSelect = (
|
||||
<ValidatedNumberInput
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={onPercentileChange}
|
||||
label={label}
|
||||
initialValue={
|
||||
typeof metric.percentile === 'number' ? metric.percentile : DEFAULT_PERCENTILE
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let labelInput;
|
||||
if (metric.type) {
|
||||
labelInput = (
|
||||
|
@ -178,6 +215,7 @@ export function MetricEditor({
|
|||
</EuiFormRow>
|
||||
|
||||
{fieldSelect}
|
||||
{percentileSelect}
|
||||
{labelInput}
|
||||
{removeButton}
|
||||
</Fragment>
|
||||
|
|
|
@ -34,6 +34,12 @@ const AGG_OPTIONS = [
|
|||
}),
|
||||
value: AGG_TYPE.MIN,
|
||||
},
|
||||
{
|
||||
label: i18n.translate('xpack.maps.metricSelect.percentileDropDownOptionLabel', {
|
||||
defaultMessage: 'Percentile',
|
||||
}),
|
||||
value: AGG_TYPE.PERCENTILE,
|
||||
},
|
||||
{
|
||||
label: i18n.translate('xpack.maps.metricSelect.sumDropDownOptionLabel', {
|
||||
defaultMessage: 'Sum',
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { ValidatedNumberInput } from './validated_number_input';
|
||||
|
||||
test('should render without error', async () => {
|
||||
const component = shallow(
|
||||
<ValidatedNumberInput onChange={() => {}} initialValue={10} min={0} max={20} label={'foobar'} />
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should render with error', async () => {
|
||||
const component = shallow(
|
||||
<ValidatedNumberInput onChange={() => {}} initialValue={30} min={0} max={20} label={'foobar'} />
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
113
x-pack/plugins/maps/public/components/validated_number_input.tsx
Normal file
113
x-pack/plugins/maps/public/components/validated_number_input.tsx
Normal file
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { Component, ChangeEvent } from 'react';
|
||||
import { EuiFieldNumber, EuiFormRow } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import _ from 'lodash';
|
||||
|
||||
interface State {
|
||||
value: number | string;
|
||||
errorMessage: string;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialValue: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function getErrorMessage(min: number, max: number): string {
|
||||
return i18n.translate('xpack.maps.validatedNumberInput.invalidClampErrorMessage', {
|
||||
defaultMessage: 'Must be between {min} and {max}',
|
||||
values: {
|
||||
min,
|
||||
max,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isNumberValid(value: number | string, min: number, max: number) {
|
||||
const parsedValue = parseFloat(value.toString());
|
||||
|
||||
if (isNaN(parsedValue)) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: getErrorMessage(min, max),
|
||||
};
|
||||
}
|
||||
|
||||
const isValid = parsedValue >= min && parsedValue <= max;
|
||||
return {
|
||||
parsedValue,
|
||||
isValid,
|
||||
errorMessage: isValid ? '' : getErrorMessage(min, max),
|
||||
};
|
||||
}
|
||||
|
||||
export class ValidatedNumberInput extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const { isValid, errorMessage } = isNumberValid(
|
||||
props.initialValue,
|
||||
this.props.min,
|
||||
this.props.max
|
||||
);
|
||||
|
||||
this.state = {
|
||||
value: props.initialValue,
|
||||
errorMessage,
|
||||
isValid,
|
||||
};
|
||||
}
|
||||
|
||||
_submit = _.debounce((value) => {
|
||||
this.props.onChange(value);
|
||||
}, 250);
|
||||
|
||||
_onChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const { isValid, errorMessage, parsedValue } = isNumberValid(
|
||||
value,
|
||||
this.props.min,
|
||||
this.props.max
|
||||
);
|
||||
|
||||
this.setState({
|
||||
value,
|
||||
errorMessage,
|
||||
isValid,
|
||||
});
|
||||
|
||||
if (isValid) {
|
||||
this._submit(parsedValue);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={this.props.label}
|
||||
isInvalid={!this.state.isValid}
|
||||
error={this.state.errorMessage ? [this.state.errorMessage] : []}
|
||||
display="columnCompressed"
|
||||
>
|
||||
<EuiFieldNumber
|
||||
isInvalid={!this.state.isValid}
|
||||
min={this.props.min}
|
||||
max={this.props.max}
|
||||
value={this.state.value}
|
||||
onChange={this._onChange}
|
||||
aria-label={`${this.props.label} number input`}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue