diff --git a/x-pack/plugins/ml/common/util/validators.ts b/x-pack/plugins/ml/common/util/validators.ts index 6f959c50219cf..5dcdec0553106 100644 --- a/x-pack/plugins/ml/common/util/validators.ts +++ b/x-pack/plugins/ml/common/util/validators.ts @@ -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 === '') { diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts new file mode 100644 index 0000000000000..ab00211313d62 --- /dev/null +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts @@ -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', + }, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts index 35dad3a8b2153..d6b3fb974783d 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts @@ -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, 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.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.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, + 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._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, 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 extends PivotAggsConfigWithUiBase { + /** Form component */ + AggFormComponent: FC<{ + aggConfig: Partial; + onChange: (arg: Partial) => void; + selectedField: string; + }>; + /** Aggregation specific configuration */ + aggConfig: Partial; + /** 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; export type PivotAggsConfigDict = Dictionary; -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, }; } diff --git a/x-pack/plugins/transform/public/app/common/request.ts b/x-pack/plugins/transform/public/app/common/request.ts index 99c68cf37b44c..9a0084c2ebffb 100644 --- a/x-pack/plugins/transform/public/app/common/request.ts +++ b/x-pack/plugins/transform/public/app/common/request.ts @@ -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; diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 466575a99b2b4..f3c35d358f1f2 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -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 { - const transformIdString = transformId !== undefined ? `/${transformId}` : ''; - return http.get(`${API_BASE_PATH}transforms${transformIdString}`); - }, - getTransformsStats(transformId?: TransformId): Promise { - if (transformId !== undefined) { - return http.get(`${API_BASE_PATH}transforms/${transformId}/_stats`); - } + return useMemo( + () => ({ + getTransforms(transformId?: TransformId): Promise { + const transformIdString = transformId !== undefined ? `/${transformId}` : ''; + return http.get(`${API_BASE_PATH}transforms${transformIdString}`); + }, + getTransformsStats(transformId?: TransformId): Promise { + 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 { - return http.put(`${API_BASE_PATH}transforms/${transformId}`, { - body: JSON.stringify(transformConfig), - }); - }, - updateTransform(transformId: TransformId, transformConfig: any): Promise { - return http.post(`${API_BASE_PATH}transforms/${transformId}/_update`, { - body: JSON.stringify(transformConfig), - }); - }, - deleteTransforms(transformsInfo: TransformEndpointRequest[]): Promise { - return http.post(`${API_BASE_PATH}delete_transforms`, { - body: JSON.stringify(transformsInfo), - }); - }, - getTransformsPreview(obj: PreviewRequestBody): Promise { - return http.post(`${API_BASE_PATH}transforms/_preview`, { body: JSON.stringify(obj) }); - }, - startTransforms(transformsInfo: TransformEndpointRequest[]): Promise { - return http.post(`${API_BASE_PATH}start_transforms`, { - body: JSON.stringify(transformsInfo), - }); - }, - stopTransforms(transformsInfo: TransformEndpointRequest[]): Promise { - return http.post(`${API_BASE_PATH}stop_transforms`, { - body: JSON.stringify(transformsInfo), - }); - }, - getTransformAuditMessages(transformId: TransformId): Promise { - return http.get(`${API_BASE_PATH}transforms/${transformId}/messages`); - }, - esSearch(payload: any): Promise { - return http.post(`${API_BASE_PATH}es_search`, { body: JSON.stringify(payload) }); - }, - getIndices(): Promise { - return http.get(`/api/index_management/indices`); - }, - }; + return http.get(`${API_BASE_PATH}transforms/_stats`); + }, + createTransform(transformId: TransformId, transformConfig: any): Promise { + return http.put(`${API_BASE_PATH}transforms/${transformId}`, { + body: JSON.stringify(transformConfig), + }); + }, + updateTransform(transformId: TransformId, transformConfig: any): Promise { + return http.post(`${API_BASE_PATH}transforms/${transformId}/_update`, { + body: JSON.stringify(transformConfig), + }); + }, + deleteTransforms( + transformsInfo: TransformEndpointRequest[] + ): Promise { + return http.post(`${API_BASE_PATH}delete_transforms`, { + body: JSON.stringify(transformsInfo), + }); + }, + getTransformsPreview(obj: PreviewRequestBody): Promise { + return http.post(`${API_BASE_PATH}transforms/_preview`, { body: JSON.stringify(obj) }); + }, + startTransforms( + transformsInfo: TransformEndpointRequest[] + ): Promise { + return http.post(`${API_BASE_PATH}start_transforms`, { + body: JSON.stringify(transformsInfo), + }); + }, + stopTransforms(transformsInfo: TransformEndpointRequest[]): Promise { + return http.post(`${API_BASE_PATH}stop_transforms`, { + body: JSON.stringify(transformsInfo), + }); + }, + getTransformAuditMessages(transformId: TransformId): Promise { + return http.get(`${API_BASE_PATH}transforms/${transformId}/messages`); + }, + esSearch(payload: any): Promise { + return http.post(`${API_BASE_PATH}es_search`, { body: JSON.stringify(payload) }); + }, + getIndices(): Promise { + return http.get(`/api/index_management/indices`); + }, + }), + [http] + ); }; diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts index fdd19d75eab46..6266defc01e16 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts @@ -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); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/popover_form.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/popover_form.test.tsx.snap index 24a8144a65ab2..5f74967ff423c 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/popover_form.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/popover_form.test.tsx.snap @@ -2,6 +2,7 @@ exports[`Transform: Aggregation Minimal initialization 1`] = ` Minimal initialization 1`] = ` labelType="label" > Minimal initialization 1`] = ` labelType="label" > diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.tsx index cf3cd24c0439c..a7ff943e0323f 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.tsx @@ -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 = ({ 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 ( @@ -43,6 +48,17 @@ export const AggLabelForm: React.FC = ({ {item.aggName} + {helperText && ( + + + {helperText} + + + )} = ({ 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); + + // 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]); - function updateAgg(aggVal: PIVOT_SUPPORTED_AGGS) { + 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 = ({ 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 = ({ defaultData, otherAggNames, onCha if (formValid && agg === PIVOT_SUPPORTED_AGGS.PERCENTILES) { formValid = validPercents; } + if (isPivotAggsWithExtendedForm(aggConfigDef)) { + formValid = validAggName && aggConfigDef.isValid(); + } return ( - + = ({ defaultData, otherAggNames, onCha })} > setAggName(e.target.value)} + data-test-subj="transformAggName" /> - {availableAggs.length > 0 && ( + {availableFields.length > 0 && ( updateAgg(e.target.value as PIVOT_SUPPORTED_AGGS)} + options={availableFields} + value={field} + onChange={(e) => setField(e.target.value)} + data-test-subj="transformAggField" /> )} - {availableFields.length > 0 && ( + {availableAggs.length > 0 && ( setField(e.target.value)} + options={availableAggs} + value={agg} + onChange={(e) => updateAgg(e.target.value as PivotSupportedAggs)} + data-test-subj="transformAggType" /> )} + {isPivotAggsWithExtendedForm(aggConfigDef) && ( + { + setAggConfigDef({ + ...aggConfigDef, + aggConfig: update, + }); + }} + /> + )} {agg === PIVOT_SUPPORTED_AGGS.PERCENTILES && ( = ({ defaultData, otherAggNames, onCha /> )} - onChange(getUpdatedItem())}> + onChange(getUpdatedItem())} + data-test-subj="transformApplyAggChanges" + > {i18n.translate('xpack.transform.agg.popoverForm.submitButtonLabel', { defaultMessage: 'Apply', })} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts index bda1efe97837f..fba703b1540f9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts @@ -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; - 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; + aggList[aggName] = getAggConfigFromEsAgg(aggConfig, aggName) as PivotAggsConfig; return aggList; }, {} as PivotAggsConfigDict); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts index 4fac3dce3de44..802e17cf156ce 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts @@ -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 ', diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/editor_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/editor_form.tsx new file mode 100644 index 0000000000000..c8adfa0f78d67 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/editor_form.tsx @@ -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 ( + <> + + { + onChange({ config: d }); + }} + mode="json" + style={{ width: '100%' }} + theme="textmate" + height="300px" + /> + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx new file mode 100644 index 0000000000000..7e23e799ae32e --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx @@ -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( + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + // re-render the same component with different props + rerender( + + + + + + ); + + 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
; + }); + + const { findByTestId, container } = render( + + + + + + ); + + 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' }, + }, + }); + }); +}); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.tsx new file mode 100644 index 0000000000000..3e67a16e3c1ed --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.tsx @@ -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 ( + <> + + } + > + ({ 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" + /> + + {filterAgg !== '' && filterAggTypeConfig?.FilterAggFormComponent && ( + { + onChange({ + ...aggConfig, + aggTypeConfig: { + ...filterAggTypeConfig, + filterAggConfig: update.config, + }, + }); + }} + selectedField={selectedField} + /> + )} + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_range_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_range_form.tsx new file mode 100644 index 0000000000000..cfc6bb27c88a1 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_range_form.tsx @@ -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 ( + <> + + + + + } + > + { + updateConfig({ from: e.target.value === '' ? undefined : Number(e.target.value) }); + }} + step={0.1} + prepend={ + '} + onChange={(e: any) => { + updateConfig({ includeFrom: e.target.checked }); + }} + isSelected={includeFrom} + isEmpty={!includeFrom} + /> + } + /> + + + + + } + > + { + updateConfig({ to: e.target.value === '' ? undefined : Number(e.target.value) }); + }} + step={0.1} + append={ + { + updateConfig({ includeTo: e.target.checked }); + }} + isSelected={includeTo} + isEmpty={!includeTo} + /> + } + /> + + + + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx new file mode 100644 index 0000000000000..b48deb771c873 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx @@ -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 ( + + } + > + { + updateConfig({ value: selected.length > 0 ? selected[0].label : undefined }); + }} + onCreateOption={(value) => { + updateConfig({ value }); + }} + onSearchChange={onSearchChange} + data-test-subj="transformFilterTermValueSelector" + /> + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/index.ts new file mode 100644 index 0000000000000..00b586f301dad --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/index.ts @@ -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'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/config.ts new file mode 100644 index 0000000000000..8602a82db8f2f --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/config.ts @@ -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!) : {}; + }, + }; + } +} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/constants.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/constants.ts new file mode 100644 index 0000000000000..e6c36cc6f7db9 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/constants.ts @@ -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]; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/index.ts new file mode 100644 index 0000000000000..b9ac665fce927 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/index.ts @@ -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'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/types.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/types.ts new file mode 100644 index 0000000000000..438fc135f8aa9 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/types.ts @@ -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 = FC<{ + /** Filter aggregation related configuration */ + config: Partial | undefined; + /** Callback for configuration updates */ + onChange: (arg: Partial<{ config: Partial }>) => void; + /** Selected field for the aggregation */ + selectedField?: string; +}>; + +interface FilterAggTypeConfig { + /** Form component */ + FilterAggFormComponent?: U extends undefined ? undefined : FilterAggForm; + /** 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 { + /** Filter aggregation type */ + filterAgg: K; + /** Definition of the filter agg config */ + aggTypeConfig: FilterAggTypeConfig; +} + +/** 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; + +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; + +export interface FilterAggConfigBase { + filterAgg?: FilterAggType; + aggTypeConfig?: any; +} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts new file mode 100644 index 0000000000000..2839c1181c333 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts @@ -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; + } +} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts index 263a8954c96eb..460164c9afe73 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts @@ -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; } } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts index 84b0cbc69f208..72bfbe369757b 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts @@ -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 }); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index 9fd388e0596eb..e0b350542a8f8 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -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 = 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); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index 69fcf822de5d2..5c34eb0d3fdf4 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -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 = React.memo(({ cloneConfig, searchItems }) => { // The current WIZARD_STEP const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.DEFINE); @@ -213,5 +218,9 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) }, ]; - return ; + return ( + + + + ); }); diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 8b407a4a24103..a72f1691af647 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -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); } }); diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index 6a99e6ed007b6..03c8b2867b240 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -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 + ) { 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 + ) { + 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[]) {