[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:
parent
1346b154ad
commit
63bb072064
|
@ -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 === '') {
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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]}
|
||||
>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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',
|
||||
})}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 ',
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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!) : {};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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];
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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[]) {
|
||||
|
|
Loading…
Reference in a new issue