mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
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:
parent
a97bfb3200
commit
c486443e76
18 changed files with 569 additions and 205 deletions
|
@ -6,3 +6,5 @@
|
|||
*/
|
||||
|
||||
export const STACK_ALERTS_FEATURE_ID = 'stackAlerts';
|
||||
|
||||
export const MAX_SELECTABLE_GROUP_BY_TERMS = 4;
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -351,6 +351,7 @@ export const EsQueryExpression: React.FC<
|
|||
(exclude) => setParam('excludeHitsFromPreviousRun', exclude),
|
||||
[setParam]
|
||||
)}
|
||||
canSelectMultiTerms={DEFAULT_VALUES.CAN_SELECT_MULTI_TERMS}
|
||||
/>
|
||||
|
||||
<EuiSpacer />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -27,7 +27,7 @@ export interface CommonRuleParams {
|
|||
aggField?: string;
|
||||
groupBy?: string;
|
||||
termSize?: number;
|
||||
termField?: string;
|
||||
termField?: string | string[];
|
||||
excludeHitsFromPreviousRun: boolean;
|
||||
}
|
||||
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]`
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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. |
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
? {
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue