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 index ab00211313d62..9e748068742f3 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts @@ -44,4 +44,29 @@ describe('getAggConfigFromEsAgg', () => { }, }); }); + + test('should resolve sub-aggregations', () => { + const esConfig = { + filter: { + term: { region: 'sa-west-1' }, + }, + aggs: { + test_avg: { + avg: { + field: 'test_field', + }, + }, + }, + }; + + const result = getAggConfigFromEsAgg(esConfig, 'test_3'); + + expect(result.subAggs!.test_avg).toEqual({ + agg: 'avg', + aggName: 'test_avg', + dropDownName: 'test_avg', + field: 'test_field', + parentAgg: result, + }); + }); }); 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 d6b3fb974783d..54dfd9ecda7b1 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts @@ -79,17 +79,32 @@ export type PivotAggDict = { [key in AggName]: PivotAgg; }; +/** + * The maximum level of sub-aggregations + */ +export const MAX_NESTING_SUB_AGGS = 10; + // The internal representation of an aggregation definition. export interface PivotAggsConfigBase { agg: PivotSupportedAggs; aggName: AggName; dropDownName: string; + /** Indicates if aggregation supports sub-aggregations */ + isSubAggsSupported?: boolean; + /** Dictionary of the sub-aggregations */ + subAggs?: PivotAggsConfigDict; + /** Reference to the parent aggregation */ + parentAgg?: PivotAggsConfig; } /** * Resolves agg UI config from provided ES agg definition */ -export function getAggConfigFromEsAgg(esAggDefinition: Record, aggName: string) { +export function getAggConfigFromEsAgg( + esAggDefinition: Record, + aggName: string, + parentRef?: PivotAggsConfig +) { const aggKeys = Object.keys(esAggDefinition); // Find the main aggregation key @@ -108,12 +123,21 @@ export function getAggConfigFromEsAgg(esAggDefinition: Record, aggN const config = getAggFormConfig(agg, commonConfig); + if (parentRef) { + config.parentAgg = parentRef; + } + if (isPivotAggsWithExtendedForm(config)) { config.setUiConfigFromEs(esAggDefinition[agg]); } if (aggKeys.includes('aggs')) { - // TODO process sub-aggregation + config.subAggs = {}; + for (const [subAggName, subAggConfigs] of Object.entries( + esAggDefinition.aggs as Record + )) { + config.subAggs[subAggName] = getAggConfigFromEsAgg(subAggConfigs, subAggName, config); + } } return config; @@ -199,6 +223,7 @@ export function getEsAggFromAggConfig( delete esAgg.agg; delete esAgg.aggName; delete esAgg.dropDownName; + delete esAgg.parentAgg; if (isPivotAggsWithExtendedForm(pivotAggsConfig)) { esAgg = pivotAggsConfig.getEsAggConfig(); @@ -208,7 +233,20 @@ export function getEsAggFromAggConfig( } } - return { + const result = { [pivotAggsConfig.agg]: esAgg, }; + + if ( + isPivotAggsConfigWithUiSupport(pivotAggsConfig) && + pivotAggsConfig.subAggs !== undefined && + Object.keys(pivotAggsConfig.subAggs).length > 0 + ) { + result.aggs = {}; + for (const subAggConfig of Object.values(pivotAggsConfig.subAggs)) { + result.aggs[subAggConfig.aggName] = getEsAggFromAggConfig(subAggConfig); + } + } + + return result; } 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 6266defc01e16..13544b80ed1b2 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 @@ -31,12 +31,25 @@ import { PivotGroupByConfig, PivotQuery, PreviewMappings, + PivotAggsConfig, } from '../common'; import { SearchItems } from './use_search_items'; import { useApi } from './use_api'; import { isPivotAggsWithExtendedForm } from '../common/pivot_aggs'; +/** + * Checks if the aggregations collection is invalid. + */ +function isConfigInvalid(aggsArray: PivotAggsConfig[]): boolean { + return aggsArray.some((agg) => { + return ( + (isPivotAggsWithExtendedForm(agg) && !agg.isValid()) || + (agg.subAggs && isConfigInvalid(Object.values(agg.subAggs))) + ); + }); +} + function sortColumns(groupByArr: PivotGroupByConfig[]) { return (a: string, b: string) => { // make sure groupBy fields are always most left columns @@ -62,7 +75,7 @@ export const usePivotData = ( const [previewMappings, setPreviewMappings] = useState({ properties: {} }); const api = useApi(); - const aggsArr = dictionaryToArray(aggs); + const aggsArr = useMemo(() => dictionaryToArray(aggs), [aggs]); const groupByArr = dictionaryToArray(groupBy); // Filters mapping properties of type `object`, which get returned for nested field parents. @@ -136,11 +149,7 @@ export const usePivotData = ( return; } - const isConfigInvalid = aggsArr.some( - (agg) => isPivotAggsWithExtendedForm(agg) && !agg.isValid() - ); - - if (isConfigInvalid) { + if (isConfigInvalid(aggsArr)) { return; } @@ -185,7 +194,7 @@ export const usePivotData = ( /* eslint-disable react-hooks/exhaustive-deps */ }, [ indexPatternTitle, - JSON.stringify(aggsArr), + aggsArr, JSON.stringify(groupByArr), JSON.stringify(query), /* eslint-enable react-hooks/exhaustive-deps */ diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx index e5381f09713b5..c28588f727e97 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx @@ -13,6 +13,7 @@ interface Props { placeholder?: string; changeHandler(d: EuiComboBoxOptionOption[]): void; testSubj?: string; + isDisabled?: boolean; } export const DropDown: React.FC = ({ @@ -20,6 +21,7 @@ export const DropDown: React.FC = ({ options, placeholder = 'Search ...', testSubj, + isDisabled, }) => { return ( = ({ onChange={changeHandler} isClearable={false} data-test-subj={testSubj} + isDisabled={isDisabled} /> ); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/agg_label_form.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/agg_label_form.test.tsx.snap index ed32fb3d6ad5f..10258f53aa25b 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/agg_label_form.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/agg_label_form.test.tsx.snap @@ -1,70 +1,72 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Transform: Date histogram aggregation 1`] = ` - - + - - the-group-by-agg-name - - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="transformFormPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" + + the-group-by-agg-name + + + - } - onChange={[Function]} - options={Object {}} - otherAggNames={Array []} + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="transformFormPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + > + + + + + - - - - - - + + + `; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_form.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_form.test.tsx.snap index 3134af4c8b21d..89b54e6d0a22f 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_form.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_form.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Transform: Minimal initialization 1`] = ` = ({ const helperText = isPivotAggsWithExtendedForm(item) && item.helperText && item.helperText(); + const isSubAggSupported = + isPivotAggsConfigWithUiSupport(item) && + item.isSubAggsSupported && + (isPivotAggsWithExtendedForm(item) ? item.isValid() : true); + return ( - - - - {item.aggName} - - - {helperText && ( - - - {helperText} - + <> + + + + {item.aggName} + - )} - - setPopoverVisibility(!isPopoverVisible)} - data-test-subj="transformAggregationEntryEditButton" + {helperText && ( + + + {helperText} + + + )} + + setPopoverVisibility(!isPopoverVisible)} + data-test-subj="transformAggregationEntryEditButton" + /> + } + isOpen={isPopoverVisible} + closePopover={() => setPopoverVisibility(false)} + > + - } - isOpen={isPopoverVisible} - closePopover={() => setPopoverVisibility(false)} - > - + + + deleteHandler(item.aggName)} + data-test-subj="transformAggregationEntryDeleteButton" /> - - - - deleteHandler(item.aggName)} - data-test-subj="transformAggregationEntryDeleteButton" - /> - - + + + + {isSubAggSupported && } + ); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx index cbcb6c668b58a..a02f4455250d7 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx @@ -32,7 +32,7 @@ export const AggListForm: React.FC = ({ deleteHandler, list, onCha const otherAggNames = listKeys.filter((k) => k !== aggName); return ( - + = ({ item }) => { + const { state, actions } = useContext(PivotConfigurationContext)!; + + const addSubAggHandler = useCallback( + (d: EuiComboBoxOptionOption[]) => { + actions.addSubAggregation(item, d); + }, + [actions, item] + ); + + const updateSubAggHandler = useCallback( + (prevSubItemName: string, subItem: PivotAggsConfig) => { + actions.updateSubAggregation(prevSubItemName, subItem); + }, + [actions] + ); + + const deleteSubAggHandler = useCallback( + (subAggName: string) => { + actions.deleteSubAggregation(item, subAggName); + }, + [actions, item] + ); + + const isNewSubAggAllowed: boolean = useMemo(() => { + let nestingLevel = 1; + let parentItem = item.parentAgg; + while (parentItem !== undefined) { + nestingLevel++; + parentItem = parentItem.parentAgg; + } + return nestingLevel <= MAX_NESTING_SUB_AGGS; + }, [item]); + + const dropdown = ( + + ); + + return ( + <> + + {item.subAggs && ( + + )} + {isNewSubAggAllowed ? ( + dropdown + ) : ( + + } + > + {dropdown} + + )} + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx index 5de35a683a376..a3a2e7c4eadfa 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEqual } from 'lodash'; -import React, { memo, FC } from 'react'; +import React, { memo, FC, createContext } from 'react'; import { EuiFormRow } from '@elastic/eui'; @@ -16,20 +15,32 @@ import { DropDown } from '../aggregation_dropdown'; import { GroupByListForm } from '../group_by_list'; import { StepDefineFormHook } from '../step_define'; +export const PivotConfigurationContext = createContext< + StepDefineFormHook['pivotConfig'] | undefined +>(undefined); + export const PivotConfiguration: FC = memo( - ({ - actions: { + ({ actions, state }) => { + const { addAggregation, addGroupBy, deleteAggregation, deleteGroupBy, updateAggregation, updateGroupBy, - }, - state: { aggList, aggOptions, aggOptionsData, groupByList, groupByOptions, groupByOptionsData }, - }) => { + } = actions; + + const { + aggList, + aggOptions, + aggOptionsData, + groupByList, + groupByOptions, + groupByOptionsData, + } = state; + return ( - <> + = memo( /> - + ); - }, - (prevProps, nextProps) => { - return isEqual(prevProps.state, nextProps.state); } ); 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 index 7e23e799ae32e..35f9734a59482 100644 --- 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 @@ -42,7 +42,7 @@ describe('FilterAggForm', () => { ); - expect(getByLabelText('Filter agg')).toBeInTheDocument(); + expect(getByLabelText('Filter query')).toBeInTheDocument(); const { options } = (await findByTestId('transformFilterAggTypeSelector')) as HTMLSelectElement; 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 index 3e67a16e3c1ed..ac6e93d3ed5eb 100644 --- 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 @@ -62,7 +62,7 @@ export const FilterAggForm: PivotAggsConfigFilter['AggFormComponent'] = ({ label={ } > 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 index cfc6bb27c88a1..7f6c23dddb9fc 100644 --- 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 @@ -60,7 +60,8 @@ export const FilterRangeForm: FilterAggConfigRange['aggTypeConfig']['FilterAggFo onChange={(e) => { updateConfig({ from: e.target.value === '' ? undefined : Number(e.target.value) }); }} - step={0.1} + // @ts-ignore + step="any" prepend={ { updateConfig({ to: e.target.value === '' ? undefined : Number(e.target.value) }); }} - step={0.1} + // @ts-ignore + step="any" append={ { // Simulate initial load. onSearchChange(''); + return () => { + // make sure the ongoing request is canceled + fetchOptions.cancel(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -115,7 +117,6 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm value: undefined, }, }); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedField]); const selectedOptions = config?.value ? [{ label: config.value }] : undefined; 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 index 8602a82db8f2f..d8b37b25f50d1 100644 --- 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 @@ -30,6 +30,7 @@ export function getFilterAggConfig( ): PivotAggsConfigFilter { return { ...commonConfig, + isSubAggsSupported: true, field: isPivotAggsConfigWithUiSupport(commonConfig) ? commonConfig.field : '', AggFormComponent: FilterAggForm, aggConfig: {}, 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 72bfbe369757b..d35d567fc8469 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 @@ -4,12 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { dictionaryToArray } from '../../../../../../../common/types/common'; import { useToastNotifications } from '../../../../../app_dependencies'; -import { AggName, DropDownLabel, PivotAggsConfig, PivotGroupByConfig } from '../../../../../common'; +import { + AggName, + DropDownLabel, + PivotAggsConfig, + PivotAggsConfigDict, + PivotGroupByConfig, +} from '../../../../../common'; import { getAggNameConflictToastMessages, @@ -18,136 +24,297 @@ import { } from '../common'; import { StepDefineFormProps } from '../step_define_form'; +/** + * Clones aggregation configuration and updates parent references + * for the sub-aggregations. + */ +function cloneAggItem(item: PivotAggsConfig, parentRef?: PivotAggsConfig) { + const newItem = { ...item }; + if (parentRef !== undefined) { + newItem.parentAgg = parentRef; + } + if (newItem.subAggs !== undefined) { + const newSubAggs: PivotAggsConfigDict = {}; + for (const [key, subItem] of Object.entries(newItem.subAggs)) { + newSubAggs[key] = cloneAggItem(subItem, newItem); + } + newItem.subAggs = newSubAggs; + } + return newItem; +} + +/** + * Returns a root aggregation configuration + * for provided aggregation item. + */ +function getRootAggregation(item: PivotAggsConfig) { + let rootItem = item; + while (rootItem.parentAgg !== undefined) { + rootItem = rootItem.parentAgg; + } + return rootItem; +} + export const usePivotConfig = ( defaults: StepDefineExposedState, indexPattern: StepDefineFormProps['searchItems']['indexPattern'] ) => { const toastNotifications = useToastNotifications(); - const { - aggOptions, - aggOptionsData, - groupByOptions, - groupByOptionsData, - } = getPivotDropdownOptions(indexPattern); + const { aggOptions, aggOptionsData, groupByOptions, groupByOptionsData } = useMemo( + () => getPivotDropdownOptions(indexPattern), + [indexPattern] + ); + // The list of selected aggregations + const [aggList, setAggList] = useState(defaults.aggList); // The list of selected group by fields const [groupByList, setGroupByList] = useState(defaults.groupByList); - const addGroupBy = (d: DropDownLabel[]) => { - const label: AggName = d[0].label; - const config: PivotGroupByConfig = groupByOptionsData[label]; - const aggName: AggName = config.aggName; + const addGroupBy = useCallback( + (d: DropDownLabel[]) => { + const label: AggName = d[0].label; + const config: PivotGroupByConfig = groupByOptionsData[label]; + const aggName: AggName = config.aggName; - const aggNameConflictMessages = getAggNameConflictToastMessages(aggName, aggList, groupByList); - if (aggNameConflictMessages.length > 0) { - aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); - return; - } + const aggNameConflictMessages = getAggNameConflictToastMessages( + aggName, + aggList, + groupByList + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); + return; + } - groupByList[aggName] = config; - setGroupByList({ ...groupByList }); - }; - - const updateGroupBy = (previousAggName: AggName, item: PivotGroupByConfig) => { - const groupByListWithoutPrevious = { ...groupByList }; - delete groupByListWithoutPrevious[previousAggName]; - - const aggNameConflictMessages = getAggNameConflictToastMessages( - item.aggName, - aggList, - groupByListWithoutPrevious - ); - if (aggNameConflictMessages.length > 0) { - aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); - return; - } + groupByList[aggName] = config; + setGroupByList({ ...groupByList }); + }, + [aggList, groupByList, groupByOptionsData, toastNotifications] + ); - groupByListWithoutPrevious[item.aggName] = item; - setGroupByList(groupByListWithoutPrevious); - }; + const updateGroupBy = useCallback( + (previousAggName: AggName, item: PivotGroupByConfig) => { + const groupByListWithoutPrevious = { ...groupByList }; + delete groupByListWithoutPrevious[previousAggName]; - const deleteGroupBy = (aggName: AggName) => { - delete groupByList[aggName]; - setGroupByList({ ...groupByList }); - }; + const aggNameConflictMessages = getAggNameConflictToastMessages( + item.aggName, + aggList, + groupByListWithoutPrevious + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); + return; + } - // The list of selected aggregations - const [aggList, setAggList] = useState(defaults.aggList); + groupByListWithoutPrevious[item.aggName] = item; + setGroupByList(groupByListWithoutPrevious); + }, + [aggList, groupByList, toastNotifications] + ); + + const deleteGroupBy = useCallback( + (aggName: AggName) => { + delete groupByList[aggName]; + setGroupByList({ ...groupByList }); + }, + [groupByList] + ); /** * Adds an aggregation to the list. */ - const addAggregation = (d: DropDownLabel[]) => { - const label: AggName = d[0].label; - const config: PivotAggsConfig = aggOptionsData[label]; - const aggName: AggName = config.aggName; - - const aggNameConflictMessages = getAggNameConflictToastMessages(aggName, aggList, groupByList); - if (aggNameConflictMessages.length > 0) { - aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); - return; - } + const addAggregation = useCallback( + (d: DropDownLabel[]) => { + const label: AggName = d[0].label; + const config: PivotAggsConfig = aggOptionsData[label]; + const aggName: AggName = config.aggName; + + const aggNameConflictMessages = getAggNameConflictToastMessages( + aggName, + aggList, + groupByList + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); + return; + } - aggList[aggName] = config; - setAggList({ ...aggList }); - }; + aggList[aggName] = config; + setAggList({ ...aggList }); + }, + [aggList, aggOptionsData, groupByList, toastNotifications] + ); /** * Adds updated aggregation to the list */ - const updateAggregation = (previousAggName: AggName, item: PivotAggsConfig) => { - const aggListWithoutPrevious = { ...aggList }; - delete aggListWithoutPrevious[previousAggName]; - - const aggNameConflictMessages = getAggNameConflictToastMessages( - item.aggName, - aggListWithoutPrevious, - groupByList - ); - if (aggNameConflictMessages.length > 0) { - aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); - return; - } + const updateAggregation = useCallback( + (previousAggName: AggName, item: PivotAggsConfig) => { + const aggListWithoutPrevious = { ...aggList }; + delete aggListWithoutPrevious[previousAggName]; + + const aggNameConflictMessages = getAggNameConflictToastMessages( + item.aggName, + aggListWithoutPrevious, + groupByList + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); + return; + } + aggListWithoutPrevious[item.aggName] = item; + setAggList(aggListWithoutPrevious); + }, + [aggList, groupByList, toastNotifications] + ); - aggListWithoutPrevious[item.aggName] = item; - setAggList(aggListWithoutPrevious); - }; + /** + * Adds sub-aggregation to the aggregation item + */ + const addSubAggregation = useCallback( + (item: PivotAggsConfig, d: DropDownLabel[]) => { + if (!item.isSubAggsSupported) { + throw new Error(`Aggregation "${item.agg}" does not support sub-aggregations`); + } + const label: AggName = d[0].label; + const config: PivotAggsConfig = aggOptionsData[label]; + + item.subAggs = item.subAggs ?? {}; + + const aggNameConflictMessages = getAggNameConflictToastMessages( + config.aggName, + item.subAggs, + {} + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); + return; + } + + item.subAggs[config.aggName] = config; + + const newRootItem = cloneAggItem(getRootAggregation(item)); + updateAggregation(newRootItem.aggName, newRootItem); + }, + [aggOptionsData, toastNotifications, updateAggregation] + ); + + /** + * Updates sub-aggregation of the aggregation item + */ + const updateSubAggregation = useCallback( + (prevSubItemName: AggName, subItem: PivotAggsConfig) => { + const parent = subItem.parentAgg; + if (!parent || !parent.subAggs) { + throw new Error('No parent aggregation reference found'); + } + + const { [prevSubItemName]: deleted, ...newSubAgg } = parent.subAggs; + + const aggNameConflictMessages = getAggNameConflictToastMessages( + subItem.aggName, + newSubAgg, + {} + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); + return; + } + + parent.subAggs = { + ...newSubAgg, + [subItem.aggName]: subItem, + }; + const newRootItem = cloneAggItem(getRootAggregation(subItem)); + updateAggregation(newRootItem.aggName, newRootItem); + }, + [toastNotifications, updateAggregation] + ); + + /** + * Deletes sub-aggregation of the aggregation item + */ + const deleteSubAggregation = useCallback( + (item: PivotAggsConfig, subAggName: string) => { + if (!item.subAggs || !item.subAggs[subAggName]) { + throw new Error('Unable to delete a sub-agg'); + } + delete item.subAggs[subAggName]; + const newRootItem = cloneAggItem(getRootAggregation(item)); + updateAggregation(newRootItem.aggName, newRootItem); + }, + [updateAggregation] + ); /** * Deletes aggregation from the list */ - const deleteAggregation = (aggName: AggName) => { - delete aggList[aggName]; - setAggList({ ...aggList }); - }; + const deleteAggregation = useCallback( + (aggName: AggName) => { + delete aggList[aggName]; + setAggList({ ...aggList }); + }, + [aggList] + ); - const pivotAggsArr = dictionaryToArray(aggList); - const pivotGroupByArr = dictionaryToArray(groupByList); + const pivotAggsArr = useMemo(() => dictionaryToArray(aggList), [aggList]); + const pivotGroupByArr = useMemo(() => dictionaryToArray(groupByList), [groupByList]); const valid = pivotGroupByArr.length > 0 && pivotAggsArr.length > 0; - return { - actions: { + const actions = useMemo(() => { + return { addAggregation, addGroupBy, + addSubAggregation, + updateSubAggregation, + deleteSubAggregation, deleteAggregation, deleteGroupBy, setAggList, setGroupByList, updateAggregation, updateGroupBy, - }, - state: { - aggList, - aggOptions, - aggOptionsData, - groupByList, - groupByOptions, - groupByOptionsData, - pivotAggsArr, - pivotGroupByArr, - valid, - }, - }; + }; + }, [ + addAggregation, + addGroupBy, + addSubAggregation, + deleteAggregation, + deleteGroupBy, + deleteSubAggregation, + updateAggregation, + updateGroupBy, + updateSubAggregation, + ]); + + return useMemo(() => { + return { + actions, + state: { + aggList, + aggOptions, + aggOptionsData, + groupByList, + groupByOptions, + groupByOptionsData, + pivotAggsArr, + pivotGroupByArr, + valid, + }, + }; + }, [ + actions, + aggList, + aggOptions, + aggOptionsData, + groupByList, + groupByOptions, + groupByOptionsData, + pivotAggsArr, + pivotGroupByArr, + valid, + ]); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts index fc47a9e3d3477..f5980ae2243d3 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts @@ -73,7 +73,7 @@ export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefi }, [ JSON.stringify(advancedPivotEditor.state), JSON.stringify(advancedSourceEditor.state), - JSON.stringify(pivotConfig.state), + pivotConfig.state, JSON.stringify(searchBar.state), /* eslint-enable react-hooks/exhaustive-deps */ ]); 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 a72f1691af647..d6dbcde436dcc 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -57,6 +57,26 @@ export default function ({ getService }: FtrProviderContext) { transformFilterAggTypeSelector: 'term', transformFilterTermValueSelector: 'New York', }, + subAggs: [ + { + identifier: 'max(products.base_price)', + label: 'products.base_price.max', + }, + { + identifier: 'filter(customer_gender)', + label: 'customer_gender.filter', + form: { + transformFilterAggTypeSelector: 'term', + transformFilterTermValueSelector: 'FEMALE', + }, + subAggs: [ + { + identifier: 'avg(taxful_total_price)', + label: 'taxful_total_price.avg', + }, + ], + }, + ], }, ], transformId: `ec_1_${Date.now()}`, @@ -87,12 +107,33 @@ export default function ({ getService }: FtrProviderContext) { field: 'products.base_price', }, }, - 'geoip.city_name.filter': { + 'New York': { filter: { term: { 'geoip.city_name': 'New York', }, }, + aggs: { + 'products.base_price.max': { + max: { + field: 'products.base_price', + }, + }, + FEMALE: { + filter: { + term: { + customer_gender: 'FEMALE', + }, + }, + aggs: { + 'taxful_total_price.avg': { + avg: { + field: 'taxful_total_price', + }, + }, + }, + }, + }, }, }, }, @@ -131,6 +172,12 @@ export default function ({ getService }: FtrProviderContext) { form: { transformFilterAggTypeSelector: 'exists', }, + subAggs: [ + { + identifier: 'max(products.discount_amount)', + label: 'products.discount_amount.max', + }, + ], }, ], transformId: `ec_2_${Date.now()}`, @@ -162,6 +209,13 @@ export default function ({ getService }: FtrProviderContext) { field: 'customer_phone', }, }, + aggs: { + 'products.discount_amount.max': { + max: { + field: 'products.discount_amount', + }, + }, + }, }, }, }, @@ -249,11 +303,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('adds the aggregation entries', async () => { - 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, agg.form); - } + await transform.wizard.addAggregationEntries(testData.aggregationEntries); }); it('displays the advanced pivot editor switch', async () => { diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index 03c8b2867b240..8b61e8c895e30 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -242,14 +242,20 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { await this.assertGroupByEntryExists(index, expectedLabel, expectedIntervalLabel); }, - async assertAggregationInputExists() { - await testSubjects.existOrFail('transformAggregationSelection > comboBoxInput'); + getAggComboBoxInputSelector(parentSelector = ''): string { + return `${parentSelector && `${parentSelector} > `}${ + parentSelector ? 'transformSubAggregationSelection' : 'transformAggregationSelection' + } > comboBoxInput`; }, - async assertAggregationInputValue(expectedIdentifier: string[]) { + async assertAggregationInputExists(parentSelector?: string) { + await testSubjects.existOrFail(this.getAggComboBoxInputSelector(parentSelector)); + }, + + async assertAggregationInputValue(expectedIdentifier: string[], parentSelector?: string) { await retry.tryForTime(2000, async () => { const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( - 'transformAggregationSelection > comboBoxInput' + this.getAggComboBoxInputSelector(parentSelector) ); expect(comboBoxSelectedOptions).to.eql( expectedIdentifier, @@ -258,11 +264,14 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { }); }, - async assertAggregationEntryExists(index: number, expectedLabel: string) { - await testSubjects.existOrFail(`transformAggregationEntry ${index}`); + async assertAggregationEntryExists(index: number, expectedLabel: string, parentSelector = '') { + const aggEntryPanelSelector = `${ + parentSelector && `${parentSelector} > ` + }transformAggregationEntry_${index}`; + await testSubjects.existOrFail(aggEntryPanelSelector); const actualLabel = await testSubjects.getVisibleText( - `transformAggregationEntry ${index} > transformAggregationEntryLabel` + `${aggEntryPanelSelector} > transformAggregationEntryLabel` ); expect(actualLabel).to.eql( expectedLabel, @@ -270,15 +279,31 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { ); }, + async addAggregationEntries(aggregationEntries: any[], parentSelector?: string) { + for (const [index, agg] of aggregationEntries.entries()) { + await this.assertAggregationInputExists(parentSelector); + await this.assertAggregationInputValue([], parentSelector); + await this.addAggregationEntry(index, agg.identifier, agg.label, agg.form, parentSelector); + + if (agg.subAggs) { + await this.addAggregationEntries( + agg.subAggs, + `${parentSelector ? `${parentSelector} > ` : ''}transformAggregationEntry_${index}` + ); + } + } + }, + async addAggregationEntry( index: number, identifier: string, expectedLabel: string, - formData?: Record + formData?: Record, + parentSelector = '' ) { - await comboBox.set('transformAggregationSelection > comboBoxInput', identifier); - await this.assertAggregationInputValue([]); - await this.assertAggregationEntryExists(index, expectedLabel); + await comboBox.set(this.getAggComboBoxInputSelector(parentSelector), identifier); + await this.assertAggregationInputValue([], parentSelector); + await this.assertAggregationEntryExists(index, expectedLabel, parentSelector); if (formData !== undefined) { await this.fillPopoverForm(identifier, expectedLabel, formData);