Elasticsearch Query rule can group by multi-terms (#166146)

Resolves: #163829

This PR allows multiple group-by terms to be selected in Elasticsearch
query rule.

<img width="841" alt="Screenshot 2023-09-11 at 22 53 34"
src="19e2dc93-0f5d-4af2-a814-41e812c5b7b7">

<img width="930" alt="Screenshot 2023-09-11 at 22 53 53"
src="617e08d3-4384-41d9-bb7d-ab15292e5055">

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Ying Mao <ying.mao@elastic.co>
This commit is contained in:
Ersin Erdal 2023-09-18 23:05:38 +02:00 committed by GitHub
parent a97bfb3200
commit c486443e76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 569 additions and 205 deletions

View file

@ -6,3 +6,5 @@
*/
export const STACK_ALERTS_FEATURE_ID = 'stackAlerts';
export const MAX_SELECTABLE_GROUP_BY_TERMS = 4;

View file

@ -22,6 +22,7 @@ export const DEFAULT_VALUES = {
TERM_SIZE: 5,
GROUP_BY: 'all',
EXCLUDE_PREVIOUS_HITS: true,
CAN_SELECT_MULTI_TERMS: true,
};
export const COMMON_EXPRESSION_ERRORS = {

View file

@ -351,6 +351,7 @@ export const EsQueryExpression: React.FC<
(exclude) => setParam('excludeHitsFromPreviousRun', exclude),
[setParam]
)}
canSelectMultiTerms={DEFAULT_VALUES.CAN_SELECT_MULTI_TERMS}
/>
<EuiSpacer />

View file

