parent
a3a78e1d94
commit
b9c61dc4f2
18 changed files with 631 additions and 56 deletions
|
@ -1329,8 +1329,10 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
'Median',
|
||||
'Minimum',
|
||||
'Moving average',
|
||||
'Percentile',
|
||||
'Sum',
|
||||
'Unique count',
|
||||
'\u00a0',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
@ -474,6 +474,53 @@ describe('IndexPattern Data Source', () => {
|
|||
expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']);
|
||||
});
|
||||
|
||||
it('should add the suffix to the remap column id if provided by the operation', async () => {
|
||||
const queryBaseState: IndexPatternBaseState = {
|
||||
currentIndexPatternId: '1',
|
||||
layers: {
|
||||
first: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['def', 'abc'],
|
||||
columns: {
|
||||
abc: {
|
||||
label: '23rd percentile',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'bytes',
|
||||
operationType: 'percentile',
|
||||
params: {
|
||||
percentile: 23,
|
||||
},
|
||||
},
|
||||
def: {
|
||||
label: 'Terms',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
operationType: 'terms',
|
||||
sourceField: 'source',
|
||||
params: {
|
||||
size: 5,
|
||||
orderBy: {
|
||||
type: 'alphabetical',
|
||||
},
|
||||
orderDirection: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const state = enrichBaseState(queryBaseState);
|
||||
|
||||
const ast = indexPatternDatasource.toExpression(state, 'first') as Ast;
|
||||
expect(Object.keys(JSON.parse(ast.chain[1].arguments.idMap[0] as string))).toEqual([
|
||||
'col-0-def',
|
||||
// col-1 is the auto naming of esasggs, abc is the specified column id, .23 is the generated suffix
|
||||
'col-1-abc.23',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add time_scale and format function if time scale is set and supported', async () => {
|
||||
const queryBaseState: IndexPatternBaseState = {
|
||||
currentIndexPatternId: '1',
|
||||
|
|
|
@ -19,7 +19,7 @@ import {
|
|||
hasDateField,
|
||||
} from './utils';
|
||||
import { updateColumnParam } from '../../layer_helpers';
|
||||
import { useDebounceWithOptions } from '../helpers';
|
||||
import { isValidNumber, useDebounceWithOptions } from '../helpers';
|
||||
import { adjustTimeScaleOnOtherColumnChange } from '../../time_scale_utils';
|
||||
import type { OperationDefinition, ParamEditorProps } from '..';
|
||||
|
||||
|
@ -122,19 +122,6 @@ export const movingAverageOperation: OperationDefinition<
|
|||
timeScalingMode: 'optional',
|
||||
};
|
||||
|
||||
function isValidNumber(input: string) {
|
||||
if (input === '') return false;
|
||||
try {
|
||||
const val = parseFloat(input);
|
||||
if (isNaN(val)) return false;
|
||||
if (val < 1) return false;
|
||||
if (val.toString().includes('.')) return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function MovingAverageParamEditor({
|
||||
layer,
|
||||
updateLayer,
|
||||
|
@ -145,7 +132,7 @@ function MovingAverageParamEditor({
|
|||
|
||||
useDebounceWithOptions(
|
||||
() => {
|
||||
if (!isValidNumber(inputValue)) return;
|
||||
if (!isValidNumber(inputValue, true, undefined, 1)) return;
|
||||
const inputNumber = parseInt(inputValue, 10);
|
||||
updateLayer(
|
||||
updateColumnParam({
|
||||
|
|
|
@ -199,7 +199,8 @@ describe('date_histogram', () => {
|
|||
const esAggsFn = dateHistogramOperation.toEsAggsFn(
|
||||
layer.columns.col1 as DateHistogramIndexPatternColumn,
|
||||
'col1',
|
||||
indexPattern1
|
||||
indexPattern1,
|
||||
layer
|
||||
);
|
||||
expect(esAggsFn).toEqual(
|
||||
expect.objectContaining({
|
||||
|
@ -250,7 +251,8 @@ describe('date_histogram', () => {
|
|||
},
|
||||
},
|
||||
]),
|
||||
}
|
||||
},
|
||||
layer
|
||||
);
|
||||
expect(esAggsFn).toEqual(
|
||||
expect.objectContaining({
|
||||
|
|
|
@ -83,7 +83,8 @@ describe('filters', () => {
|
|||
const esAggsFn = filtersOperation.toEsAggsFn(
|
||||
layer.columns.col1 as FiltersIndexPatternColumn,
|
||||
'col1',
|
||||
createMockedIndexPattern()
|
||||
createMockedIndexPattern(),
|
||||
layer
|
||||
);
|
||||
expect(esAggsFn).toEqual(
|
||||
expect.objectContaining({
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import { useRef } from 'react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { operationDefinitionMap } from '.';
|
||||
import { IndexPatternColumn, operationDefinitionMap } from '.';
|
||||
import { FieldBasedIndexPatternColumn } from './column_types';
|
||||
import { IndexPattern } from '../../types';
|
||||
|
||||
|
@ -63,6 +63,13 @@ export function getInvalidFieldMessage(
|
|||
: undefined;
|
||||
}
|
||||
|
||||
export function getEsAggsSuffix(column: IndexPatternColumn) {
|
||||
const operationDefinition = operationDefinitionMap[column.operationType];
|
||||
return operationDefinition.input === 'field' && operationDefinition.getEsAggsSuffix
|
||||
? operationDefinition.getEsAggsSuffix(column)
|
||||
: '';
|
||||
}
|
||||
|
||||
export function getSafeName(name: string, indexPattern: IndexPattern): string {
|
||||
const field = indexPattern.getFieldByName(name);
|
||||
return field
|
||||
|
@ -71,3 +78,22 @@ export function getSafeName(name: string, indexPattern: IndexPattern): string {
|
|||
defaultMessage: 'Missing field',
|
||||
});
|
||||
}
|
||||
|
||||
export function isValidNumber(
|
||||
inputValue: string | number | null | undefined,
|
||||
integer?: boolean,
|
||||
upperBound?: number,
|
||||
lowerBound?: number
|
||||
) {
|
||||
const inputValueAsNumber = Number(inputValue);
|
||||
return (
|
||||
inputValue !== '' &&
|
||||
inputValue !== null &&
|
||||
inputValue !== undefined &&
|
||||
!Number.isNaN(inputValueAsNumber) &&
|
||||
Number.isFinite(inputValueAsNumber) &&
|
||||
(!integer || Number.isInteger(inputValueAsNumber)) &&
|
||||
(upperBound === undefined || inputValueAsNumber <= upperBound) &&
|
||||
(lowerBound === undefined || inputValueAsNumber >= lowerBound)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
|
|||
import { termsOperation, TermsIndexPatternColumn } from './terms';
|
||||
import { filtersOperation, FiltersIndexPatternColumn } from './filters';
|
||||
import { cardinalityOperation, CardinalityIndexPatternColumn } from './cardinality';
|
||||
import { percentileOperation, PercentileIndexPatternColumn } from './percentile';
|
||||
import {
|
||||
minOperation,
|
||||
MinIndexPatternColumn,
|
||||
|
@ -58,6 +59,7 @@ export type IndexPatternColumn =
|
|||
| CardinalityIndexPatternColumn
|
||||
| SumIndexPatternColumn
|
||||
| MedianIndexPatternColumn
|
||||
| PercentileIndexPatternColumn
|
||||
| CountIndexPatternColumn
|
||||
| LastValueIndexPatternColumn
|
||||
| CumulativeSumIndexPatternColumn
|
||||
|
@ -82,6 +84,7 @@ const internalOperationDefinitions = [
|
|||
cardinalityOperation,
|
||||
sumOperation,
|
||||
medianOperation,
|
||||
percentileOperation,
|
||||
lastValueOperation,
|
||||
countOperation,
|
||||
rangeOperation,
|
||||
|
@ -96,6 +99,7 @@ export { rangeOperation } from './ranges';
|
|||
export { filtersOperation } from './filters';
|
||||
export { dateHistogramOperation } from './date_histogram';
|
||||
export { minOperation, averageOperation, sumOperation, maxOperation } from './metrics';
|
||||
export { percentileOperation } from './percentile';
|
||||
export { countOperation } from './count';
|
||||
export { lastValueOperation } from './last_value';
|
||||
export {
|
||||
|
@ -223,7 +227,12 @@ interface FieldlessOperationDefinition<C extends BaseIndexPatternColumn> {
|
|||
* Function turning a column into an agg config passed to the `esaggs` function
|
||||
* together with the agg configs returned from other columns.
|
||||
*/
|
||||
toEsAggsFn: (column: C, columnId: string, indexPattern: IndexPattern) => ExpressionAstFunction;
|
||||
toEsAggsFn: (
|
||||
column: C,
|
||||
columnId: string,
|
||||
indexPattern: IndexPattern,
|
||||
layer: IndexPatternLayer
|
||||
) => ExpressionAstFunction;
|
||||
}
|
||||
|
||||
interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> {
|
||||
|
@ -262,7 +271,19 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> {
|
|||
* Function turning a column into an agg config passed to the `esaggs` function
|
||||
* together with the agg configs returned from other columns.
|
||||
*/
|
||||
toEsAggsFn: (column: C, columnId: string, indexPattern: IndexPattern) => ExpressionAstFunction;
|
||||
toEsAggsFn: (
|
||||
column: C,
|
||||
columnId: string,
|
||||
indexPattern: IndexPattern,
|
||||
layer: IndexPatternLayer
|
||||
) => ExpressionAstFunction;
|
||||
/**
|
||||
* Optional function to return the suffix used for ES bucket paths and esaggs column id.
|
||||
* This is relevant for multi metrics to pick the right value.
|
||||
*
|
||||
* @param column The current column
|
||||
*/
|
||||
getEsAggsSuffix?: (column: C) => string;
|
||||
/**
|
||||
* Validate that the operation has the right preconditions in the state. For example:
|
||||
*
|
||||
|
|
|
@ -69,7 +69,8 @@ describe('last_value', () => {
|
|||
const esAggsFn = lastValueOperation.toEsAggsFn(
|
||||
{ ...lastValueColumn, params: { ...lastValueColumn.params } },
|
||||
'col1',
|
||||
{} as IndexPattern
|
||||
{} as IndexPattern,
|
||||
layer
|
||||
);
|
||||
expect(esAggsFn).toEqual(
|
||||
expect.objectContaining({
|
||||
|
|
|
@ -83,21 +83,24 @@ function buildMetricOperation<T extends MetricColumn<string>>({
|
|||
},
|
||||
onOtherColumnChanged: (layer, thisColumnId, changedColumnId) =>
|
||||
optionalTimeScaling
|
||||
? adjustTimeScaleOnOtherColumnChange(layer, thisColumnId, changedColumnId)
|
||||
: layer.columns[thisColumnId],
|
||||
? (adjustTimeScaleOnOtherColumnChange(layer, thisColumnId, changedColumnId) as T)
|
||||
: (layer.columns[thisColumnId] as T),
|
||||
getDefaultLabel: (column, indexPattern, columns) =>
|
||||
labelLookup(getSafeName(column.sourceField, indexPattern), column),
|
||||
buildColumn: ({ field, previousColumn }) => ({
|
||||
label: labelLookup(field.displayName, previousColumn),
|
||||
dataType: 'number',
|
||||
operationType: type,
|
||||
sourceField: field.name,
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
timeScale: optionalTimeScaling ? previousColumn?.timeScale : undefined,
|
||||
params:
|
||||
previousColumn && previousColumn.dataType === 'number' ? previousColumn.params : undefined,
|
||||
}),
|
||||
buildColumn: ({ field, previousColumn }) =>
|
||||
({
|
||||
label: labelLookup(field.displayName, previousColumn),
|
||||
dataType: 'number',
|
||||
operationType: type,
|
||||
sourceField: field.name,
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
timeScale: optionalTimeScaling ? previousColumn?.timeScale : undefined,
|
||||
params:
|
||||
previousColumn && previousColumn.dataType === 'number'
|
||||
? previousColumn.params
|
||||
: undefined,
|
||||
} as T),
|
||||
onFieldChange: (oldColumn, field) => {
|
||||
return {
|
||||
...oldColumn,
|
||||
|
|
|
@ -0,0 +1,237 @@
|
|||
/*
|
||||
* 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, mount } from 'enzyme';
|
||||
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
|
||||
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
|
||||
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
|
||||
import { createMockedIndexPattern } from '../../mocks';
|
||||
import { percentileOperation } from './index';
|
||||
import { IndexPattern, IndexPatternLayer } from '../../types';
|
||||
import { PercentileIndexPatternColumn } from './percentile';
|
||||
import { EuiFieldNumber } from '@elastic/eui';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { EuiFormRow } from '@elastic/eui';
|
||||
|
||||
const defaultProps = {
|
||||
storage: {} as IStorageWrapper,
|
||||
uiSettings: {} as IUiSettingsClient,
|
||||
savedObjectsClient: {} as SavedObjectsClientContract,
|
||||
dateRange: { fromDate: 'now-1d', toDate: 'now' },
|
||||
data: dataPluginMock.createStartContract(),
|
||||
http: {} as HttpSetup,
|
||||
indexPattern: {
|
||||
...createMockedIndexPattern(),
|
||||
hasRestrictions: false,
|
||||
} as IndexPattern,
|
||||
};
|
||||
|
||||
describe('percentile', () => {
|
||||
let layer: IndexPatternLayer;
|
||||
const InlineOptions = percentileOperation.paramEditor!;
|
||||
|
||||
beforeEach(() => {
|
||||
layer = {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'Top value of category',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
operationType: 'terms',
|
||||
params: {
|
||||
orderBy: { type: 'alphabetical' },
|
||||
size: 3,
|
||||
orderDirection: 'asc',
|
||||
},
|
||||
sourceField: 'category',
|
||||
},
|
||||
col2: {
|
||||
label: '23rd percentile of a',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'a',
|
||||
operationType: 'percentile',
|
||||
params: {
|
||||
percentile: 23,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('toEsAggsFn', () => {
|
||||
it('should reflect params correctly', () => {
|
||||
const percentileColumn = layer.columns.col2 as PercentileIndexPatternColumn;
|
||||
const esAggsFn = percentileOperation.toEsAggsFn(
|
||||
percentileColumn,
|
||||
'col1',
|
||||
{} as IndexPattern,
|
||||
layer
|
||||
);
|
||||
expect(esAggsFn).toEqual(
|
||||
expect.objectContaining({
|
||||
arguments: expect.objectContaining({
|
||||
percents: [23],
|
||||
field: ['a'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFieldChange', () => {
|
||||
it('should change correctly to new field', () => {
|
||||
const oldColumn: PercentileIndexPatternColumn = {
|
||||
operationType: 'percentile',
|
||||
sourceField: 'bytes',
|
||||
label: '23rd percentile of bytes',
|
||||
isBucketed: true,
|
||||
dataType: 'number',
|
||||
params: {
|
||||
percentile: 23,
|
||||
},
|
||||
};
|
||||
const indexPattern = createMockedIndexPattern();
|
||||
const newNumberField = indexPattern.getFieldByName('memory')!;
|
||||
const column = percentileOperation.onFieldChange(oldColumn, newNumberField);
|
||||
|
||||
expect(column).toEqual(
|
||||
expect.objectContaining({
|
||||
dataType: 'number',
|
||||
sourceField: 'memory',
|
||||
params: expect.objectContaining({
|
||||
percentile: 23,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(column.label).toContain('memory');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildColumn', () => {
|
||||
it('should set default percentile', () => {
|
||||
const indexPattern = createMockedIndexPattern();
|
||||
const bytesField = indexPattern.fields.find(({ name }) => name === 'bytes')!;
|
||||
bytesField.displayName = 'test';
|
||||
const percentileColumn = percentileOperation.buildColumn({
|
||||
indexPattern,
|
||||
field: bytesField,
|
||||
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
|
||||
});
|
||||
expect(percentileColumn.dataType).toEqual('number');
|
||||
expect(percentileColumn.params.percentile).toEqual(95);
|
||||
expect(percentileColumn.label).toEqual('95th percentile of test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('param editor', () => {
|
||||
it('should render current percentile', () => {
|
||||
const updateLayerSpy = jest.fn();
|
||||
const instance = shallow(
|
||||
<InlineOptions
|
||||
{...defaultProps}
|
||||
layer={layer}
|
||||
updateLayer={updateLayerSpy}
|
||||
columnId="col2"
|
||||
currentColumn={layer.columns.col2 as PercentileIndexPatternColumn}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = instance.find('[data-test-subj="lns-indexPattern-percentile-input"]');
|
||||
|
||||
expect(input.prop('value')).toEqual('23');
|
||||
});
|
||||
|
||||
it('should update state on change', async () => {
|
||||
jest.useFakeTimers();
|
||||
const updateLayerSpy = jest.fn();
|
||||
const instance = mount(
|
||||
<InlineOptions
|
||||
{...defaultProps}
|
||||
layer={layer}
|
||||
updateLayer={updateLayerSpy}
|
||||
columnId="col2"
|
||||
currentColumn={layer.columns.col2 as PercentileIndexPatternColumn}
|
||||
/>
|
||||
);
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
const input = instance
|
||||
.find('[data-test-subj="lns-indexPattern-percentile-input"]')
|
||||
.find(EuiFieldNumber);
|
||||
|
||||
await act(async () => {
|
||||
input.prop('onChange')!({ target: { value: '27' } } as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
instance.update();
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(updateLayerSpy).toHaveBeenCalledWith({
|
||||
...layer,
|
||||
columns: {
|
||||
...layer.columns,
|
||||
col2: {
|
||||
...layer.columns.col2,
|
||||
params: {
|
||||
percentile: 27,
|
||||
},
|
||||
label: '27th percentile of a',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update on invalid input, but show invalid value locally', async () => {
|
||||
const updateLayerSpy = jest.fn();
|
||||
const instance = mount(
|
||||
<InlineOptions
|
||||
{...defaultProps}
|
||||
layer={layer}
|
||||
updateLayer={updateLayerSpy}
|
||||
columnId="col2"
|
||||
currentColumn={layer.columns.col2 as PercentileIndexPatternColumn}
|
||||
/>
|
||||
);
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
const input = instance
|
||||
.find('[data-test-subj="lns-indexPattern-percentile-input"]')
|
||||
.find(EuiFieldNumber);
|
||||
|
||||
await act(async () => {
|
||||
input.prop('onChange')!({
|
||||
target: { value: '12.12' },
|
||||
} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
instance.update();
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(updateLayerSpy).not.toHaveBeenCalled();
|
||||
|
||||
expect(
|
||||
instance
|
||||
.find('[data-test-subj="lns-indexPattern-percentile-form"]')
|
||||
.find(EuiFormRow)
|
||||
.prop('isInvalid')
|
||||
).toEqual(true);
|
||||
expect(
|
||||
instance
|
||||
.find('[data-test-subj="lns-indexPattern-percentile-input"]')
|
||||
.find(EuiFieldNumber)
|
||||
.prop('value')
|
||||
).toEqual('12.12');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
* 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 { EuiFieldNumber, EuiFormRow } from '@elastic/eui';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { AggFunctionsMapping } from 'src/plugins/data/public';
|
||||
import { buildExpressionFunction } from '../../../../../../../src/plugins/expressions/public';
|
||||
import { OperationDefinition } from './index';
|
||||
import {
|
||||
getInvalidFieldMessage,
|
||||
getSafeName,
|
||||
isValidNumber,
|
||||
useDebounceWithOptions,
|
||||
} from './helpers';
|
||||
import { FieldBasedIndexPatternColumn } from './column_types';
|
||||
|
||||
export interface PercentileIndexPatternColumn extends FieldBasedIndexPatternColumn {
|
||||
operationType: 'percentile';
|
||||
params: {
|
||||
percentile: number;
|
||||
format?: {
|
||||
id: string;
|
||||
params?: {
|
||||
decimals: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function ofName(name: string, percentile: number) {
|
||||
return i18n.translate('xpack.lens.indexPattern.percentileOf', {
|
||||
defaultMessage:
|
||||
'{percentile, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} percentile of {name}',
|
||||
values: { name, percentile },
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_PERCENTILE_VALUE = 95;
|
||||
|
||||
export const percentileOperation: OperationDefinition<PercentileIndexPatternColumn, 'field'> = {
|
||||
type: 'percentile',
|
||||
displayName: i18n.translate('xpack.lens.indexPattern.percentile', {
|
||||
defaultMessage: 'Percentile',
|
||||
}),
|
||||
input: 'field',
|
||||
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type: fieldType }) => {
|
||||
if (fieldType === 'number' && aggregatable && !aggregationRestrictions) {
|
||||
return {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
};
|
||||
}
|
||||
},
|
||||
isTransferable: (column, newIndexPattern) => {
|
||||
const newField = newIndexPattern.getFieldByName(column.sourceField);
|
||||
|
||||
return Boolean(
|
||||
newField &&
|
||||
newField.type === 'number' &&
|
||||
newField.aggregatable &&
|
||||
!newField.aggregationRestrictions
|
||||
);
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern, columns) =>
|
||||
ofName(getSafeName(column.sourceField, indexPattern), column.params.percentile),
|
||||
buildColumn: ({ field, previousColumn, indexPattern }) => {
|
||||
const existingFormat =
|
||||
previousColumn?.params && 'format' in previousColumn?.params
|
||||
? previousColumn?.params?.format
|
||||
: undefined;
|
||||
const existingPercentileParam =
|
||||
previousColumn?.operationType === 'percentile' && previousColumn?.params.percentile;
|
||||
const newPercentileParam = existingPercentileParam || DEFAULT_PERCENTILE_VALUE;
|
||||
return {
|
||||
label: ofName(getSafeName(field.name, indexPattern), newPercentileParam),
|
||||
dataType: 'number',
|
||||
operationType: 'percentile',
|
||||
sourceField: field.name,
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
params: {
|
||||
format: existingFormat,
|
||||
percentile: newPercentileParam,
|
||||
},
|
||||
};
|
||||
},
|
||||
onFieldChange: (oldColumn, field) => {
|
||||
return {
|
||||
...oldColumn,
|
||||
label: ofName(field.displayName, oldColumn.params.percentile),
|
||||
sourceField: field.name,
|
||||
};
|
||||
},
|
||||
toEsAggsFn: (column, columnId, _indexPattern) => {
|
||||
return buildExpressionFunction<AggFunctionsMapping['aggPercentiles']>('aggPercentiles', {
|
||||
id: columnId,
|
||||
enabled: true,
|
||||
schema: 'metric',
|
||||
field: column.sourceField,
|
||||
percents: [column.params.percentile],
|
||||
}).toAst();
|
||||
},
|
||||
getEsAggsSuffix: (column) => {
|
||||
const value = column.params.percentile;
|
||||
return `.${value}`;
|
||||
},
|
||||
getErrorMessage: (layer, columnId, indexPattern) =>
|
||||
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
|
||||
paramEditor: function PercentileParamEditor({
|
||||
layer,
|
||||
updateLayer,
|
||||
currentColumn,
|
||||
columnId,
|
||||
indexPattern,
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState(String(currentColumn.params.percentile));
|
||||
|
||||
const inputValueAsNumber = Number(inputValue);
|
||||
// an input is value if it's not an empty string, parses to a valid number, is between 0 and 100 (exclusive)
|
||||
// and is an integer
|
||||
const inputValueIsValid = isValidNumber(inputValue, true, 99, 1);
|
||||
|
||||
useDebounceWithOptions(
|
||||
() => {
|
||||
if (!inputValueIsValid) return;
|
||||
updateLayer({
|
||||
...layer,
|
||||
columns: {
|
||||
...layer.columns,
|
||||
[columnId]: {
|
||||
...currentColumn,
|
||||
label: currentColumn.customLabel
|
||||
? currentColumn.label
|
||||
: ofName(
|
||||
indexPattern.getFieldByName(currentColumn.sourceField)?.displayName ||
|
||||
currentColumn.sourceField,
|
||||
inputValueAsNumber
|
||||
),
|
||||
params: {
|
||||
...currentColumn.params,
|
||||
percentile: inputValueAsNumber,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
{ skipFirstRender: true },
|
||||
256,
|
||||
[inputValue]
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = String(e.target.value);
|
||||
setInputValue(val);
|
||||
}, []);
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.lens.indexPattern.percentile.percentileValue', {
|
||||
defaultMessage: 'Percentile',
|
||||
})}
|
||||
data-test-subj="lns-indexPattern-percentile-form"
|
||||
display="columnCompressed"
|
||||
fullWidth
|
||||
isInvalid={!inputValueIsValid}
|
||||
error={
|
||||
!inputValueIsValid &&
|
||||
i18n.translate('xpack.lens.indexPattern.percentile.errorMessage', {
|
||||
defaultMessage: 'Percentile has to be an integer between 1 and 99',
|
||||
})
|
||||
}
|
||||
>
|
||||
<EuiFieldNumber
|
||||
data-test-subj="lns-indexPattern-percentile-input"
|
||||
compressed
|
||||
value={inputValue}
|
||||
min={1}
|
||||
max={99}
|
||||
step={1}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
},
|
||||
};
|
|
@ -22,7 +22,7 @@ import {
|
|||
keys,
|
||||
} from '@elastic/eui';
|
||||
import { IFieldFormat } from '../../../../../../../../src/plugins/data/common';
|
||||
import { RangeTypeLens, isValidRange, isValidNumber } from './ranges';
|
||||
import { RangeTypeLens, isValidRange } from './ranges';
|
||||
import { FROM_PLACEHOLDER, TO_PLACEHOLDER, TYPING_DEBOUNCE_TIME } from './constants';
|
||||
import {
|
||||
NewBucketButton,
|
||||
|
@ -30,7 +30,7 @@ import {
|
|||
DraggableBucketContainer,
|
||||
LabelInput,
|
||||
} from '../shared_components';
|
||||
import { useDebounceWithOptions } from '../helpers';
|
||||
import { isValidNumber, useDebounceWithOptions } from '../helpers';
|
||||
|
||||
const generateId = htmlIdGenerator();
|
||||
|
||||
|
|
|
@ -142,7 +142,8 @@ describe('ranges', () => {
|
|||
const esAggsFn = rangeOperation.toEsAggsFn(
|
||||
layer.columns.col1 as RangeIndexPatternColumn,
|
||||
'col1',
|
||||
{} as IndexPattern
|
||||
{} as IndexPattern,
|
||||
layer
|
||||
);
|
||||
expect(esAggsFn).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
|
@ -184,7 +185,8 @@ describe('ranges', () => {
|
|||
const esAggsFn = rangeOperation.toEsAggsFn(
|
||||
layer.columns.col1 as RangeIndexPatternColumn,
|
||||
'col1',
|
||||
{} as IndexPattern
|
||||
{} as IndexPattern,
|
||||
layer
|
||||
);
|
||||
|
||||
expect(esAggsFn).toEqual(
|
||||
|
@ -203,7 +205,8 @@ describe('ranges', () => {
|
|||
const esAggsFn = rangeOperation.toEsAggsFn(
|
||||
layer.columns.col1 as RangeIndexPatternColumn,
|
||||
'col1',
|
||||
{} as IndexPattern
|
||||
{} as IndexPattern,
|
||||
layer
|
||||
);
|
||||
|
||||
expect(esAggsFn).toEqual(
|
||||
|
@ -222,7 +225,8 @@ describe('ranges', () => {
|
|||
const esAggsFn = rangeOperation.toEsAggsFn(
|
||||
layer.columns.col1 as RangeIndexPatternColumn,
|
||||
'col1',
|
||||
{} as IndexPattern
|
||||
{} as IndexPattern,
|
||||
layer
|
||||
);
|
||||
|
||||
expect((esAggsFn as { arguments: unknown }).arguments).toEqual(
|
||||
|
|
|
@ -19,7 +19,7 @@ import { updateColumnParam } from '../../layer_helpers';
|
|||
import { supportedFormats } from '../../../format_column';
|
||||
import { MODES, AUTO_BARS, DEFAULT_INTERVAL, MIN_HISTOGRAM_BARS, SLICES } from './constants';
|
||||
import { IndexPattern, IndexPatternField } from '../../../types';
|
||||
import { getInvalidFieldMessage } from '../helpers';
|
||||
import { getInvalidFieldMessage, isValidNumber } from '../helpers';
|
||||
|
||||
type RangeType = Omit<Range, 'type'>;
|
||||
// Try to cover all possible serialized states for ranges
|
||||
|
@ -52,10 +52,6 @@ export type UpdateParamsFnType = <K extends keyof RangeColumnParams>(
|
|||
value: RangeColumnParams[K]
|
||||
) => void;
|
||||
|
||||
// on initialization values can be null (from the Infinity serialization), so handle it correctly
|
||||
// or they will be casted to 0 by the editor ( see #78867 )
|
||||
export const isValidNumber = (value: number | '' | null): value is number =>
|
||||
value != null && value !== '' && !isNaN(value) && isFinite(value);
|
||||
export const isRangeWithin = (range: RangeType): boolean => range.from <= range.to;
|
||||
const isFullRange = (range: RangeTypeLens): range is FullRangeTypeLens =>
|
||||
isValidNumber(range.from) && isValidNumber(range.to);
|
||||
|
@ -152,10 +148,10 @@ export const rangeOperation: OperationDefinition<RangeIndexPatternColumn, 'field
|
|||
const partialRange: Partial<RangeType> = { label: range.label };
|
||||
// be careful with the fields to set on partial ranges
|
||||
if (isValidNumber(range.from)) {
|
||||
partialRange.from = range.from;
|
||||
partialRange.from = Number(range.from);
|
||||
}
|
||||
if (isValidNumber(range.to)) {
|
||||
partialRange.to = range.to;
|
||||
partialRange.to = Number(range.to);
|
||||
}
|
||||
return partialRange;
|
||||
})
|
||||
|
|
|
@ -23,7 +23,7 @@ import { DataType } from '../../../../types';
|
|||
import { OperationDefinition } from '../index';
|
||||
import { FieldBasedIndexPatternColumn } from '../column_types';
|
||||
import { ValuesRangeInput } from './values_range_input';
|
||||
import { getInvalidFieldMessage } from '../helpers';
|
||||
import { getEsAggsSuffix, getInvalidFieldMessage } from '../helpers';
|
||||
import type { IndexPatternLayer } from '../../../types';
|
||||
|
||||
function ofName(name?: string) {
|
||||
|
@ -119,7 +119,10 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
|
|||
params: {
|
||||
size: previousBucketsLength === 0 ? 5 : DEFAULT_SIZE,
|
||||
orderBy: existingMetricColumn
|
||||
? { type: 'column', columnId: existingMetricColumn }
|
||||
? {
|
||||
type: 'column',
|
||||
columnId: existingMetricColumn,
|
||||
}
|
||||
: { type: 'alphabetical' },
|
||||
orderDirection: existingMetricColumn ? 'desc' : 'asc',
|
||||
otherBucket: !indexPattern.hasRestrictions,
|
||||
|
@ -127,14 +130,18 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
|
|||
},
|
||||
};
|
||||
},
|
||||
toEsAggsFn: (column, columnId, _indexPattern) => {
|
||||
toEsAggsFn: (column, columnId, _indexPattern, layer) => {
|
||||
return buildExpressionFunction<AggFunctionsMapping['aggTerms']>('aggTerms', {
|
||||
id: columnId,
|
||||
enabled: true,
|
||||
schema: 'segment',
|
||||
field: column.sourceField,
|
||||
orderBy:
|
||||
column.params.orderBy.type === 'alphabetical' ? '_key' : column.params.orderBy.columnId,
|
||||
column.params.orderBy.type === 'alphabetical'
|
||||
? '_key'
|
||||
: `${column.params.orderBy.columnId}${getEsAggsSuffix(
|
||||
layer.columns[column.params.orderBy.columnId]
|
||||
)}`,
|
||||
order: column.params.orderDirection,
|
||||
size: column.params.size,
|
||||
otherBucket: Boolean(column.params.otherBucket),
|
||||
|
|
|
@ -65,7 +65,8 @@ describe('terms', () => {
|
|||
const esAggsFn = termsOperation.toEsAggsFn(
|
||||
{ ...termsColumn, params: { ...termsColumn.params, otherBucket: true } },
|
||||
'col1',
|
||||
{} as IndexPattern
|
||||
{} as IndexPattern,
|
||||
layer
|
||||
);
|
||||
expect(esAggsFn).toEqual(
|
||||
expect.objectContaining({
|
||||
|
@ -87,7 +88,8 @@ describe('terms', () => {
|
|||
params: { ...termsColumn.params, otherBucket: false, missingBucket: true },
|
||||
},
|
||||
'col1',
|
||||
{} as IndexPattern
|
||||
{} as IndexPattern,
|
||||
layer
|
||||
);
|
||||
expect(esAggsFn).toEqual(
|
||||
expect.objectContaining({
|
||||
|
@ -98,6 +100,45 @@ describe('terms', () => {
|
|||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include esaggs suffix from other columns in orderby argument', () => {
|
||||
const termsColumn = layer.columns.col1 as TermsIndexPatternColumn;
|
||||
const esAggsFn = termsOperation.toEsAggsFn(
|
||||
{
|
||||
...termsColumn,
|
||||
params: {
|
||||
...termsColumn.params,
|
||||
otherBucket: true,
|
||||
orderBy: { type: 'column', columnId: 'abcde' },
|
||||
},
|
||||
},
|
||||
'col1',
|
||||
{} as IndexPattern,
|
||||
{
|
||||
...layer,
|
||||
columns: {
|
||||
...layer.columns,
|
||||
abcde: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'percentile',
|
||||
sourceField: 'abc',
|
||||
label: '',
|
||||
params: {
|
||||
percentile: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(esAggsFn).toEqual(
|
||||
expect.objectContaining({
|
||||
arguments: expect.objectContaining({
|
||||
orderBy: ['abcde.12'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFieldChange', () => {
|
||||
|
|
|
@ -293,6 +293,11 @@ describe('getOperationTypesForField', () => {
|
|||
"operationType": "median",
|
||||
"type": "field",
|
||||
},
|
||||
Object {
|
||||
"field": "bytes",
|
||||
"operationType": "percentile",
|
||||
"type": "field",
|
||||
},
|
||||
Object {
|
||||
"field": "bytes",
|
||||
"operationType": "last_value",
|
||||
|
|
|
@ -20,6 +20,7 @@ import { operationDefinitionMap } from './operations';
|
|||
import { IndexPattern, IndexPatternPrivateState, IndexPatternLayer } from './types';
|
||||
import { OriginalColumn } from './rename_columns';
|
||||
import { dateHistogramOperation } from './operations/definitions';
|
||||
import { getEsAggsSuffix } from './operations/definitions/helpers';
|
||||
|
||||
function getExpressionForLayer(
|
||||
layer: IndexPatternLayer,
|
||||
|
@ -41,15 +42,20 @@ function getExpressionForLayer(
|
|||
expressions.push(...def.toExpression(layer, colId, indexPattern));
|
||||
} else {
|
||||
aggs.push(
|
||||
buildExpression({ type: 'expression', chain: [def.toEsAggsFn(col, colId, indexPattern)] })
|
||||
buildExpression({
|
||||
type: 'expression',
|
||||
chain: [def.toEsAggsFn(col, colId, indexPattern, layer)],
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const idMap = columnEntries.reduce((currentIdMap, [colId, column], index) => {
|
||||
const esAggsId = `col-${columnEntries.length === 1 ? 0 : index}-${colId}`;
|
||||
const suffix = getEsAggsSuffix(column);
|
||||
return {
|
||||
...currentIdMap,
|
||||
[`col-${columnEntries.length === 1 ? 0 : index}-${colId}`]: {
|
||||
[`${esAggsId}${suffix}`]: {
|
||||
...column,
|
||||
id: colId,
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue