From 47e26634734b54ac6befcca233f626600474bddf Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Tue, 15 Dec 2020 15:40:21 -0500 Subject: [PATCH] [Maps] Add percentile (#85367) --- x-pack/plugins/maps/common/constants.ts | 2 + .../source_descriptor_types.ts | 8 +- .../common/elasticsearch_util/es_agg_utils.ts | 18 ++- .../classes/fields/agg/es_agg_factory.test.ts | 28 ++++- .../classes/fields/agg/es_agg_factory.ts | 18 ++- .../fields/agg/percentile_agg_field.test.ts | 95 +++++++++++++++ .../fields/agg/percentile_agg_field.ts | 72 +++++++++++ .../blended_vector_layer.ts | 4 +- .../create_region_map_layer_descriptor.ts | 11 +- .../create_tile_map_layer_descriptor.ts | 16 ++- .../properties/dynamic_color_property.tsx | 27 +---- .../public/classes/util/ordinal_suffix.ts | 32 +++++ .../validated_number_input.test.tsx.snap | 51 ++++++++ .../metrics_editor/metric_editor.tsx | 44 ++++++- .../metrics_editor/metric_select.tsx | 6 + .../validated_number_input.test.tsx | 25 ++++ .../components/validated_number_input.tsx | 113 ++++++++++++++++++ 17 files changed, 528 insertions(+), 42 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/fields/agg/percentile_agg_field.test.ts create mode 100644 x-pack/plugins/maps/public/classes/fields/agg/percentile_agg_field.ts create mode 100644 x-pack/plugins/maps/public/classes/util/ordinal_suffix.ts create mode 100644 x-pack/plugins/maps/public/components/__snapshots__/validated_number_input.test.tsx.snap create mode 100644 x-pack/plugins/maps/public/components/validated_number_input.test.tsx create mode 100644 x-pack/plugins/maps/public/components/validated_number_input.tsx diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index d587b330ac22..c267052e6dfe 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -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', diff --git a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts index 0e35b97a66bb..46b799835b9e 100644 --- a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts @@ -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[]; diff --git a/x-pack/plugins/maps/common/elasticsearch_util/es_agg_utils.ts b/x-pack/plugins/maps/common/elasticsearch_util/es_agg_utils.ts index 99c1fa3070fb..665aea400e99 100644 --- a/x-pack/plugins/maps/common/elasticsearch_util/es_agg_utils.ts +++ b/x-pack/plugins/maps/common/elasticsearch_util/es_agg_utils.ts @@ -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; export type PropertiesMap = Map; @@ -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; diff --git a/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.test.ts b/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.test.ts index fd5ede61d5c0..abd1527f61ef 100644 --- a/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.test.ts +++ b/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.test.ts @@ -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'); + }); + }); }); diff --git a/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.ts b/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.ts index a734432d03ca..cc70caff6271 100644 --- a/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.ts +++ b/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.ts @@ -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, diff --git a/x-pack/plugins/maps/public/classes/fields/agg/percentile_agg_field.test.ts b/x-pack/plugins/maps/public/classes/fields/agg/percentile_agg_field.test.ts new file mode 100644 index 000000000000..14e4fb341281 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/fields/agg/percentile_agg_field.test.ts @@ -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'); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/fields/agg/percentile_agg_field.ts b/x-pack/plugins/maps/public/classes/fields/agg/percentile_agg_field.ts new file mode 100644 index 000000000000..d882da4f64cd --- /dev/null +++ b/x-pack/plugins/maps/public/classes/fields/agg/percentile_agg_field.ts @@ -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 { + 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 = addFieldToDSL({}, field); + dsl.percents = [this._percentile]; + return { + percentiles: dsl, + }; + } +} diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index 85391ea82cbf..fdd8a1e898b6 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -48,7 +48,9 @@ interface CountData { isSyncClustered: boolean; } -function getAggType(dynamicProperty: IDynamicStyleProperty): AGG_TYPE { +function getAggType( + dynamicProperty: IDynamicStyleProperty +): AGG_TYPE.AVG | AGG_TYPE.TERMS { return dynamicProperty.isOrdinal() ? AGG_TYPE.AVG : AGG_TYPE.TERMS; } diff --git a/x-pack/plugins/maps/public/classes/layers/create_region_map_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/create_region_map_layer_descriptor.ts index 5fa2524b1b79..17dc84ead299 100644 --- a/x-pack/plugins/maps/public/classes/layers/create_region_map_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/create_region_map_layer_descriptor.ts @@ -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({ diff --git a/x-pack/plugins/maps/public/classes/layers/create_tile_map_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/create_tile_map_layer_descriptor.ts index 05616f6916f6..6cfb7540b866 100644 --- a/x-pack/plugins/maps/public/classes/layers/create_tile_map_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/create_tile_map_layer_descriptor.ts @@ -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({ diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx index 289bb6be3179..cc76a1d25856 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx @@ -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 { syncCircleColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) { const color = this._getMbColor(); diff --git a/x-pack/plugins/maps/public/classes/util/ordinal_suffix.ts b/x-pack/plugins/maps/public/classes/util/ordinal_suffix.ts new file mode 100644 index 000000000000..441f78561cbb --- /dev/null +++ b/x-pack/plugins/maps/public/classes/util/ordinal_suffix.ts @@ -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', + }); +} diff --git a/x-pack/plugins/maps/public/components/__snapshots__/validated_number_input.test.tsx.snap b/x-pack/plugins/maps/public/components/__snapshots__/validated_number_input.test.tsx.snap new file mode 100644 index 000000000000..0ae98e2fd450 --- /dev/null +++ b/x-pack/plugins/maps/public/components/__snapshots__/validated_number_input.test.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render with error 1`] = ` + + + +`; + +exports[`should render without error 1`] = ` + + + +`; diff --git a/x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx b/x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx index 242cd07ff382..147dc89b4188 100644 --- a/x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx +++ b/x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx @@ -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) => { 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 = ( + + ); + } + let labelInput; if (metric.type) { labelInput = ( @@ -178,6 +215,7 @@ export function MetricEditor({ {fieldSelect} + {percentileSelect} {labelInput} {removeButton} diff --git a/x-pack/plugins/maps/public/components/metrics_editor/metric_select.tsx b/x-pack/plugins/maps/public/components/metrics_editor/metric_select.tsx index 197c5466fe0f..4e77a91958a7 100644 --- a/x-pack/plugins/maps/public/components/metrics_editor/metric_select.tsx +++ b/x-pack/plugins/maps/public/components/metrics_editor/metric_select.tsx @@ -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', diff --git a/x-pack/plugins/maps/public/components/validated_number_input.test.tsx b/x-pack/plugins/maps/public/components/validated_number_input.test.tsx new file mode 100644 index 000000000000..389722c60f17 --- /dev/null +++ b/x-pack/plugins/maps/public/components/validated_number_input.test.tsx @@ -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( + {}} initialValue={10} min={0} max={20} label={'foobar'} /> + ); + + expect(component).toMatchSnapshot(); +}); + +test('should render with error', async () => { + const component = shallow( + {}} initialValue={30} min={0} max={20} label={'foobar'} /> + ); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/components/validated_number_input.tsx b/x-pack/plugins/maps/public/components/validated_number_input.tsx new file mode 100644 index 000000000000..a79faa73ced0 --- /dev/null +++ b/x-pack/plugins/maps/public/components/validated_number_input.tsx @@ -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 { + 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) => { + 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 ( + + + + ); + } +}