@ -14,12 +14,8 @@ import { EuiSpacer, EuiTitle } from '@elastic/eui';
import { IErrorObject } from '@kbn/triggers-actions-ui-plugin/public';
import type { SearchBarProps } from '@kbn/unified-search-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import {
mapAndFlattenFilters,
getTime,
type SavedQuery,
type ISearchSource,
} from '@kbn/data-plugin/public';
import { mapAndFlattenFilters, getTime } from '@kbn/data-plugin/public';
import type { SavedQuery, ISearchSource } from '@kbn/data-plugin/public';
import {
BUCKET_SELECTOR_FIELD,
buildAggregation,
@ -51,7 +47,9 @@ interface LocalState extends CommonRuleParams {
interface LocalStateAction {
type: SearchSourceParamsAction['type'] | keyof CommonRuleParams;
payload: SearchSourceParamsAction['payload'] | (number[] | number | string | boolean | undefined);
payload:
| SearchSourceParamsAction['payload']
| (number[] | number | string | string[] | boolean | undefined);
}
type LocalStateReducer = (prevState: LocalState, action: LocalStateAction) => LocalState;
@ -201,7 +199,8 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
);
const onChangeSelectedTermField = useCallback(
(selectedTermField?: string) => dispatch({ type: 'termField', payload: selectedTermField }),
(selectedTermField?: string | string[]) =>
dispatch({ type: 'termField', payload: selectedTermField }),
[]
);
@ -372,6 +371,7 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
onCopyQuery={onCopyQuery}
excludeHitsFromPreviousRun={ruleConfiguration.excludeHitsFromPreviousRun}
onChangeExcludeHitsFromPreviousRun={onChangeExcludeHitsFromPreviousRun}
canSelectMultiTerms={DEFAULT_VALUES.CAN_SELECT_MULTI_TERMS}
/>
<EuiSpacer />
</Fragment>

View file

@ -217,6 +217,33 @@ describe('RuleCommonExpressions', () => {
);
});
test(`should use multiple group by terms`, async () => {
const aggType = 'avg';
const thresholdComparator = 'between';
const timeWindowSize = 987;
const timeWindowUnit = 's';
const threshold = [3, 1003];
const groupBy = 'top';
const termSize = '27';
const termField = ['term', 'term2'];
const wrapper = await setup({
ruleParams: getCommonParams({
aggType,
thresholdComparator,
timeWindowSize,
timeWindowUnit,
termSize,
termField,
groupBy,
threshold,
}),
});
expect(wrapper.find('button[data-test-subj="groupByExpression"]').text()).toEqual(
`grouped over ${groupBy} ${termSize} 'term,term2'`
);
});
test(`should disable excludeHitsFromPreviousRuns when groupBy is not all`, async () => {
const aggType = 'avg';
const thresholdComparator = 'between';

View file

@ -52,6 +52,7 @@ export interface RuleCommonExpressionsProps extends CommonRuleParams {
onTestFetch: TestQueryRowProps['fetch'];
onCopyQuery?: TestQueryRowProps['copyQuery'];
onChangeExcludeHitsFromPreviousRun: (exclude: boolean) => void;
canSelectMultiTerms?: boolean;
}
export const RuleCommonExpressions: React.FC<RuleCommonExpressionsProps> = ({
@ -82,6 +83,7 @@ export const RuleCommonExpressions: React.FC<RuleCommonExpressionsProps> = ({
onCopyQuery,
excludeHitsFromPreviousRun,
onChangeExcludeHitsFromPreviousRun,
canSelectMultiTerms,
}) => {
const [isExcludeHitsDisabled, setIsExcludeHitsDisabled] = useState<boolean>(false);
@ -127,6 +129,7 @@ export const RuleCommonExpressions: React.FC<RuleCommonExpressionsProps> = ({
errors={errors}
fields={esFields}
display="fullWidth"
canSelectMultiTerms={canSelectMultiTerms}
onChangeSelectedGroupBy={onChangeSelectedGroupBy}
onChangeSelectedTermField={onChangeSelectedTermField}
onChangeSelectedTermSize={onChangeSelectedTermSize}

View file

@ -27,7 +27,7 @@ export interface CommonRuleParams {
aggField?: string;
groupBy?: string;
termSize?: number;
termField?: string;
termField?: string | string[];
excludeHitsFromPreviousRun: boolean;
}

View file

@ -103,6 +103,46 @@ describe('expression params validation', () => {
expect(validateExpression(initialParams).errors.termField[0]).toBe('Term field is required.');
});
test('if termField property is an array but has no items should return proper error message', () => {
const initialParams: EsQueryRuleParams<SearchType.esQuery> = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
timeField: '',
excludeHitsFromPreviousRun: true,
aggType: 'count',
groupBy: 'top',
termSize: 10,
termField: [],
};
expect(validateExpression(initialParams).errors.termField.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.termField[0]).toBe('Term field is required.');
});
test('if termField property is an array but has more than 4 items, should return proper error message', () => {
const initialParams: EsQueryRuleParams<SearchType.esQuery> = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
timeField: '',
excludeHitsFromPreviousRun: true,
aggType: 'count',
groupBy: 'top',
termSize: 10,
termField: ['term', 'term2', 'term3', 'term4', 'term5'],
};
expect(validateExpression(initialParams).errors.termField.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.termField[0]).toBe(
'Cannot select more than 4 terms'
);
});
test('if esQuery property is invalid JSON should return proper error message', () => {
const initialParams: EsQueryRuleParams<SearchType.esQuery> = {
index: ['test'],

View file

@ -14,6 +14,7 @@ import {
builtInGroupByTypes,
COMPARATORS,
} from '@kbn/triggers-actions-ui-plugin/public';
import { MAX_SELECTABLE_GROUP_BY_TERMS } from '../../../common/constants';
import { EsQueryRuleParams, SearchType } from './types';
import { isEsqlQueryRule, isSearchSourceRule } from './util';
import {
@ -72,7 +73,7 @@ const validateCommonParams = (ruleParams: EsQueryRuleParams) => {
groupBy &&
builtInGroupByTypes[groupBy].validNormalizedTypes &&
builtInGroupByTypes[groupBy].validNormalizedTypes.length > 0 &&
!termField
(!termField || termField.length <= 0)
) {
errors.termField.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTermFieldText', {
@ -81,6 +82,22 @@ const validateCommonParams = (ruleParams: EsQueryRuleParams) => {
);
}
if (
groupBy &&
builtInGroupByTypes[groupBy].validNormalizedTypes &&
builtInGroupByTypes[groupBy].validNormalizedTypes.length > 0 &&
termField &&
Array.isArray(termField) &&
termField.length > MAX_SELECTABLE_GROUP_BY_TERMS
) {
errors.termField.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.overNumberedTermFieldText', {
defaultMessage: `Cannot select more than {max} terms`,
values: { max: MAX_SELECTABLE_GROUP_BY_TERMS },
})
);
}
if (!threshold || threshold.length === 0 || threshold[0] === undefined) {
errors.threshold0.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredThreshold0Text', {

View file

@ -34,7 +34,7 @@ export interface IndexThresholdRuleParams extends RuleTypeParams {
aggField?: string;
groupBy?: string;
termSize?: number;
termField?: string;
termField?: string | string[];
thresholdComparator?: string;
threshold: number[];
timeWindowSize: number;

View file

@ -179,15 +179,51 @@ describe('ruleType Params validate()', () => {
});
it('fails for invalid termField', async () => {
params.termField = ['term', 'term 2'];
params.termSize = 1;
expect(onValidate()).not.toThrow();
params.termField = 'term';
params.termSize = 1;
expect(onValidate()).not.toThrow();
// string or array of string
params.groupBy = 'top';
params.termField = 42;
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[termField]: expected value of type [string] but got [number]"`
expect(onValidate()).toThrow(`[termField]: types that failed validation:
- [termField.0]: expected value of type [string] but got [number]
- [termField.1]: expected value of type [array] but got [number]`);
// no array other than array of stings
params.termField = [1, 2, 3];
expect(onValidate()).toThrow(
`[termField]: types that failed validation:
- [termField.0]: expected value of type [string] but got [Array]
- [termField.1.0]: expected value of type [string] but got [number]`
);
// no empty string
params.termField = '';
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[termField]: value has length [0] but it must have a minimum length of [1]."`
expect(onValidate()).toThrow(
`[termField]: types that failed validation:
- [termField.0]: value has length [0] but it must have a minimum length of [1].
- [termField.1]: could not parse array value from json input`
);
// no array with one element -> has to be a string
params.termField = ['term'];
expect(onValidate()).toThrow(
`[termField]: types that failed validation:
- [termField.0]: expected value of type [string] but got [Array]
- [termField.1]: array size is [1], but cannot be smaller than [2]`
);
// no array that has more than 4 elements
params.termField = ['term', 'term2', 'term3', 'term4', 'term4'];
expect(onValidate()).toThrow(
`[termField]: types that failed validation:
- [termField.0]: expected value of type [string] but got [Array]
- [termField.1]: array size is [5], but cannot be greater than [4]`
);
});

View file

@ -15,6 +15,7 @@ import {
} from '@kbn/triggers-actions-ui-plugin/server';
import { RuleTypeState } from '@kbn/alerting-plugin/server';
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
import { MAX_SELECTABLE_GROUP_BY_TERMS } from '../../../common/constants';
import { ComparatorFnNames } from '../../../common';
import { Comparator } from '../../../common/comparator_types';
import { getComparatorSchemaType } from '../lib/comparator';
@ -48,7 +49,12 @@ const EsQueryRuleParamsSchemaProperties = {
// how to group
groupBy: schema.string({ validate: validateGroupBy, defaultValue: 'all' }),
// field to group on (for groupBy: top)
termField: schema.maybe(schema.string({ minLength: 1 })),
termField: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string(), { minSize: 2, maxSize: MAX_SELECTABLE_GROUP_BY_TERMS }),
])
),
// limit on number of groups returned
termSize: schema.maybe(schema.number({ min: 1 })),
searchType: schema.oneOf(

View file

@ -535,10 +535,10 @@ Props definition:
interface GroupByExpressionProps {
groupBy: string;
termSize?: number;
termField?: string;
termField?: string | string[];
errors: { [key: string]: string[] };
onChangeSelectedTermSize: (selectedTermSize?: number) => void;
onChangeSelectedTermField: (selectedTermField?: string) => void;
onChangeSelectedTermField: (selectedTermField?: string | string[]) => void;
onChangeSelectedGroupBy: (selectedGroupBy?: string) => void;
fields: Record<string, any>;
customGroupByTypes?: {
@ -555,9 +555,9 @@ interface GroupByExpressionProps {
| termSize | Selected term size that will be set as the alert type property. |
| termField | Selected term field that will be set as the alert type property. |
| errors | List of errors with proper messages for the alert params that should be validated. In current component is validated `termSize` and `termField`. |
| onChangeSelectedTermSize | Event handler that will be excuted if selected term size is changed. |
| onChangeSelectedTermField | Event handler that will be excuted if selected term field is changed. |
| onChangeSelectedGroupBy | Event handler that will be excuted if selected group by is changed. |
| onChangeSelectedTermSize | Event handler that will be executed if selected term size is changed. |
| onChangeSelectedTermField | Event handler that will be executed if selected term field is changed. |
| onChangeSelectedGroupBy | Event handler that will be executed if selected group by is changed. |
| fields | Fields list with options for the `termField` dropdown. |
| customGroupByTypes | (Optional) List of group by types that replaces the default options defined in constants `x-pack/plugins/triggers_actions_ui/public/common/constants/group_by_types.ts`. |
| popupPosition | (Optional) expression popup position. Default is `downLeft`. Recommend changing it for a small parent window space. |

View file

@ -247,6 +247,67 @@ describe('buildAgg', () => {
});
});
it('should create correct aggregation when condition params are defined and timeSeries is defined and multi terms selected', async () => {
expect(
buildAggregation({
timeSeries: {
timeField: 'time-field',
timeWindowSize: 5,
timeWindowUnit: 'm',
dateStart: '2021-04-22T15:19:31Z',
dateEnd: '2021-04-22T15:20:31Z',
interval: '1m',
},
aggType: 'count',
aggField: undefined,
termField: ['the-term', 'second-term'],
termSize: 10,
condition: {
resultLimit: 1000,
conditionScript: `params.compareValue > 1`,
},
})
).toEqual({
groupAgg: {
multi_terms: {
size: 10,
terms: [{ field: 'the-term' }, { field: 'second-term' }],
},
aggs: {
conditionSelector: {
bucket_selector: {
buckets_path: {
compareValue: '_count',
},
script: `params.compareValue > 1`,
},
},
dateAgg: {
date_range: {
field: 'time-field',
format: 'strict_date_time',
ranges: [
{
from: '2021-04-22T15:14:31.000Z',
to: '2021-04-22T15:19:31.000Z',
},
{
from: '2021-04-22T15:15:31.000Z',
to: '2021-04-22T15:20:31.000Z',
},
],
},
},
},
},
groupAggCount: {
stats_bucket: {
buckets_path: 'groupAgg._count',
},
},
});
});
it('should create correct aggregation when condition params are defined and timeSeries is undefined', async () => {
expect(
buildAggregation({

View file

@ -19,7 +19,7 @@ export interface BuildAggregationOpts {
aggType: string;
aggField?: string;
termSize?: number;
termField?: string;
termField?: string | string[];
topHitsSize?: number;
condition?: {
resultLimit?: number;
@ -32,7 +32,7 @@ export const BUCKET_SELECTOR_FIELD = `params.${BUCKET_SELECTOR_PATH_NAME}`;
export const DEFAULT_GROUPS = 100;
export const isCountAggregation = (aggType: string) => aggType === 'count';
export const isGroupAggregation = (termField?: string) => !!termField;
export const isGroupAggregation = (termField?: string | string[]) => !!termField;
export const buildAggregation = ({
timeSeries,
@ -48,6 +48,7 @@ export const buildAggregation = ({
};
const isCountAgg = isCountAggregation(aggType);
const isGroupAgg = isGroupAggregation(termField);
const isMultiTerms = Array.isArray(termField);
const isDateAgg = !!timeSeries;
const includeConditionInQuery = !!condition;
@ -82,10 +83,19 @@ export const buildAggregation = ({
if (isGroupAgg) {
aggParent.aggs = {
groupAgg: {
terms: {
field: termField,
size: terms,
},
...(isMultiTerms
? {
multi_terms: {
terms: termField.map((field) => ({ field })),
size: terms,
},
}
: {
terms: {
field: termField,
size: terms,
},
}),
},
...(includeConditionInQuery
? {

View file

@ -6,146 +6,216 @@
*/
import * as React from 'react';
import { shallow } from 'enzyme';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { act } from 'react-dom/test-utils';
import { GroupByExpression } from './group_by_over';
import { FormattedMessage } from '@kbn/i18n-react';
import { render, screen, fireEvent, configure } from '@testing-library/react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
describe('group by expression', () => {
it('renders with builtin group by types', () => {
configure({ testIdAttribute: 'data-test-subj' });
it('renders with builtin group by types', async () => {
const onChangeSelectedTermField = jest.fn();
const onChangeSelectedGroupBy = jest.fn();
const onChangeSelectedTermSize = jest.fn();
const wrapper = shallow(
<GroupByExpression
errors={{ termSize: [], termField: [] }}
fields={[]}
groupBy={'all'}
onChangeSelectedGroupBy={onChangeSelectedGroupBy}
onChangeSelectedTermField={onChangeSelectedTermField}
onChangeSelectedTermSize={onChangeSelectedTermSize}
/>
);
expect(wrapper.find('[data-test-subj="overExpressionSelect"]')).toMatchInlineSnapshot(`
<EuiSelect
data-test-subj="overExpressionSelect"
onChange={[Function]}
options={
Array [
Object {
"text": "all documents",
"value": "all",
},
Object {
"text": "top",
"value": "top",
},
]
}
value="all"
/>
`);
});
it('renders with aggregation type fields', () => {
const onChangeSelectedTermField = jest.fn();
const onChangeSelectedGroupBy = jest.fn();
const onChangeSelectedTermSize = jest.fn();
const wrapper = shallow(
<GroupByExpression
errors={{ termSize: [], termField: [] }}
fields={[
{
normalizedType: 'number',
name: 'test',
type: 'long',
searchable: true,
aggregatable: true,
},
]}
groupBy={'top'}
onChangeSelectedGroupBy={onChangeSelectedGroupBy}
onChangeSelectedTermField={onChangeSelectedTermField}
onChangeSelectedTermSize={onChangeSelectedTermSize}
/>
);
expect(wrapper.find('[data-test-subj="fieldsExpressionSelect"]')).toMatchInlineSnapshot(`
<EuiSelect
data-test-subj="fieldsExpressionSelect"
isInvalid={false}
onBlur={[Function]}
onChange={[Function]}
options={
Array [
Object {
"text": "Select a field",
"value": "",
},
Object {
"text": "test",
"value": "test",
},
]
}
render(
<IntlProvider locale="en">
<GroupByExpression
errors={{ termSize: [], termField: [] }}
fields={[]}
groupBy={'all'}
onChangeSelectedGroupBy={onChangeSelectedGroupBy}
onChangeSelectedTermField={onChangeSelectedTermField}
onChangeSelectedTermSize={onChangeSelectedTermSize}
/>
`);
});
it('renders with default aggreagation type preselected if no aggType was set', () => {
const onChangeSelectedTermField = jest.fn();
const onChangeSelectedGroupBy = jest.fn();
const onChangeSelectedTermSize = jest.fn();
const wrapper = shallow(
<GroupByExpression
errors={{ termSize: [], termField: [] }}
fields={[]}
groupBy={'all'}
onChangeSelectedGroupBy={onChangeSelectedGroupBy}
onChangeSelectedTermField={onChangeSelectedTermField}
onChangeSelectedTermSize={onChangeSelectedTermSize}
/>
</IntlProvider>
);
wrapper.simulate('click');
expect(wrapper.find('[value="all"]').length > 0).toBeTruthy();
expect(
wrapper.contains(
<FormattedMessage
id="xpack.triggersActionsUI.common.expressionItems.groupByType.overButtonLabel"
defaultMessage="over"
/>
)
).toBeTruthy();
fireEvent.click(screen.getByTestId('groupByExpression'));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'all documents' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'top' })).toBeInTheDocument();
});
it('clears selected agg field if fields does not contain current selection', async () => {
const onChangeSelectedTermField = jest.fn();
const wrapper = mountWithIntl(
<GroupByExpression
errors={{ termSize: [], termField: [] }}
fields={[
{
normalizedType: 'number',
name: 'test',
type: 'long',
searchable: true,
aggregatable: true,
},
]}
termField="notavailable"
groupBy={'all'}
onChangeSelectedGroupBy={() => {}}
onChangeSelectedTermSize={() => {}}
onChangeSelectedTermField={onChangeSelectedTermField}
/>
render(
<IntlProvider locale="en">
<GroupByExpression
errors={{ termSize: [], termField: [] }}
fields={[
{
normalizedType: 'number',
name: 'test',
type: 'long',
searchable: true,
aggregatable: true,
},
]}
termField="notavailable"
groupBy={'top'}
onChangeSelectedGroupBy={() => {}}
onChangeSelectedTermSize={() => {}}
onChangeSelectedTermField={onChangeSelectedTermField}
/>
</IntlProvider>
);
expect(onChangeSelectedTermField).toHaveBeenCalledTimes(1);
expect(onChangeSelectedTermField).toHaveBeenCalledWith(undefined);
});
it('clears selected agg field if there is unknown field', async () => {
const onChangeSelectedTermField = jest.fn();
render(
<IntlProvider locale="en">
<GroupByExpression
errors={{ termSize: [], termField: [] }}
fields={[
{
normalizedType: 'number',
name: 'test',
type: 'long',
searchable: true,
aggregatable: true,
},
]}
termField={['test', 'unknown']}
groupBy={'top'}
onChangeSelectedGroupBy={() => {}}
onChangeSelectedTermSize={() => {}}
onChangeSelectedTermField={onChangeSelectedTermField}
/>
</IntlProvider>
);
expect(onChangeSelectedTermField).toHaveBeenCalledTimes(1);
expect(onChangeSelectedTermField).toHaveBeenCalledWith(undefined);
});
it('clears selected agg field if groupBy field is all', async () => {
const onChangeSelectedTermField = jest.fn();
render(
<IntlProvider locale="en">
<GroupByExpression
errors={{ termSize: [], termField: [] }}
fields={[
{
normalizedType: 'number',
name: 'test',
type: 'long',
searchable: true,
aggregatable: true,
},
]}
termField={['test']}
groupBy={'all'}
onChangeSelectedGroupBy={() => {}}
onChangeSelectedTermSize={() => {}}
onChangeSelectedTermField={onChangeSelectedTermField}
/>
</IntlProvider>
);
await act(async () => {
await nextTick();
wrapper.update();
});
expect(onChangeSelectedTermField).toHaveBeenCalledTimes(1);
expect(onChangeSelectedTermField).toHaveBeenCalledWith(undefined);
});
expect(onChangeSelectedTermField).toHaveBeenCalledWith('');
it('calls onChangeSelectedTermField when a termField is selected', async () => {
const onChangeSelectedTermField = jest.fn();
render(
<IntlProvider locale="en">
<GroupByExpression
errors={{ termSize: [], termField: [] }}
fields={[
{
normalizedType: 'number',
name: 'field1',
type: 'long',
searchable: true,
aggregatable: true,
},
{
normalizedType: 'number',
name: 'field2',
type: 'long',
searchable: true,
aggregatable: true,
},
]}
termSize={1}
groupBy={'top'}
onChangeSelectedGroupBy={() => {}}
onChangeSelectedTermSize={() => {}}
onChangeSelectedTermField={onChangeSelectedTermField}
/>
</IntlProvider>
);
expect(onChangeSelectedTermField).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('groupByExpression'));
expect(await screen.findByText(/You are in a dialog/)).toBeInTheDocument();
fireEvent.click(screen.getByTestId('comboBoxToggleListButton'));
const option1 = screen.getByText('field1');
expect(option1).toBeInTheDocument();
fireEvent.click(option1);
expect(onChangeSelectedTermField).toHaveBeenCalledWith('field1');
const option2 = screen.getByText('field2');
expect(option2).toBeInTheDocument();
fireEvent.click(option2);
expect(onChangeSelectedTermField).toHaveBeenCalledWith('field2');
});
it('calls onChangeSelectedTermField when multiple termFields are selected', async () => {
const onChangeSelectedTermField = jest.fn();
render(
<IntlProvider locale="en">
<GroupByExpression
errors={{ termSize: [], termField: [] }}
fields={[
{
normalizedType: 'number',
name: 'field1',
type: 'long',
searchable: true,
aggregatable: true,
},
{
normalizedType: 'number',
name: 'field2',
type: 'long',
searchable: true,
aggregatable: true,
},
]}
termSize={1}
groupBy="top"
onChangeSelectedGroupBy={() => {}}
onChangeSelectedTermSize={() => {}}
onChangeSelectedTermField={onChangeSelectedTermField}
canSelectMultiTerms={true}
/>
</IntlProvider>
);
expect(onChangeSelectedTermField).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('groupByExpression'));
expect(await screen.findByText(/You are in a dialog/)).toBeInTheDocument();
// dropdown is closed
expect(screen.queryByText('field1')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('comboBoxToggleListButton'));
// dropdown is open
expect(screen.getByText('field1')).toBeInTheDocument();
fireEvent.click(screen.getByText('field1'));
expect(onChangeSelectedTermField).toHaveBeenCalledWith('field1');
fireEvent.click(screen.getByText('field2'));
expect(onChangeSelectedTermField).toHaveBeenCalledTimes(2);
expect(onChangeSelectedTermField).toHaveBeenCalledWith(['field1', 'field2']);
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
@ -17,6 +17,8 @@ import {
EuiFormRow,
EuiSelect,
EuiFieldNumber,
EuiComboBoxOptionOption,
EuiComboBox,
} from '@elastic/eui';
import { builtInGroupByTypes } from '../constants';
import { FieldOption, GroupByType } from '../types';
@ -24,18 +26,17 @@ import { ClosablePopoverTitle } from './components';
import { IErrorObject } from '../../types';
interface GroupByOverFieldOption {
text: string;
value: string;
label: string;
}
export interface GroupByExpressionProps {
groupBy: string;
errors: IErrorObject;
onChangeSelectedTermSize: (selectedTermSize?: number) => void;
onChangeSelectedTermField: (selectedTermField?: string) => void;
onChangeSelectedTermField: (selectedTermField?: string | string[]) => void;
onChangeSelectedGroupBy: (selectedGroupBy?: string) => void;
fields: FieldOption[];
termSize?: number;
termField?: string;
termField?: string | string[];
customGroupByTypes?: {
[key: string]: GroupByType;
};
@ -53,6 +54,7 @@ export interface GroupByExpressionProps {
| 'rightUp'
| 'rightDown';
display?: 'fullWidth' | 'inline';
canSelectMultiTerms?: boolean;
}
export const GroupByExpression = ({
@ -67,45 +69,55 @@ export const GroupByExpression = ({
termField,
customGroupByTypes,
popupPosition,
canSelectMultiTerms,
}: GroupByExpressionProps) => {
const groupByTypes = customGroupByTypes ?? builtInGroupByTypes;
const [groupByPopoverOpen, setGroupByPopoverOpen] = useState(false);
const MIN_TERM_SIZE = 1;
const MAX_TERM_SIZE = 1000;
const firstFieldOption: GroupByOverFieldOption = {
text: i18n.translate(
'xpack.triggersActionsUI.common.expressionItems.groupByType.timeFieldOptionLabel',
{
defaultMessage: 'Select a field',
}
),
value: '',
};
const availableFieldOptions: GroupByOverFieldOption[] = fields.reduce(
(options: GroupByOverFieldOption[], field: FieldOption) => {
if (groupByTypes[groupBy].validNormalizedTypes.includes(field.normalizedType)) {
options.push({
text: field.name,
value: field.name,
});
}
return options;
},
[firstFieldOption]
const availableFieldOptions: GroupByOverFieldOption[] = useMemo(
() =>
fields.reduce((options: GroupByOverFieldOption[], field: FieldOption) => {
if (groupByTypes[groupBy].validNormalizedTypes.includes(field.normalizedType)) {
options.push({ label: field.name });
}
return options;
}, []),
[groupByTypes, fields, groupBy]
);
const initialTermFieldOptions = useMemo(() => {
let initialFields: string[] = [];
if (!!termField) {
initialFields = Array.isArray(termField) ? termField : [termField];
}
return initialFields.map((field: string) => ({
label: field,
}));
}, [termField]);
const [selectedTermsFieldsOptions, setSelectedTermsFieldsOptions] =
useState<GroupByOverFieldOption[]>(initialTermFieldOptions);
useEffect(() => {
if (groupBy === builtInGroupByTypes.all.value && selectedTermsFieldsOptions.length > 0) {
setSelectedTermsFieldsOptions([]);
onChangeSelectedTermField(undefined);
}
}, [selectedTermsFieldsOptions, groupBy, onChangeSelectedTermField]);
useEffect(() => {
// if current field set doesn't contain selected field, clear selection
if (
termField &&
termField.length > 0 &&
fields.length > 0 &&
!fields.find((field: FieldOption) => field.name === termField)
) {
onChangeSelectedTermField('');
const hasUnknownField = selectedTermsFieldsOptions.some(
(fieldOption) => !fields.some((field) => field.name === fieldOption.label)
);
if (hasUnknownField) {
setSelectedTermsFieldsOptions([]);
onChangeSelectedTermField(undefined);
}
}, [termField, fields, onChangeSelectedTermField]);
}, [selectedTermsFieldsOptions, fields, onChangeSelectedTermField]);
return (
<EuiPopover
@ -137,7 +149,7 @@ export const GroupByExpression = ({
setGroupByPopoverOpen(true);
}}
display={display === 'inline' ? 'inline' : 'columns'}
isInvalid={!(groupBy === 'all' || (termSize && termField))}
isInvalid={!(groupBy === 'all' || (termSize && termField && termField.length > 0))}
/>
}
isOpen={groupByPopoverOpen}
@ -157,7 +169,7 @@ export const GroupByExpression = ({
/>
</ClosablePopoverTitle>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiFlexItem grow={1}>
<EuiSelect
data-test-subj="overExpressionSelect"
value={groupBy}
@ -182,7 +194,7 @@ export const GroupByExpression = ({
{groupByTypes[groupBy].sizeRequired ? (
<>
<EuiFlexItem grow={false}>
<EuiFlexItem grow={1}>
<EuiFormRow isInvalid={errors.termSize.length > 0} error={errors.termSize}>
<EuiFieldNumber
data-test-subj="fieldsNumberSelect"
@ -201,24 +213,33 @@ export const GroupByExpression = ({
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow
isInvalid={errors.termField.length > 0 && termField !== undefined}
error={errors.termField}
>
<EuiSelect
<EuiFlexItem grow={2}>
<EuiFormRow isInvalid={errors.termField.length > 0} error={errors.termField}>
<EuiComboBox
singleSelection={canSelectMultiTerms ? false : { asPlainText: true }}
placeholder={i18n.translate(
'xpack.triggersActionsUI.common.expressionItems.groupByType.timeFieldOptionLabel',
{
defaultMessage: 'Select a field',
}
)}
data-test-subj="fieldsExpressionSelect"
value={termField}
isInvalid={errors.termField.length > 0 && termField !== undefined}
onChange={(e) => {
onChangeSelectedTermField(e.target.value);
isInvalid={errors.termField.length > 0}
selectedOptions={selectedTermsFieldsOptions}
onChange={(
selectedOptions: Array<EuiComboBoxOptionOption<GroupByOverFieldOption>>
) => {
const selectedTermFields = selectedOptions.map((option) => option.label);
const termsToSave =
Array.isArray(selectedTermFields) && selectedTermFields.length > 1
? selectedTermFields
: selectedTermFields[0];
onChangeSelectedTermField(termsToSave);
setSelectedTermsFieldsOptions(selectedOptions);
}}
options={availableFieldOptions}
onBlur={() => {
if (termField === undefined) {
onChangeSelectedTermField('');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>

View file

@ -368,6 +368,75 @@ export default function ruleTests({ getService }: FtrProviderContext) {
})
);
[
[
'esQuery',
async () => {
await createRule({
name: 'always fire',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
thresholdComparator: '>',
threshold: [-1],
groupBy: 'top',
termField: ['group', 'testedValue'],
termSize: 2,
});
},
] as const,
[
'searchSource',
async () => {
const esTestDataView = await indexPatterns.create(
{ title: ES_TEST_INDEX_NAME, timeFieldName: 'date' },
{ override: true },
getUrlPrefix(Spaces.space1.id)
);
await createRule({
name: 'always fire',
size: 100,
thresholdComparator: '>',
threshold: [-1],
searchType: 'searchSource',
searchConfiguration: {
query: {
query: '',
language: 'kuery',
},
index: esTestDataView.id,
filter: [],
},
groupBy: 'top',
termField: ['group', 'testedValue'],
termSize: 2,
});
},
] as const,
].forEach(([searchType, initData]) =>
it(`runs correctly: threshold on grouped with multi term hit count < > for ${searchType} search type`, async () => {
// write documents from now to the future end date in groups
await createGroupedEsDocumentsInGroups(ES_GROUPS_TO_WRITE, endDate);
await initData();
const docs = await waitForDocs(2);
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const { previousTimestamp, hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('always fire');
const titlePattern = /rule 'always fire' matched query for group group-\d/;
expect(title).to.match(titlePattern);
const messagePattern =
/rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents for group \"group-\d,\d{1,2}\" is greater than -1 over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
expect(previousTimestamp).to.be.empty();
}
})
);
[
[
'esQuery',
@ -1044,7 +1113,7 @@ export default function ruleTests({ getService }: FtrProviderContext) {
aggType?: string;
aggField?: string;
groupBy?: string;
termField?: string;
termField?: string | string[];
termSize?: number;
}