[ML] Transforms: Filter aggregation support (#67591)

* [ML] WIP filter support

* [ML] value selector

* [ML] only supported filter aggs as options

* [ML] WIP apply config

* [ML] fix form persistence

* [ML] refactor

* [ML] support clone

* [ML] validation, get es config

* [ML] support "exists", fixes for the term form, validation

* [ML] fix ts issues

* [ML] don't perform request on adding incomplete agg

* [ML] basic range number support

* [ML] filter bool agg support

* [ML] functional tests

* [ML] getAggConfigFromEsAgg tests

* [ML] fix unit tests

* [ML] agg name update on config change, add unit tests

* [ML] update snapshot

* [ML] range selector enhancements

* [ML] improve types

* [ML] update step for range selector to support float numbers

* [ML] range validation

* [ML] term selector improvements

* [ML] fix switch between advanced editor

* [ML] prefix test ids

* [ML] support helper text for aggs item
This commit is contained in:
Dima Arnautov 2020-06-04 14:21:03 +02:00 committed by GitHub
parent 1346b154ad
commit 63bb072064
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1402 additions and 142 deletions

View file

@ -65,6 +65,8 @@ export function requiredValidator() {
};
}
export type ValidationResult = object | null;
export function memoryInputValidator(allowedUnits = ALLOWED_DATA_UNITS) {
return (value: any) => {
if (typeof value !== 'string' || value === '') {

View file

@ -0,0 +1,47 @@
/*
* 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 { getAggConfigFromEsAgg } from './pivot_aggs';
import {
FilterAggForm,
FilterTermForm,
} from '../sections/create_transform/components/step_define/common/filter_agg/components';
describe('getAggConfigFromEsAgg', () => {
test('should throw an error for unsupported agg', () => {
expect(() => getAggConfigFromEsAgg({ terms: {} }, 'test')).toThrowError();
});
test('should return a common config if the agg does not have a custom config defined', () => {
expect(getAggConfigFromEsAgg({ avg: { field: 'region' } }, 'test_1')).toEqual({
agg: 'avg',
aggName: 'test_1',
dropDownName: 'test_1',
field: 'region',
});
});
test('should return a custom config for recognized aggregation type', () => {
expect(
getAggConfigFromEsAgg({ filter: { term: { region: 'sa-west-1' } } }, 'test_2')
).toMatchObject({
agg: 'filter',
aggName: 'test_2',
dropDownName: 'test_2',
field: 'region',
AggFormComponent: FilterAggForm,
aggConfig: {
filterAgg: 'term',
aggTypeConfig: {
FilterAggFormComponent: FilterTermForm,
filterAggConfig: {
value: 'sa-west-1',
},
},
},
});
});
});

View file

@ -4,36 +4,51 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { FC } from 'react';
import { Dictionary } from '../../../common/types/common';
import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/common';
import { AggName } from './aggregations';
import { EsFieldName } from './fields';
import { getAggFormConfig } from '../sections/create_transform/components/step_define/common/get_agg_form_config';
import { PivotAggsConfigFilter } from '../sections/create_transform/components/step_define/common/filter_agg/types';
export enum PIVOT_SUPPORTED_AGGS {
AVG = 'avg',
CARDINALITY = 'cardinality',
MAX = 'max',
MIN = 'min',
PERCENTILES = 'percentiles',
SUM = 'sum',
VALUE_COUNT = 'value_count',
export type PivotSupportedAggs = typeof PIVOT_SUPPORTED_AGGS[keyof typeof PIVOT_SUPPORTED_AGGS];
export function isPivotSupportedAggs(arg: any): arg is PivotSupportedAggs {
return Object.values(PIVOT_SUPPORTED_AGGS).includes(arg);
}
export const PIVOT_SUPPORTED_AGGS = {
AVG: 'avg',
CARDINALITY: 'cardinality',
MAX: 'max',
MIN: 'min',
PERCENTILES: 'percentiles',
SUM: 'sum',
VALUE_COUNT: 'value_count',
FILTER: 'filter',
} as const;
export const PERCENTILES_AGG_DEFAULT_PERCENTS = [1, 5, 25, 50, 75, 95, 99];
export const pivotAggsFieldSupport = {
[KBN_FIELD_TYPES.ATTACHMENT]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT],
[KBN_FIELD_TYPES.BOOLEAN]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT],
[KBN_FIELD_TYPES.ATTACHMENT]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER],
[KBN_FIELD_TYPES.BOOLEAN]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER],
[KBN_FIELD_TYPES.DATE]: [
PIVOT_SUPPORTED_AGGS.MAX,
PIVOT_SUPPORTED_AGGS.MIN,
PIVOT_SUPPORTED_AGGS.VALUE_COUNT,
PIVOT_SUPPORTED_AGGS.FILTER,
],
[KBN_FIELD_TYPES.GEO_POINT]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT],
[KBN_FIELD_TYPES.GEO_SHAPE]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT],
[KBN_FIELD_TYPES.IP]: [PIVOT_SUPPORTED_AGGS.CARDINALITY, PIVOT_SUPPORTED_AGGS.VALUE_COUNT],
[KBN_FIELD_TYPES.MURMUR3]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT],
[KBN_FIELD_TYPES.GEO_POINT]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER],
[KBN_FIELD_TYPES.GEO_SHAPE]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER],
[KBN_FIELD_TYPES.IP]: [
PIVOT_SUPPORTED_AGGS.CARDINALITY,
PIVOT_SUPPORTED_AGGS.VALUE_COUNT,
PIVOT_SUPPORTED_AGGS.FILTER,
],
[KBN_FIELD_TYPES.MURMUR3]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER],
[KBN_FIELD_TYPES.NUMBER]: [
PIVOT_SUPPORTED_AGGS.AVG,
PIVOT_SUPPORTED_AGGS.CARDINALITY,
@ -42,38 +57,102 @@ export const pivotAggsFieldSupport = {
PIVOT_SUPPORTED_AGGS.PERCENTILES,
PIVOT_SUPPORTED_AGGS.SUM,
PIVOT_SUPPORTED_AGGS.VALUE_COUNT,
PIVOT_SUPPORTED_AGGS.FILTER,
],
[KBN_FIELD_TYPES.STRING]: [PIVOT_SUPPORTED_AGGS.CARDINALITY, PIVOT_SUPPORTED_AGGS.VALUE_COUNT],
[KBN_FIELD_TYPES._SOURCE]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT],
[KBN_FIELD_TYPES.UNKNOWN]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT],
[KBN_FIELD_TYPES.CONFLICT]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT],
[KBN_FIELD_TYPES.STRING]: [
PIVOT_SUPPORTED_AGGS.CARDINALITY,
PIVOT_SUPPORTED_AGGS.VALUE_COUNT,
PIVOT_SUPPORTED_AGGS.FILTER,
],
[KBN_FIELD_TYPES._SOURCE]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER],
[KBN_FIELD_TYPES.UNKNOWN]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER],
[KBN_FIELD_TYPES.CONFLICT]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER],
};
export type PivotAgg = {
[key in PIVOT_SUPPORTED_AGGS]?: {
[key in PivotSupportedAggs]?: {
field: EsFieldName;
};
};
export type PivotAggDict = { [key in AggName]: PivotAgg };
export type PivotAggDict = {
[key in AggName]: PivotAgg;
};
// The internal representation of an aggregation definition.
export interface PivotAggsConfigBase {
agg: PIVOT_SUPPORTED_AGGS;
agg: PivotSupportedAggs;
aggName: AggName;
dropDownName: string;
}
interface PivotAggsConfigWithUiBase extends PivotAggsConfigBase {
/**
* Resolves agg UI config from provided ES agg definition
*/
export function getAggConfigFromEsAgg(esAggDefinition: Record<string, any>, aggName: string) {
const aggKeys = Object.keys(esAggDefinition);
// Find the main aggregation key
const agg = aggKeys.find((aggKey) => aggKey !== 'aggs');
if (!isPivotSupportedAggs(agg)) {
throw new Error(`Aggregation "${agg}" is not supported`);
}
const commonConfig: PivotAggsConfigBase = {
...esAggDefinition[agg],
agg,
aggName,
dropDownName: aggName,
};
const config = getAggFormConfig(agg, commonConfig);
if (isPivotAggsWithExtendedForm(config)) {
config.setUiConfigFromEs(esAggDefinition[agg]);
}
if (aggKeys.includes('aggs')) {
// TODO process sub-aggregation
}
return config;
}
export interface PivotAggsConfigWithUiBase extends PivotAggsConfigBase {
field: EsFieldName;
}
export interface PivotAggsConfigWithExtra<T> extends PivotAggsConfigWithUiBase {
/** Form component */
AggFormComponent: FC<{
aggConfig: Partial<T>;
onChange: (arg: Partial<T>) => void;
selectedField: string;
}>;
/** Aggregation specific configuration */
aggConfig: Partial<T>;
/** Set UI configuration from ES aggregation definition */
setUiConfigFromEs: (arg: { [key: string]: any }) => void;
/** Converts UI agg config form to ES agg request object */
getEsAggConfig: () => { [key: string]: any } | null;
/** Indicates if the configuration is valid */
isValid: () => boolean;
/** Provides aggregation name generated based on the configuration */
getAggName?: () => string | undefined;
/** Helper text for the aggregation reflecting some configuration info */
helperText?: () => string | undefined;
}
interface PivotAggsConfigPercentiles extends PivotAggsConfigWithUiBase {
agg: PIVOT_SUPPORTED_AGGS.PERCENTILES;
agg: typeof PIVOT_SUPPORTED_AGGS.PERCENTILES;
percents: number[];
}
export type PivotAggsConfigWithUiSupport = PivotAggsConfigWithUiBase | PivotAggsConfigPercentiles;
export type PivotAggsConfigWithUiSupport =
| PivotAggsConfigWithUiBase
| PivotAggsConfigPercentiles
| PivotAggsConfigWithExtendedForm;
export function isPivotAggsConfigWithUiSupport(arg: any): arg is PivotAggsConfigWithUiSupport {
return (
@ -81,10 +160,19 @@ export function isPivotAggsConfigWithUiSupport(arg: any): arg is PivotAggsConfig
arg.hasOwnProperty('aggName') &&
arg.hasOwnProperty('dropDownName') &&
arg.hasOwnProperty('field') &&
Object.values(PIVOT_SUPPORTED_AGGS).includes(arg.agg)
isPivotSupportedAggs(arg.agg)
);
}
/**
* Union type for agg configs with extended forms
*/
type PivotAggsConfigWithExtendedForm = PivotAggsConfigFilter;
export function isPivotAggsWithExtendedForm(arg: any): arg is PivotAggsConfigWithExtendedForm {
return arg.hasOwnProperty('AggFormComponent');
}
export function isPivotAggsConfigPercentiles(arg: any): arg is PivotAggsConfigPercentiles {
return (
arg.hasOwnProperty('agg') &&
@ -99,14 +187,28 @@ export type PivotAggsConfig = PivotAggsConfigBase | PivotAggsConfigWithUiSupport
export type PivotAggsConfigWithUiSupportDict = Dictionary<PivotAggsConfigWithUiSupport>;
export type PivotAggsConfigDict = Dictionary<PivotAggsConfig>;
export function getEsAggFromAggConfig(groupByConfig: PivotAggsConfigBase): PivotAgg {
const esAgg = { ...groupByConfig };
/**
* Extracts Elasticsearch-ready aggregation configuration
* from the UI config
*/
export function getEsAggFromAggConfig(
pivotAggsConfig: PivotAggsConfigBase | PivotAggsConfigWithExtendedForm
): PivotAgg | null {
let esAgg: { [key: string]: any } | null = { ...pivotAggsConfig };
delete esAgg.agg;
delete esAgg.aggName;
delete esAgg.dropDownName;
if (isPivotAggsWithExtendedForm(pivotAggsConfig)) {
esAgg = pivotAggsConfig.getEsAggConfig();
if (esAgg === null) {
return null;
}
}
return {
[groupByConfig.agg]: esAgg,
[pivotAggsConfig.agg]: esAgg,
};
}

View file

@ -115,7 +115,11 @@ export function getPreviewRequestBody(
});
aggs.forEach((agg) => {
request.pivot.aggregations[agg.aggName] = getEsAggFromAggConfig(agg);
const result = getEsAggFromAggConfig(agg);
if (result === null) {
return;
}
request.pivot.aggregations[agg.aggName] = result;
});
return request;

View file

@ -4,7 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { TransformId, TransformEndpointRequest, TransformEndpointResult } from '../../../common';
import { useMemo } from 'react';
import { TransformEndpointRequest, TransformEndpointResult, TransformId } from '../../../common';
import { API_BASE_PATH } from '../../../common/constants';
import { useAppDependencies } from '../app_dependencies';
@ -15,54 +16,61 @@ import { EsIndex } from './use_api_types';
export const useApi = () => {
const { http } = useAppDependencies();
return {
getTransforms(transformId?: TransformId): Promise<any> {
const transformIdString = transformId !== undefined ? `/${transformId}` : '';
return http.get(`${API_BASE_PATH}transforms${transformIdString}`);
},
getTransformsStats(transformId?: TransformId): Promise<any> {
if (transformId !== undefined) {
return http.get(`${API_BASE_PATH}transforms/${transformId}/_stats`);
}
return useMemo(
() => ({
getTransforms(transformId?: TransformId): Promise<any> {
const transformIdString = transformId !== undefined ? `/${transformId}` : '';
return http.get(`${API_BASE_PATH}transforms${transformIdString}`);
},
getTransformsStats(transformId?: TransformId): Promise<any> {
if (transformId !== undefined) {
return http.get(`${API_BASE_PATH}transforms/${transformId}/_stats`);
}
return http.get(`${API_BASE_PATH}transforms/_stats`);
},
createTransform(transformId: TransformId, transformConfig: any): Promise<any> {
return http.put(`${API_BASE_PATH}transforms/${transformId}`, {
body: JSON.stringify(transformConfig),
});
},
updateTransform(transformId: TransformId, transformConfig: any): Promise<any> {
return http.post(`${API_BASE_PATH}transforms/${transformId}/_update`, {
body: JSON.stringify(transformConfig),
});
},
deleteTransforms(transformsInfo: TransformEndpointRequest[]): Promise<TransformEndpointResult> {
return http.post(`${API_BASE_PATH}delete_transforms`, {
body: JSON.stringify(transformsInfo),
});
},
getTransformsPreview(obj: PreviewRequestBody): Promise<GetTransformsResponse> {
return http.post(`${API_BASE_PATH}transforms/_preview`, { body: JSON.stringify(obj) });
},
startTransforms(transformsInfo: TransformEndpointRequest[]): Promise<TransformEndpointResult> {
return http.post(`${API_BASE_PATH}start_transforms`, {
body: JSON.stringify(transformsInfo),
});
},
stopTransforms(transformsInfo: TransformEndpointRequest[]): Promise<TransformEndpointResult> {
return http.post(`${API_BASE_PATH}stop_transforms`, {
body: JSON.stringify(transformsInfo),
});
},
getTransformAuditMessages(transformId: TransformId): Promise<any> {
return http.get(`${API_BASE_PATH}transforms/${transformId}/messages`);
},
esSearch(payload: any): Promise<any> {
return http.post(`${API_BASE_PATH}es_search`, { body: JSON.stringify(payload) });
},
getIndices(): Promise<EsIndex[]> {
return http.get(`/api/index_management/indices`);
},
};
return http.get(`${API_BASE_PATH}transforms/_stats`);
},
createTransform(transformId: TransformId, transformConfig: any): Promise<any> {
return http.put(`${API_BASE_PATH}transforms/${transformId}`, {
body: JSON.stringify(transformConfig),
});
},
updateTransform(transformId: TransformId, transformConfig: any): Promise<any> {
return http.post(`${API_BASE_PATH}transforms/${transformId}/_update`, {
body: JSON.stringify(transformConfig),
});
},
deleteTransforms(
transformsInfo: TransformEndpointRequest[]
): Promise<TransformEndpointResult> {
return http.post(`${API_BASE_PATH}delete_transforms`, {
body: JSON.stringify(transformsInfo),
});
},
getTransformsPreview(obj: PreviewRequestBody): Promise<GetTransformsResponse> {
return http.post(`${API_BASE_PATH}transforms/_preview`, { body: JSON.stringify(obj) });
},
startTransforms(
transformsInfo: TransformEndpointRequest[]
): Promise<TransformEndpointResult> {
return http.post(`${API_BASE_PATH}start_transforms`, {
body: JSON.stringify(transformsInfo),
});
},
stopTransforms(transformsInfo: TransformEndpointRequest[]): Promise<TransformEndpointResult> {
return http.post(`${API_BASE_PATH}stop_transforms`, {
body: JSON.stringify(transformsInfo),
});
},
getTransformAuditMessages(transformId: TransformId): Promise<any> {
return http.get(`${API_BASE_PATH}transforms/${transformId}/messages`);
},
esSearch(payload: any): Promise<any> {
return http.post(`${API_BASE_PATH}es_search`, { body: JSON.stringify(payload) });
},
getIndices(): Promise<EsIndex[]> {
return http.get(`/api/index_management/indices`);
},
}),
[http]
);
};

View file

@ -35,6 +35,7 @@ import {
import { SearchItems } from './use_search_items';
import { useApi } from './use_api';
import { isPivotAggsWithExtendedForm } from '../common/pivot_aggs';
function sortColumns(groupByArr: PivotGroupByConfig[]) {
return (a: string, b: string) => {
@ -135,6 +136,14 @@ export const usePivotData = (
return;
}
const isConfigInvalid = aggsArr.some(
(agg) => isPivotAggsWithExtendedForm(agg) && !agg.isValid()
);
if (isConfigInvalid) {
return;
}
setErrorMessage('');
setNoDataMessage('');
setStatus(INDEX_STATUS.LOADING);

View file

@ -2,6 +2,7 @@
exports[`Transform: Aggregation <PopoverForm /> Minimal initialization 1`] = `
<EuiForm
data-test-subj="transformAggPopoverForm_the-group-by-agg-name"
style={
Object {
"width": "300px",
@ -21,9 +22,10 @@ exports[`Transform: Aggregation <PopoverForm /> Minimal initialization 1`] = `
labelType="label"
>
<EuiFieldText
defaultValue="the-group-by-agg-name"
data-test-subj="transformAggName"
isInvalid={false}
onChange={[Function]}
value="the-group-by-agg-name"
/>
</EuiFormRow>
<EuiFormRow
@ -35,6 +37,7 @@ exports[`Transform: Aggregation <PopoverForm /> Minimal initialization 1`] = `
labelType="label"
>
<EuiButton
data-test-subj="transformApplyAggChanges"
isDisabled={false}
onClick={[Function]}
>

View file

@ -8,11 +8,12 @@ import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPopover } from '@elastic/eui';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPopover, EuiTextColor } from '@elastic/eui';
import { AggName, PivotAggsConfig, PivotAggsConfigWithUiSupportDict } from '../../../../common';
import { PopoverForm } from './popover_form';
import { isPivotAggsWithExtendedForm } from '../../../../common/pivot_aggs';
interface Props {
item: PivotAggsConfig;
@ -29,13 +30,17 @@ export const AggLabelForm: React.FC<Props> = ({
onChange,
options,
}) => {
const [isPopoverVisible, setPopoverVisibility] = useState(false);
const [isPopoverVisible, setPopoverVisibility] = useState(
isPivotAggsWithExtendedForm(item) && !item.isValid()
);
function update(updateItem: PivotAggsConfig) {
onChange({ ...updateItem });
setPopoverVisibility(false);
}
const helperText = isPivotAggsWithExtendedForm(item) && item.helperText && item.helperText();
return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem className="transform__AggregationLabel--text">
@ -43,6 +48,17 @@ export const AggLabelForm: React.FC<Props> = ({
{item.aggName}
</span>
</EuiFlexItem>
{helperText && (
<EuiFlexItem grow={false}>
<EuiTextColor
color="subdued"
className="eui-textTruncate"
data-test-subj="transformAggHelperText"
>
{helperText}
</EuiTextColor>
</EuiFlexItem>
)}
<EuiFlexItem grow={false} className="transform__GroupByLabel--button">
<EuiPopover
id="transformFormPopover"

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
@ -15,8 +15,11 @@ import {
EuiForm,
EuiFormRow,
EuiSelect,
EuiSelectOption,
} from '@elastic/eui';
import { cloneDeep } from 'lodash';
import { useUpdateEffect } from 'react-use';
import { dictionaryToArray } from '../../../../../../common/types/common';
import {
@ -30,10 +33,8 @@ import {
PivotAggsConfigWithUiSupportDict,
PIVOT_SUPPORTED_AGGS,
} from '../../../../common';
interface SelectOption {
text: string;
}
import { isPivotAggsWithExtendedForm, PivotSupportedAggs } from '../../../../common/pivot_aggs';
import { getAggFormConfig } from '../step_define/common/get_agg_form_config';
interface Props {
defaultData: PivotAggsConfig;
@ -70,19 +71,45 @@ function parsePercentsInput(inputValue: string | undefined) {
}
export const PopoverForm: React.FC<Props> = ({ defaultData, otherAggNames, onChange, options }) => {
const isUnsupportedAgg = !isPivotAggsConfigWithUiSupport(defaultData);
const [aggConfigDef, setAggConfigDef] = useState(cloneDeep(defaultData));
const [aggName, setAggName] = useState(defaultData.aggName);
const [agg, setAgg] = useState(defaultData.agg);
const [field, setField] = useState(
isPivotAggsConfigWithUiSupport(defaultData) ? defaultData.field : ''
);
const [percents, setPercents] = useState(getDefaultPercents(defaultData));
const availableFields: SelectOption[] = [];
const availableAggs: SelectOption[] = [];
const isUnsupportedAgg = !isPivotAggsConfigWithUiSupport(defaultData);
function updateAgg(aggVal: PIVOT_SUPPORTED_AGGS) {
// Update configuration based on the aggregation type
useEffect(() => {
if (agg === aggConfigDef.agg) return;
const config = getAggFormConfig(agg, {
agg,
aggName,
dropDownName: aggName,
field,
});
setAggConfigDef(config);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agg]);
useUpdateEffect(() => {
if (isPivotAggsWithExtendedForm(aggConfigDef)) {
const name = aggConfigDef.getAggName ? aggConfigDef.getAggName() : undefined;
if (name !== undefined) {
setAggName(name);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [aggConfigDef]);
const availableFields: EuiSelectOption[] = [];
const availableAggs: EuiSelectOption[] = [];
function updateAgg(aggVal: PivotSupportedAggs) {
setAgg(aggVal);
if (aggVal === PIVOT_SUPPORTED_AGGS.PERCENTILES && percents === undefined) {
setPercents(PERCENTILES_AGG_DEFAULT_PERCENTS);
@ -95,9 +122,9 @@ export const PopoverForm: React.FC<Props> = ({ defaultData, otherAggNames, onCha
function getUpdatedItem(): PivotAggsConfig {
let updatedItem: PivotAggsConfig;
if (agg !== PIVOT_SUPPORTED_AGGS.PERCENTILES) {
updatedItem = {
...aggConfigDef,
agg,
aggName,
field,
@ -159,9 +186,12 @@ export const PopoverForm: React.FC<Props> = ({ defaultData, otherAggNames, onCha
if (formValid && agg === PIVOT_SUPPORTED_AGGS.PERCENTILES) {
formValid = validPercents;
}
if (isPivotAggsWithExtendedForm(aggConfigDef)) {
formValid = validAggName && aggConfigDef.isValid();
}
return (
<EuiForm style={{ width: '300px' }}>
<EuiForm style={{ width: '300px' }} data-test-subj={'transformAggPopoverForm_' + aggName}>
<EuiFormRow
error={!validAggName && [aggNameError]}
isInvalid={!validAggName}
@ -178,24 +208,12 @@ export const PopoverForm: React.FC<Props> = ({ defaultData, otherAggNames, onCha
})}
>
<EuiFieldText
defaultValue={aggName}
value={aggName}
isInvalid={!validAggName}
onChange={(e) => setAggName(e.target.value)}
data-test-subj="transformAggName"
/>
</EuiFormRow>
{availableAggs.length > 0 && (
<EuiFormRow
label={i18n.translate('xpack.transform.agg.popoverForm.aggLabel', {
defaultMessage: 'Aggregation',
})}
>
<EuiSelect
options={availableAggs}
value={agg}
onChange={(e) => updateAgg(e.target.value as PIVOT_SUPPORTED_AGGS)}
/>
</EuiFormRow>
)}
{availableFields.length > 0 && (
<EuiFormRow
label={i18n.translate('xpack.transform.agg.popoverForm.fieldLabel', {
@ -206,9 +224,36 @@ export const PopoverForm: React.FC<Props> = ({ defaultData, otherAggNames, onCha
options={availableFields}
value={field}
onChange={(e) => setField(e.target.value)}
data-test-subj="transformAggField"
/>
</EuiFormRow>
)}
{availableAggs.length > 0 && (
<EuiFormRow
label={i18n.translate('xpack.transform.agg.popoverForm.aggLabel', {
defaultMessage: 'Aggregation',
})}
>
<EuiSelect
options={availableAggs}
value={agg}
onChange={(e) => updateAgg(e.target.value as PivotSupportedAggs)}
data-test-subj="transformAggType"
/>
</EuiFormRow>
)}
{isPivotAggsWithExtendedForm(aggConfigDef) && (
<aggConfigDef.AggFormComponent
aggConfig={aggConfigDef.aggConfig}
selectedField={field}
onChange={(update) => {
setAggConfigDef({
...aggConfigDef,
aggConfig: update,
});
}}
/>
)}
{agg === PIVOT_SUPPORTED_AGGS.PERCENTILES && (
<EuiFormRow
label={i18n.translate('xpack.transform.agg.popoverForm.percentsLabel', {
@ -242,7 +287,11 @@ export const PopoverForm: React.FC<Props> = ({ defaultData, otherAggNames, onCha
/>
)}
<EuiFormRow hasEmptyLabelSpace>
<EuiButton isDisabled={!formValid} onClick={() => onChange(getUpdatedItem())}>
<EuiButton
isDisabled={!formValid}
onClick={() => onChange(getUpdatedItem())}
data-test-subj="transformApplyAggChanges"
>
{i18n.translate('xpack.transform.agg.popoverForm.submitButtonLabel', {
defaultMessage: 'Apply',
})}

View file

@ -13,12 +13,12 @@ import {
PivotGroupByConfig,
PivotGroupByConfigDict,
TransformPivotConfig,
PIVOT_SUPPORTED_AGGS,
PIVOT_SUPPORTED_GROUP_BY_AGGS,
} from '../../../../../common';
import { Dictionary } from '../../../../../../../common/types/common';
import { StepDefineExposedState } from './types';
import { getAggConfigFromEsAgg, PivotSupportedAggs } from '../../../../../common/pivot_aggs';
export function applyTransformConfigToDefineState(
state: StepDefineExposedState,
@ -28,14 +28,10 @@ export function applyTransformConfigToDefineState(
if (transformConfig !== undefined) {
// transform aggregations config to wizard state
state.aggList = Object.keys(transformConfig.pivot.aggregations).reduce((aggList, aggName) => {
const aggConfig = transformConfig.pivot.aggregations[aggName] as Dictionary<any>;
const agg = Object.keys(aggConfig)[0];
aggList[aggName] = {
...aggConfig[agg],
agg: agg as PIVOT_SUPPORTED_AGGS,
aggName,
dropDownName: aggName,
} as PivotAggsConfig;
const aggConfig = transformConfig.pivot.aggregations[
aggName as PivotSupportedAggs
] as Dictionary<any>;
aggList[aggName] = getAggConfigFromEsAgg(aggConfig, aggName) as PivotAggsConfig;
return aggList;
}, {} as PivotAggsConfigDict);

View file

@ -6,6 +6,7 @@
import { getPivotDropdownOptions } from '../common';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
import { FilterAggForm } from './filter_agg/components';
describe('Transform: Define Pivot Common', () => {
test('getPivotDropdownOptions()', () => {
@ -28,7 +29,7 @@ describe('Transform: Define Pivot Common', () => {
const options = getPivotDropdownOptions(indexPattern);
expect(options).toEqual({
expect(options).toMatchObject({
aggOptions: [
{
label: ' the-f[i]e>ld ',
@ -40,6 +41,7 @@ describe('Transform: Define Pivot Common', () => {
{ label: 'percentiles( the-f[i]e>ld )' },
{ label: 'sum( the-f[i]e>ld )' },
{ label: 'value_count( the-f[i]e>ld )' },
{ label: 'filter( the-f[i]e>ld )' },
],
},
],
@ -75,6 +77,13 @@ describe('Transform: Define Pivot Common', () => {
dropDownName: 'percentiles( the-f[i]e>ld )',
percents: [1, 5, 25, 50, 75, 95, 99],
},
'filter( the-f[i]e>ld )': {
agg: 'filter',
field: ' the-f[i]e>ld ',
aggName: 'the-field.filter',
dropDownName: 'filter( the-f[i]e>ld )',
AggFormComponent: FilterAggForm,
},
'sum( the-f[i]e>ld )': {
agg: 'sum',
field: ' the-f[i]e>ld ',

View file

@ -0,0 +1,30 @@
/*
* 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 { EuiCodeEditor, EuiSpacer } from '@elastic/eui';
import { FilterAggConfigEditor } from '../types';
export const FilterEditorForm: FilterAggConfigEditor['aggTypeConfig']['FilterAggFormComponent'] = ({
config,
onChange,
}) => {
return (
<>
<EuiSpacer size="m" />
<EuiCodeEditor
value={config}
onChange={(d) => {
onChange({ config: d });
}}
mode="json"
style={{ width: '100%' }}
theme="textmate"
height="300px"
/>
</>
);
};

View file

@ -0,0 +1,163 @@
/*
* 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 { render, fireEvent } from '@testing-library/react';
import React from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { FilterAggForm } from './filter_agg_form';
import { CreateTransformWizardContext } from '../../../../wizard/wizard';
import { KBN_FIELD_TYPES } from '../../../../../../../../../../../../src/plugins/data/common';
import { IndexPattern } from '../../../../../../../../../../../../src/plugins/data/public';
import { FilterTermForm } from './filter_term_form';
describe('FilterAggForm', () => {
const indexPattern = ({
fields: {
getByName: jest.fn((fieldName: string) => {
if (fieldName === 'test_text_field') {
return {
type: KBN_FIELD_TYPES.STRING,
};
}
if (fieldName === 'test_number_field') {
return {
type: KBN_FIELD_TYPES.NUMBER,
};
}
}),
},
} as unknown) as IndexPattern;
test('should render only select dropdown on empty configuration', async () => {
const onChange = jest.fn();
const { getByLabelText, findByTestId, container } = render(
<I18nProvider>
<CreateTransformWizardContext.Provider value={{ indexPattern }}>
<FilterAggForm aggConfig={{}} selectedField="test_text_field" onChange={onChange} />
</CreateTransformWizardContext.Provider>
</I18nProvider>
);
expect(getByLabelText('Filter agg')).toBeInTheDocument();
const { options } = (await findByTestId('transformFilterAggTypeSelector')) as HTMLSelectElement;
expect(container.childElementCount).toBe(1);
expect(options.length).toBe(4);
expect(options[0].value).toBe('');
expect(options[0].selected).toBe(true);
expect(options[1].value).toBe('bool');
expect(options[2].value).toBe('exists');
expect(options[3].value).toBe('term');
});
test('should update "filterAgg" and "aggTypeConfig" on change', async () => {
const onChange = jest.fn();
const { findByTestId } = render(
<I18nProvider>
<CreateTransformWizardContext.Provider value={{ indexPattern }}>
<FilterAggForm aggConfig={{}} selectedField="test_text_field" onChange={onChange} />
</CreateTransformWizardContext.Provider>
</I18nProvider>
);
const select = (await findByTestId('transformFilterAggTypeSelector')) as HTMLSelectElement;
fireEvent.change(select, {
target: { value: 'term' },
});
expect(onChange.mock.calls[0][0]).toMatchObject({
filterAgg: 'term',
aggTypeConfig: {
FilterAggFormComponent: FilterTermForm,
filterAggConfig: {
value: undefined,
},
},
});
});
test('should reset config of field change', async () => {
const onChange = jest.fn();
const { rerender, findByTestId } = render(
<I18nProvider>
<CreateTransformWizardContext.Provider value={{ indexPattern }}>
<FilterAggForm aggConfig={{}} selectedField="test_text_field" onChange={onChange} />
</CreateTransformWizardContext.Provider>
</I18nProvider>
);
// re-render the same component with different props
rerender(
<I18nProvider>
<CreateTransformWizardContext.Provider value={{ indexPattern }}>
<FilterAggForm aggConfig={{}} selectedField="test_number_field" onChange={onChange} />
</CreateTransformWizardContext.Provider>
</I18nProvider>
);
expect(onChange).toHaveBeenCalledWith({});
const { options } = (await findByTestId('transformFilterAggTypeSelector')) as HTMLSelectElement;
expect(options.length).toBe(4);
expect(options[0].value).toBe('');
expect(options[0].selected).toBe(true);
expect(options[1].value).toBe('bool');
expect(options[2].value).toBe('exists');
expect(options[3].value).toBe('range');
});
test('should render additional form if presented in the configuration', async () => {
const onChange = jest.fn();
let childChange: Function;
const DummyComponent = jest.fn(({ config, onChange: onChangeCallback }) => {
childChange = onChangeCallback;
return <div />;
});
const { findByTestId, container } = render(
<I18nProvider>
<CreateTransformWizardContext.Provider value={{ indexPattern }}>
<FilterAggForm
aggConfig={{
filterAgg: 'term',
aggTypeConfig: {
FilterAggFormComponent: DummyComponent,
filterAggConfig: { value: 'test' },
},
}}
selectedField="test_text_field"
onChange={onChange}
/>
</CreateTransformWizardContext.Provider>
</I18nProvider>
);
const { options } = (await findByTestId('transformFilterAggTypeSelector')) as HTMLSelectElement;
expect(options[3].value).toBe('term');
expect(options[3].selected).toBe(true);
expect(container.childElementCount).toBe(2);
// @ts-ignore
expect(DummyComponent.mock.calls[0][0]).toMatchObject({ config: { value: 'test' } });
childChange!({ config: { value: 'test_1' } });
expect(onChange).toHaveBeenCalledWith({
filterAgg: 'term',
aggTypeConfig: {
FilterAggFormComponent: DummyComponent,
filterAggConfig: { value: 'test_1' },
},
});
});
});

View file

@ -0,0 +1,102 @@
/*
* 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, { useContext, useMemo } from 'react';
import { EuiFormRow, EuiSelect } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { useUpdateEffect } from 'react-use';
import { CreateTransformWizardContext } from '../../../../wizard/wizard';
import { commonFilterAggs, filterAggsFieldSupport } from '../constants';
import { IndexPattern } from '../../../../../../../../../../../../src/plugins/data/public';
import { getFilterAggTypeConfig } from '../config';
import { FilterAggType, PivotAggsConfigFilter } from '../types';
/**
* Resolves supported filters for provided field.
*/
export function getSupportedFilterAggs(
fieldName: string,
indexPattern: IndexPattern
): FilterAggType[] {
const field = indexPattern.fields.getByName(fieldName);
if (field === undefined) {
throw new Error(`The field ${fieldName} does not exist in the index`);
}
return [...commonFilterAggs, ...filterAggsFieldSupport[field.type]];
}
/**
* Component for filter aggregation related controls.
*
* Responsible for the filter agg type selection and rendering of
* the corresponded field set.
*/
export const FilterAggForm: PivotAggsConfigFilter['AggFormComponent'] = ({
aggConfig,
onChange,
selectedField,
}) => {
const { indexPattern } = useContext(CreateTransformWizardContext);
const filterAggsOptions = useMemo(() => getSupportedFilterAggs(selectedField, indexPattern!), [
indexPattern,
selectedField,
]);
useUpdateEffect(() => {
// reset filter agg on field change
onChange({});
}, [selectedField]);
const filterAggTypeConfig = aggConfig?.aggTypeConfig;
const filterAgg = aggConfig?.filterAgg ?? '';
return (
<>
<EuiFormRow
label={
<FormattedMessage
id="xpack.transform.agg.popoverForm.filerAggLabel"
defaultMessage="Filter agg"
/>
}
>
<EuiSelect
options={[{ text: '', value: '' }].concat(
filterAggsOptions.map((v) => ({ text: v, value: v }))
)}
value={filterAgg}
onChange={(e) => {
// have to reset aggTypeConfig of filterAgg change
const filterAggUpdate = e.target.value as FilterAggType;
onChange({
filterAgg: filterAggUpdate,
aggTypeConfig: getFilterAggTypeConfig(filterAggUpdate),
});
}}
data-test-subj="transformFilterAggTypeSelector"
/>
</EuiFormRow>
{filterAgg !== '' && filterAggTypeConfig?.FilterAggFormComponent && (
<filterAggTypeConfig.FilterAggFormComponent
config={filterAggTypeConfig?.filterAggConfig}
onChange={(update: any) => {
onChange({
...aggConfig,
aggTypeConfig: {
...filterAggTypeConfig,
filterAggConfig: update.config,
},
});
}}
selectedField={selectedField}
/>
)}
</>
);
};

View file

@ -0,0 +1,111 @@
/*
* 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, { useCallback } from 'react';
import {
EuiFieldNumber,
EuiFormRow,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiButtonToggle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { FilterAggConfigRange } from '../types';
/**
* Form component for the range filter aggregation for number type fields.
*/
export const FilterRangeForm: FilterAggConfigRange['aggTypeConfig']['FilterAggFormComponent'] = ({
config,
onChange,
}) => {
const from = config?.from ?? '';
const to = config?.to ?? '';
const includeFrom = config?.includeFrom ?? false;
const includeTo = config?.includeTo ?? false;
const updateConfig = useCallback(
(update) => {
onChange({
config: {
...config,
...update,
},
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[config]
);
return (
<>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
label={
<FormattedMessage
id="xpack.transform.agg.popoverForm.filerAgg.range.greaterThanLabel"
defaultMessage="Greater than"
/>
}
>
<EuiFieldNumber
value={from}
max={to !== '' ? to : undefined}
onChange={(e) => {
updateConfig({ from: e.target.value === '' ? undefined : Number(e.target.value) });
}}
step={0.1}
prepend={
<EuiButtonToggle
style={{ minWidth: '40px' }}
label={includeFrom ? '≥' : '>'}
onChange={(e: any) => {
updateConfig({ includeFrom: e.target.checked });
}}
isSelected={includeFrom}
isEmpty={!includeFrom}
/>
}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={
<FormattedMessage
id="xpack.transform.agg.popoverForm.filerAgg.range.lessThanLabel"
defaultMessage="Less than"
/>
}
>
<EuiFieldNumber
value={to}
min={from !== '' ? from : undefined}
onChange={(e) => {
updateConfig({ to: e.target.value === '' ? undefined : Number(e.target.value) });
}}
step={0.1}
append={
<EuiButtonToggle
style={{ minWidth: '40px' }}
label={includeTo ? '≤' : '<'}
onChange={(e: any) => {
updateConfig({ includeTo: e.target.checked });
}}
isSelected={includeTo}
isEmpty={!includeTo}
/>
}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};

View file

@ -0,0 +1,152 @@
/*
* 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, { useCallback, useContext, useEffect, useState } from 'react';
import { EuiComboBox, EuiFormRow } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { debounce } from 'lodash';
import { useUpdateEffect } from 'react-use';
import { i18n } from '@kbn/i18n';
import { useApi } from '../../../../../../../hooks';
import { CreateTransformWizardContext } from '../../../../wizard/wizard';
import { FilterAggConfigTerm } from '../types';
import { useToastNotifications } from '../../../../../../../app_dependencies';
/**
* Form component for the term filter aggregation.
*/
export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggFormComponent'] = ({
config,
onChange,
selectedField,
}) => {
const api = useApi();
const { indexPattern } = useContext(CreateTransformWizardContext);
const toastNotifications = useToastNotifications();
const [options, setOptions] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const fetchOptions = useCallback(
debounce(async (searchValue: string) => {
const esSearchRequest = {
index: indexPattern!.title,
body: {
query: {
wildcard: {
[selectedField!]: {
value: `*${searchValue}*`,
},
},
},
aggs: {
field_values: {
terms: {
field: selectedField,
size: 10,
},
},
},
size: 0,
},
};
try {
const response = await api.esSearch(esSearchRequest);
setOptions(
response.aggregations.field_values.buckets.map(
(value: { key: string; doc_count: number }) => ({ label: value.key })
)
);
} catch (e) {
toastNotifications.addWarning(
i18n.translate('xpack.transform.agg.popoverForm.filerAgg.term.errorFetchSuggestions', {
defaultMessage: 'Unable to fetch suggestions',
})
);
}
setIsLoading(false);
}, 600),
[selectedField]
);
const onSearchChange = useCallback(
async (searchValue) => {
if (selectedField === undefined) return;
setIsLoading(true);
setOptions([]);
await fetchOptions(searchValue);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedField]
);
const updateConfig = useCallback(
(update) => {
onChange({
config: {
...config,
...update,
},
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[config]
);
useEffect(() => {
// Simulate initial load.
onSearchChange('');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useUpdateEffect(() => {
// Reset value control on field change
if (!selectedField) return;
onChange({
config: {
value: undefined,
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedField]);
const selectedOptions = config?.value ? [{ label: config.value }] : undefined;
if (selectedField === undefined) return null;
return (
<EuiFormRow
label={
<FormattedMessage
id="xpack.transform.agg.popoverForm.filerAgg.term.valueLabel"
defaultMessage="Value"
/>
}
>
<EuiComboBox
async
isLoading={isLoading}
fullWidth
singleSelection={{ asPlainText: true }}
options={options}
selectedOptions={selectedOptions}
isClearable={false}
onChange={(selected) => {
updateConfig({ value: selected.length > 0 ? selected[0].label : undefined });
}}
onCreateOption={(value) => {
updateConfig({ value });
}}
onSearchChange={onSearchChange}
data-test-subj="transformFilterTermValueSelector"
/>
</EuiFormRow>
);
};

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
export { FilterEditorForm } from './editor_form';
export { FilterAggForm } from './filter_agg_form';
export { FilterTermForm } from './filter_term_form';
export { FilterRangeForm } from './filter_range_form';

View file

@ -0,0 +1,202 @@
/*
* 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 {
isPivotAggsConfigWithUiSupport,
PivotAggsConfigBase,
PivotAggsConfigWithUiBase,
} from '../../../../../../common/pivot_aggs';
import { FILTERS } from './constants';
import { FilterAggForm, FilterEditorForm, FilterRangeForm, FilterTermForm } from './components';
import {
FilterAggConfigBase,
FilterAggConfigBool,
FilterAggConfigExists,
FilterAggConfigRange,
FilterAggConfigTerm,
FilterAggConfigUnion,
FilterAggType,
PivotAggsConfigFilter,
} from './types';
/**
* Gets initial basic configuration of the filter aggregation.
*/
export function getFilterAggConfig(
commonConfig: PivotAggsConfigWithUiBase | PivotAggsConfigBase
): PivotAggsConfigFilter {
return {
...commonConfig,
field: isPivotAggsConfigWithUiSupport(commonConfig) ? commonConfig.field : '',
AggFormComponent: FilterAggForm,
aggConfig: {},
getEsAggConfig() {
// ensure the configuration has been completed
if (!this.isValid()) {
return null;
}
const esAgg = this.aggConfig.aggTypeConfig?.getEsAggConfig(this.field);
return {
[this.aggConfig.filterAgg as string]: esAgg,
};
},
setUiConfigFromEs(esAggDefinition) {
const filterAgg = Object.keys(esAggDefinition)[0] as FilterAggType;
const filterAggConfig = esAggDefinition[filterAgg];
const aggTypeConfig = getFilterAggTypeConfig(filterAgg, filterAggConfig);
// TODO consider moving field to the filter agg type level
this.field = Object.keys(filterAggConfig)[0];
this.aggConfig = {
filterAgg,
aggTypeConfig,
};
},
isValid() {
return (
this.aggConfig?.filterAgg !== undefined &&
(this.aggConfig.aggTypeConfig?.isValid ? this.aggConfig.aggTypeConfig.isValid() : true)
);
},
getAggName() {
return this.aggConfig?.aggTypeConfig?.getAggName
? this.aggConfig.aggTypeConfig.getAggName()
: undefined;
},
helperText() {
return this.aggConfig?.aggTypeConfig?.helperText
? this.aggConfig.aggTypeConfig.helperText()
: undefined;
},
};
}
/**
* Returns a form component for provided filter aggregation type.
*/
export function getFilterAggTypeConfig(
filterAggType: FilterAggConfigUnion['filterAgg'] | FilterAggType,
esConfig?: { [key: string]: any }
): FilterAggConfigUnion['aggTypeConfig'] | FilterAggConfigBase['aggTypeConfig'] {
switch (filterAggType) {
case FILTERS.TERM:
const value = typeof esConfig === 'object' ? Object.values(esConfig)[0] : undefined;
return {
FilterAggFormComponent: FilterTermForm,
filterAggConfig: {
value,
},
getEsAggConfig(fieldName) {
if (fieldName === undefined || !this.filterAggConfig) {
throw new Error(`Config ${FILTERS.TERM} is not completed`);
}
return {
[fieldName]: this.filterAggConfig.value,
};
},
isValid() {
return this.filterAggConfig?.value !== undefined;
},
getAggName() {
return this.filterAggConfig?.value ? this.filterAggConfig.value : undefined;
},
} as FilterAggConfigTerm['aggTypeConfig'];
case FILTERS.RANGE:
const esFilterRange = typeof esConfig === 'object' ? Object.values(esConfig)[0] : undefined;
return {
FilterAggFormComponent: FilterRangeForm,
filterAggConfig:
typeof esFilterRange === 'object'
? {
from: esFilterRange.gte ?? esFilterRange.gt,
to: esFilterRange.lte ?? esFilterRange.lt,
includeFrom: esFilterRange.gte !== undefined,
includeTo: esFilterRange.lts !== undefined,
}
: undefined,
getEsAggConfig(fieldName) {
if (fieldName === undefined || !this.filterAggConfig) {
throw new Error(`Config ${FILTERS.RANGE} is not completed`);
}
const { from, includeFrom, to, includeTo } = this.filterAggConfig;
const result = {} as ReturnType<
FilterAggConfigRange['aggTypeConfig']['getEsAggConfig']
>[0];
if (from) {
result[includeFrom ? 'gte' : 'gt'] = from;
}
if (to) {
result[includeTo ? 'lte' : 'lt'] = to;
}
return {
[fieldName]: result,
};
},
isValid() {
if (
typeof this.filterAggConfig !== 'object' ||
(this.filterAggConfig.from === undefined && this.filterAggConfig.to === undefined)
) {
return false;
}
if (this.filterAggConfig.from !== undefined && this.filterAggConfig.to !== undefined) {
return this.filterAggConfig.from <= this.filterAggConfig.to;
}
return true;
},
helperText() {
if (!this.isValid!()) return;
const { from, to, includeFrom, includeTo } = this.filterAggConfig!;
return `range: ${`${from !== undefined ? `${includeFrom ? '≥' : '>'} ${from}` : ''} ${
from !== undefined && to !== undefined ? '&' : ''
} ${to !== undefined ? `${includeTo ? '≤' : '<'} ${to}` : ''}`.trim()}`;
},
} as FilterAggConfigRange['aggTypeConfig'];
case FILTERS.EXISTS:
return {
getEsAggConfig(fieldName) {
if (fieldName === undefined) {
throw new Error(`Config ${FILTERS.EXISTS} is not completed`);
}
return {
field: fieldName,
};
},
} as FilterAggConfigExists['aggTypeConfig'];
case FILTERS.BOOL:
return {
FilterAggFormComponent: FilterEditorForm,
filterAggConfig: JSON.stringify(
{
must: [],
must_not: [],
should: [],
},
null,
2
),
getEsAggConfig(fieldName) {
return JSON.parse(this.filterAggConfig!);
},
} as FilterAggConfigBool['aggTypeConfig'];
default:
return {
FilterAggFormComponent: FilterEditorForm,
filterAggConfig: '',
getEsAggConfig() {
return this.filterAggConfig !== undefined ? JSON.parse(this.filterAggConfig!) : {};
},
};
}
}

View file

@ -0,0 +1,42 @@
/*
* 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 { KBN_FIELD_TYPES } from '../../../../../../../../../../../src/plugins/data/common';
import { FilterAggType } from './types';
export const FILTERS = {
CUSTOM: 'custom',
PHRASES: 'phrases',
PHRASE: 'phrase',
EXISTS: 'exists',
MATCH_ALL: 'match_all',
MISSING: 'missing',
QUERY_STRING: 'query_string',
RANGE: 'range',
GEO_BOUNDING_BOX: 'geo_bounding_box',
GEO_POLYGON: 'geo_polygon',
SPATIAL_FILTER: 'spatial_filter',
TERM: 'term',
TERMS: 'terms',
BOOL: 'bool',
} as const;
export const filterAggsFieldSupport: { [key: string]: FilterAggType[] } = {
[KBN_FIELD_TYPES.ATTACHMENT]: [],
[KBN_FIELD_TYPES.BOOLEAN]: [],
[KBN_FIELD_TYPES.DATE]: [FILTERS.RANGE],
[KBN_FIELD_TYPES.GEO_POINT]: [FILTERS.GEO_BOUNDING_BOX, FILTERS.GEO_POLYGON],
[KBN_FIELD_TYPES.GEO_SHAPE]: [FILTERS.GEO_BOUNDING_BOX, FILTERS.GEO_POLYGON],
[KBN_FIELD_TYPES.IP]: [FILTERS.RANGE],
[KBN_FIELD_TYPES.MURMUR3]: [],
[KBN_FIELD_TYPES.NUMBER]: [FILTERS.RANGE],
[KBN_FIELD_TYPES.STRING]: [FILTERS.TERM],
[KBN_FIELD_TYPES._SOURCE]: [],
[KBN_FIELD_TYPES.UNKNOWN]: [],
[KBN_FIELD_TYPES.CONFLICT]: [],
};
export const commonFilterAggs = [FILTERS.BOOL, FILTERS.EXISTS];

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { filterAggsFieldSupport, FILTERS } from './constants';
export { FilterAggType } from './types';

View file

@ -0,0 +1,80 @@
/*
* 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 { FC } from 'react';
import { PivotAggsConfigWithExtra } from '../../../../../../common/pivot_aggs';
import { FILTERS } from './constants';
export type FilterAggType = typeof FILTERS[keyof typeof FILTERS];
type FilterAggForm<T> = FC<{
/** Filter aggregation related configuration */
config: Partial<T> | undefined;
/** Callback for configuration updates */
onChange: (arg: Partial<{ config: Partial<T> }>) => void;
/** Selected field for the aggregation */
selectedField?: string;
}>;
interface FilterAggTypeConfig<U, R> {
/** Form component */
FilterAggFormComponent?: U extends undefined ? undefined : FilterAggForm<U>;
/** Filter agg type configuration*/
filterAggConfig?: U extends undefined ? undefined : U;
/** Converts UI agg config form to ES agg request object */
getEsAggConfig: (field?: string) => R;
isValid?: () => boolean;
/** Provides aggregation name generated based on the configuration */
getAggName?: () => string | undefined;
/** Helper text for the aggregation reflecting some configuration info */
helperText?: () => string | undefined;
}
/** Filter agg type definition */
interface FilterAggProps<K extends FilterAggType, U, R = { [key: string]: any }> {
/** Filter aggregation type */
filterAgg: K;
/** Definition of the filter agg config */
aggTypeConfig: FilterAggTypeConfig<U, R>;
}
/** Filter term agg */
export type FilterAggConfigTerm = FilterAggProps<
'term',
{ value: string },
{ [field: string]: string }
>;
/** Filter range agg */
export type FilterAggConfigRange = FilterAggProps<
'range',
{ from?: number; to?: number; includeFrom?: boolean; includeTo?: boolean },
{ [field: string]: { [key in 'gt' | 'gte' | 'lt' | 'lte']: number } }
>;
/** Filter exists agg */
export type FilterAggConfigExists = FilterAggProps<'exists', undefined, { field: string }>;
/** Filter bool agg */
export type FilterAggConfigBool = FilterAggProps<'bool', string>;
/** General type for filter agg */
export type FilterAggConfigEditor = FilterAggProps<FilterAggType, string>;
export type FilterAggConfigUnion =
| FilterAggConfigTerm
| FilterAggConfigRange
| FilterAggConfigBool
| FilterAggConfigExists;
/**
* Union type for filter aggregations
* TODO find out if it's possible to use {@link FilterAggConfigUnion} instead of {@link FilterAggConfigBase}.
* ATM TS is not able to infer a type.
*/
export type PivotAggsConfigFilter = PivotAggsConfigWithExtra<FilterAggConfigBase>;
export interface FilterAggConfigBase {
filterAgg?: FilterAggType;
aggTypeConfig?: any;
}

View file

@ -0,0 +1,28 @@
/*
* 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 {
PIVOT_SUPPORTED_AGGS,
PivotAggsConfigBase,
PivotAggsConfigWithUiBase,
PivotSupportedAggs,
} from '../../../../../common/pivot_aggs';
import { getFilterAggConfig } from './filter_agg/config';
/**
* Gets form configuration for provided aggregation type.
*/
export function getAggFormConfig(
agg: PivotSupportedAggs | string,
commonConfig: PivotAggsConfigBase | PivotAggsConfigWithUiBase
) {
switch (agg) {
case PIVOT_SUPPORTED_AGGS.FILTER:
return getFilterAggConfig(commonConfig);
default:
return commonConfig;
}
}

View file

@ -7,31 +7,38 @@
import {
EsFieldName,
PERCENTILES_AGG_DEFAULT_PERCENTS,
PivotAggsConfigWithUiSupport,
PIVOT_SUPPORTED_AGGS,
PivotAggsConfigWithUiSupport,
} from '../../../../../common';
import { PivotSupportedAggs } from '../../../../../common/pivot_aggs';
import { getFilterAggConfig } from './filter_agg/config';
/**
* Provides a configuration based on the aggregation type.
*/
export function getDefaultAggregationConfig(
aggName: string,
dropDownName: string,
fieldName: EsFieldName,
agg: PIVOT_SUPPORTED_AGGS
agg: PivotSupportedAggs
): PivotAggsConfigWithUiSupport {
const commonConfig = {
agg,
aggName,
dropDownName,
field: fieldName,
};
switch (agg) {
case PIVOT_SUPPORTED_AGGS.PERCENTILES:
return {
...commonConfig,
agg,
aggName,
dropDownName,
field: fieldName,
percents: PERCENTILES_AGG_DEFAULT_PERCENTS,
};
case PIVOT_SUPPORTED_AGGS.FILTER:
return getFilterAggConfig(commonConfig);
default:
return {
agg,
aggName,
dropDownName,
field: fieldName,
};
return commonConfig;
}
}

View file

@ -75,6 +75,9 @@ export const usePivotConfig = (
// The list of selected aggregations
const [aggList, setAggList] = useState(defaults.aggList);
/**
* Adds an aggregation to the list.
*/
const addAggregation = (d: DropDownLabel[]) => {
const label: AggName = d[0].label;
const config: PivotAggsConfig = aggOptionsData[label];
@ -90,6 +93,9 @@ export const usePivotConfig = (
setAggList({ ...aggList });
};
/**
* Adds updated aggregation to the list
*/
const updateAggregation = (previousAggName: AggName, item: PivotAggsConfig) => {
const aggListWithoutPrevious = { ...aggList };
delete aggListWithoutPrevious[previousAggName];
@ -108,6 +114,9 @@ export const usePivotConfig = (
setAggList(aggListWithoutPrevious);
};
/**
* Deletes aggregation from the list
*/
const deleteAggregation = (aggName: AggName) => {
delete aggList[aggName];
setAggList({ ...aggList });

View file

@ -36,7 +36,7 @@ import {
PivotGroupByDict,
PivotGroupByConfigDict,
PivotSupportedGroupByAggs,
PIVOT_SUPPORTED_AGGS,
PivotAggsConfig,
} from '../../../../common';
import { useDocumentationLinks } from '../../../../hooks/use_documentation_links';
import { useIndexData } from '../../../../hooks/use_index_data';
@ -53,6 +53,7 @@ import { SourceSearchBar } from '../source_search_bar';
import { StepDefineExposedState } from './common';
import { useStepDefineForm } from './hooks/use_step_define_form';
import { getAggConfigFromEsAgg } from '../../../../common/pivot_aggs';
export interface StepDefineFormProps {
overrides?: StepDefineExposedState;
@ -153,14 +154,8 @@ export const StepDefineForm: FC<StepDefineFormProps> = React.memo((props) => {
Object.entries(pivot.aggregations).forEach((d) => {
const aggName = d[0];
const aggConfig = d[1] as PivotAggDict;
const aggConfigKeys = Object.keys(aggConfig);
const agg = aggConfigKeys[0] as PIVOT_SUPPORTED_AGGS;
newAggList[aggName] = {
...aggConfig[agg],
agg,
aggName,
dropDownName: '',
};
newAggList[aggName] = getAggConfigFromEsAgg(aggConfig, aggName) as PivotAggsConfig;
});
}
stepDefineForm.pivotConfig.actions.setAggList(newAggList);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, FC, useEffect, useRef, useState } from 'react';
import React, { Fragment, FC, useEffect, useRef, useState, createContext } from 'react';
import { i18n } from '@kbn/i18n';
@ -28,6 +28,7 @@ import {
StepDetailsSummary,
} from '../step_details';
import { WizardNav } from '../wizard_nav';
import { IndexPattern } from '../../../../../../../../../src/plugins/data/public';
enum KBN_MANAGEMENT_PAGE_CLASSNAME {
DEFAULT_BODY = 'mgtPage__body',
@ -85,6 +86,10 @@ interface WizardProps {
searchItems: SearchItems;
}
export const CreateTransformWizardContext = createContext<{ indexPattern: IndexPattern | null }>({
indexPattern: null,
});
export const Wizard: FC<WizardProps> = React.memo(({ cloneConfig, searchItems }) => {
// The current WIZARD_STEP
const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.DEFINE);
@ -213,5 +218,9 @@ export const Wizard: FC<WizardProps> = React.memo(({ cloneConfig, searchItems })
},
];
return <EuiSteps className="transform__steps" steps={stepsConfig} />;
return (
<CreateTransformWizardContext.Provider value={{ indexPattern }}>
<EuiSteps className="transform__steps" steps={stepsConfig} />
</CreateTransformWizardContext.Provider>
);
});

View file

@ -50,6 +50,14 @@ export default function ({ getService }: FtrProviderContext) {
identifier: 'avg(products.base_price)',
label: 'products.base_price.avg',
},
{
identifier: 'filter(geoip.city_name)',
label: 'geoip.city_name.filter',
form: {
transformFilterAggTypeSelector: 'term',
transformFilterTermValueSelector: 'New York',
},
},
],
transformId: `ec_1_${Date.now()}`,
transformDescription:
@ -79,6 +87,13 @@ export default function ({ getService }: FtrProviderContext) {
field: 'products.base_price',
},
},
'geoip.city_name.filter': {
filter: {
term: {
'geoip.city_name': 'New York',
},
},
},
},
},
pivotPreview: {
@ -110,6 +125,13 @@ export default function ({ getService }: FtrProviderContext) {
identifier: 'percentiles(products.base_price)',
label: 'products.base_price.percentiles',
},
{
identifier: 'filter(customer_phone)',
label: 'customer_phone.filter',
form: {
transformFilterAggTypeSelector: 'exists',
},
},
],
transformId: `ec_2_${Date.now()}`,
transformDescription:
@ -134,6 +156,13 @@ export default function ({ getService }: FtrProviderContext) {
percents: [1, 5, 25, 50, 75, 95, 99],
},
},
'customer_phone.filter': {
filter: {
exists: {
field: 'customer_phone',
},
},
},
},
},
pivotPreview: {
@ -223,7 +252,7 @@ export default function ({ getService }: FtrProviderContext) {
for (const [index, agg] of testData.aggregationEntries.entries()) {
await transform.wizard.assertAggregationInputExists();
await transform.wizard.assertAggregationInputValue([]);
await transform.wizard.addAggregationEntry(index, agg.identifier, agg.label);
await transform.wizard.addAggregationEntry(index, agg.identifier, agg.label, agg.form);
}
});

View file

@ -270,10 +270,48 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
);
},
async addAggregationEntry(index: number, identifier: string, expectedLabel: string) {
async addAggregationEntry(
index: number,
identifier: string,
expectedLabel: string,
formData?: Record<string, any>
) {
await comboBox.set('transformAggregationSelection > comboBoxInput', identifier);
await this.assertAggregationInputValue([]);
await this.assertAggregationEntryExists(index, expectedLabel);
if (formData !== undefined) {
await this.fillPopoverForm(identifier, expectedLabel, formData);
}
},
async fillPopoverForm(
identifier: string,
expectedLabel: string,
formData: Record<string, any>
) {
await testSubjects.existOrFail(`transformAggPopoverForm_${expectedLabel}`);
for (const [testObj, value] of Object.entries(formData)) {
switch (testObj) {
case 'transformFilterAggTypeSelector':
await this.selectFilerAggType(value);
break;
case 'transformFilterTermValueSelector':
await this.fillFilterTermValue(value);
break;
}
}
await testSubjects.clickWhenNotDisabled('transformApplyAggChanges');
await testSubjects.missingOrFail(`transformAggPopoverForm_${expectedLabel}`);
},
async selectFilerAggType(value: string) {
await testSubjects.selectValue('transformFilterAggTypeSelector', value);
},
async fillFilterTermValue(value: string) {
await comboBox.set('transformFilterTermValueSelector', value);
},
async assertAdvancedPivotEditorContent(expectedValue: string[]) {