diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index 27e365ce0cb3716..e1b42b7c193e2af 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -38,6 +38,7 @@ const createStartContract = (): Start => { }), get: jest.fn().mockReturnValue(Promise.resolve({})), clearCache: jest.fn(), + getIdsWithTitle: jest.fn(), } as unknown as DataViewsContract; return { diff --git a/src/plugins/data/public/query/mocks.ts b/src/plugins/data/public/query/mocks.ts index a2d73e5b5ce340c..296a61afef2fd9a 100644 --- a/src/plugins/data/public/query/mocks.ts +++ b/src/plugins/data/public/query/mocks.ts @@ -32,7 +32,7 @@ const createStartContractMock = () => { addToQueryLog: jest.fn(), filterManager: createFilterManagerMock(), queryString: queryStringManagerMock.createStartContract(), - savedQueries: jest.fn() as any, + savedQueries: { getSavedQuery: jest.fn() } as any, state$: new Observable(), getState: jest.fn(), timefilter: timefilterServiceMock.createStartContract(), diff --git a/src/plugins/data_views/public/mocks.ts b/src/plugins/data_views/public/mocks.ts index 61713c9406c2358..3767c93be10e639 100644 --- a/src/plugins/data_views/public/mocks.ts +++ b/src/plugins/data_views/public/mocks.ts @@ -28,6 +28,7 @@ const createStartContract = (): Start => { get: jest.fn().mockReturnValue(Promise.resolve({})), clearCache: jest.fn(), getCanSaveSync: jest.fn(), + getIdsWithTitle: jest.fn(), } as unknown as jest.Mocked; }; diff --git a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx index f2ac0d2bfa060f9..ee35e10b6631a05 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx @@ -74,6 +74,7 @@ export const getTopNavLinks = ({ anchorElement, searchSource: savedSearch.searchSource, services, + savedQueryId: state.appStateContainer.getState().savedQuery, }); }, testId: 'discoverAlertsButton', diff --git a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx index d414919e567f98f..71a0ef3df1b8c8d 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx @@ -26,9 +26,15 @@ interface AlertsPopoverProps { onClose: () => void; anchorElement: HTMLElement; searchSource: ISearchSource; + savedQueryId?: string; } -export function AlertsPopover({ searchSource, anchorElement, onClose }: AlertsPopoverProps) { +export function AlertsPopover({ + searchSource, + anchorElement, + savedQueryId, + onClose, +}: AlertsPopoverProps) { const dataView = searchSource.getField('index')!; const services = useDiscoverServices(); const { triggersActionsUi } = services; @@ -49,8 +55,9 @@ export function AlertsPopover({ searchSource, anchorElement, onClose }: AlertsPo return { searchType: 'searchSource', searchConfiguration: nextSearchSource.getSerializedFields(), + savedQueryId, }; - }, [searchSource, services]); + }, [savedQueryId, searchSource, services]); const SearchThresholdAlertFlyout = useMemo(() => { if (!alertFlyoutVisible) { @@ -156,11 +163,13 @@ export function openAlertsPopover({ anchorElement, searchSource, services, + savedQueryId, }: { I18nContext: I18nStart['Context']; anchorElement: HTMLElement; searchSource: ISearchSource; services: DiscoverServices; + savedQueryId?: string; }) { if (isOpen) { closeAlertsPopover(); @@ -177,6 +186,7 @@ export function openAlertsPopover({ onClose={closeAlertsPopover} anchorElement={anchorElement} searchSource={searchSource} + savedQueryId={savedQueryId} /> diff --git a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx index 387b5e751ff44ba..847140fd8e27219 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx @@ -42,7 +42,6 @@ export interface FilterItemProps { uiSettings: IUiSettingsClient; hiddenPanelOptions?: FilterPanelOption[]; timeRangeForSuggestionsOverride?: boolean; - readonly?: boolean; } type FilterPopoverProps = HTMLAttributes & EuiPopoverProps; @@ -364,7 +363,6 @@ export function FilterItem(props: FilterItemProps) { iconOnClick: handleIconClick, onClick: handleBadgeClick, 'data-test-subj': getDataTestSubj(valueLabelConfig), - readonly: props.readonly, }; const popoverProps: FilterPopoverProps = { @@ -379,18 +377,6 @@ export function FilterItem(props: FilterItemProps) { panelPaddingSize: 'none', }; - if (props.readonly) { - return ( - - - - ); - } - return ( {renderedComponent === 'menu' ? ( diff --git a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx index d399bb0025a1096..0e1076613982071 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx @@ -19,7 +19,6 @@ interface Props { fieldLabel?: string; filterLabelStatus: FilterLabelStatus; errorMessage?: string; - readonly?: boolean; hideAlias?: boolean; [propName: string]: any; } @@ -32,7 +31,6 @@ export const FilterView: FC = ({ fieldLabel, errorMessage, filterLabelStatus, - readonly, hideAlias, ...rest }: Props) => { @@ -56,45 +54,29 @@ export const FilterView: FC = ({ })} ${title}`; } - const badgeProps: EuiBadgeProps = readonly - ? { - title, - color: 'hollow', - onClick, - onClickAriaLabel: i18n.translate( - 'unifiedSearch.filter.filterBar.filterItemReadOnlyBadgeAriaLabel', - { - defaultMessage: 'Filter entry', - } - ), - iconOnClick, + const badgeProps: EuiBadgeProps = { + title, + color: 'hollow', + iconType: 'cross', + iconSide: 'right', + closeButtonProps: { + // Removing tab focus on close button because the same option can be obtained through the context menu + // Also, we may want to add a `DEL` keyboard press functionality + tabIndex: -1, + }, + iconOnClick, + iconOnClickAriaLabel: i18n.translate( + 'unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel', + { + defaultMessage: 'Delete {filter}', + values: { filter: innerText }, } - : { - title, - color: 'hollow', - iconType: 'cross', - iconSide: 'right', - closeButtonProps: { - // Removing tab focus on close button because the same option can be obtained through the context menu - // Also, we may want to add a `DEL` keyboard press functionality - tabIndex: -1, - }, - iconOnClick, - iconOnClickAriaLabel: i18n.translate( - 'unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel', - { - defaultMessage: 'Delete {filter}', - values: { filter: innerText }, - } - ), - onClick, - onClickAriaLabel: i18n.translate( - 'unifiedSearch.filter.filterBar.filterItemBadgeAriaLabel', - { - defaultMessage: 'Filter actions', - } - ), - }; + ), + onClick, + onClickAriaLabel: i18n.translate('unifiedSearch.filter.filterBar.filterItemBadgeAriaLabel', { + defaultMessage: 'Filter actions', + }), + }; return ( diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index 0ad4756e9177bb0..d62a7f79c82de1d 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -43,6 +43,7 @@ import { shallowEqual } from '../utils/shallow_equal'; import { AddFilterPopover } from './add_filter_popover'; import { DataViewPicker, DataViewPickerProps } from '../dataview_picker'; import { FilterButtonGroup } from '../filter_bar/filter_button_group/filter_button_group'; +import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import './query_bar.scss'; const SuperDatePicker = React.memo( @@ -88,6 +89,7 @@ export interface QueryBarTopRowProps { filterBar?: React.ReactNode; showDatePickerAsBadge?: boolean; showSubmitButton?: boolean; + suggestionsSize?: SuggestionsListSize; isScreenshotMode?: boolean; } @@ -483,6 +485,7 @@ export const QueryBarTopRow = React.memo( timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride} disableLanguageSwitcher={true} prepend={renderFilterMenuOnly() && renderFilterButtonGroup()} + size={props.suggestionsSize} /> )} diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index a6ca444612402ba..9d96ba936f708ac 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -29,6 +29,7 @@ import { QueryBarMenu, QueryBarMenuProps } from '../query_string_input/query_bar import type { DataViewPickerProps } from '../dataview_picker'; import QueryBarTopRow from '../query_string_input/query_bar_top_row'; import { FilterBar, FilterItems } from '../filter_bar'; +import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import { searchBarStyles } from './search_bar.styles'; export interface SearchBarInjectedDeps { @@ -88,6 +89,8 @@ export interface SearchBarOwnProps { fillSubmitButton?: boolean; dataViewPickerComponentProps?: DataViewPickerProps; showSubmitButton?: boolean; + // defines size of suggestions query popover + suggestionsSize?: SuggestionsListSize; isScreenshotMode?: boolean; } @@ -485,6 +488,7 @@ class SearchBarUI extends Component { dataViewPickerComponentProps={this.props.dataViewPickerComponentProps} showDatePickerAsBadge={this.shouldShowDatePickerAsBadge()} filterBar={filterBar} + suggestionsSize={this.props.suggestionsSize} isScreenshotMode={this.props.isScreenshotMode} /> diff --git a/x-pack/plugins/stack_alerts/kibana.json b/x-pack/plugins/stack_alerts/kibana.json index abafba8010fbc29..ff436ef53fae7bd 100644 --- a/x-pack/plugins/stack_alerts/kibana.json +++ b/x-pack/plugins/stack_alerts/kibana.json @@ -14,7 +14,8 @@ "triggersActionsUi", "kibanaReact", "savedObjects", - "data" + "data", + "kibanaUtils" ], "configPath": ["xpack", "stack_alerts"], "requiredBundles": ["esUiShared"], diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx new file mode 100644 index 000000000000000..94e6a6b0c0cd442 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; +import { DataViewSelectPopover } from './data_view_select_popover'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { act } from 'react-dom/test-utils'; + +const props = { + onSelectDataView: () => {}, + initialDataViewTitle: 'kibana_sample_data_logs', + initialDataViewId: 'mock-data-logs-id', +}; + +const dataViewOptions = [ + { + id: 'mock-data-logs-id', + namespaces: ['default'], + title: 'kibana_sample_data_logs', + }, + { + id: 'mock-flyghts-id', + namespaces: ['default'], + title: 'kibana_sample_data_flights', + }, + { + id: 'mock-ecommerce-id', + namespaces: ['default'], + title: 'kibana_sample_data_ecommerce', + typeMeta: {}, + }, + { + id: 'mock-test-id', + namespaces: ['default'], + title: 'test', + typeMeta: {}, + }, +]; + +const mount = () => { + const dataViewsMock = dataViewPluginMocks.createStartContract(); + dataViewsMock.getIdsWithTitle.mockImplementation(() => Promise.resolve(dataViewOptions)); + + return { + wrapper: mountWithIntl( + + + + ), + dataViewsMock, + }; +}; + +describe('DataViewSelectPopover', () => { + test('renders properly', async () => { + const { wrapper, dataViewsMock } = mount(); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(dataViewsMock.getIdsWithTitle).toHaveBeenCalled(); + expect(wrapper.find('[data-test-subj="selectDataViewExpression"]').exists()).toBeTruthy(); + + const getIdsWithTitleResult = await dataViewsMock.getIdsWithTitle.mock.results[0].value; + expect(getIdsWithTitleResult).toBe(dataViewOptions); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx new file mode 100644 index 000000000000000..a62b640e0d8eb4b --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonIcon, + EuiExpression, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import { DataViewsList } from '@kbn/unified-search-plugin/public'; +import { DataViewListItem } from '@kbn/data-views-plugin/public'; +import { useTriggersAndActionsUiDeps } from '../es_query/util'; + +interface DataViewSelectPopoverProps { + onSelectDataView: (newDataViewId: string) => void; + initialDataViewTitle: string; + initialDataViewId?: string; +} + +export const DataViewSelectPopover: React.FunctionComponent = ({ + onSelectDataView, + initialDataViewTitle, + initialDataViewId, +}) => { + const { data } = useTriggersAndActionsUiDeps(); + const [dataViewItems, setDataViewsItems] = useState(); + const [dataViewPopoverOpen, setDataViewPopoverOpen] = useState(false); + + const [selectedDataViewId, setSelectedDataViewId] = useState(initialDataViewId); + const [selectedTitle, setSelectedTitle] = useState(initialDataViewTitle); + + useEffect(() => { + const initDataViews = async () => { + const fetchedDataViewItems = await data.dataViews.getIdsWithTitle(); + setDataViewsItems(fetchedDataViewItems); + }; + initDataViews(); + }, [data.dataViews]); + + const closeDataViewPopover = useCallback(() => setDataViewPopoverOpen(false), []); + + if (!dataViewItems) { + return null; + } + + return ( + { + setDataViewPopoverOpen(true); + }} + isInvalid={!selectedTitle} + /> + } + isOpen={dataViewPopoverOpen} + closePopover={closeDataViewPopover} + ownFocus + anchorPosition="downLeft" + display="block" + > +
+ + + + {i18n.translate('xpack.stackAlerts.components.ui.alertParams.dataViewPopoverTitle', { + defaultMessage: 'Data view', + })} + + + + + + + + { + setSelectedDataViewId(newId); + const newTitle = dataViewItems?.find(({ id }) => id === newId)?.title; + if (newTitle) { + setSelectedTitle(newTitle); + } + + onSelectDataView(newId); + closeDataViewPopover(); + }} + currentDataViewId={selectedDataViewId} + /> + +
+
+ ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts index bceb39ba08cf9ec..da85c878f328184 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts @@ -6,6 +6,7 @@ */ import { COMPARATORS } from '@kbn/triggers-actions-ui-plugin/public'; +import { ErrorKey } from './types'; export const DEFAULT_VALUES = { THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN, @@ -19,3 +20,17 @@ export const DEFAULT_VALUES = { TIME_WINDOW_UNIT: 'm', THRESHOLD: [1000], }; + +export const EXPRESSION_ERRORS = { + index: new Array(), + size: new Array(), + timeField: new Array(), + threshold0: new Array(), + threshold1: new Array(), + esQuery: new Array(), + thresholdComparator: new Array(), + timeWindowSize: new Array(), + searchConfiguration: new Array(), +}; + +export const EXPRESSION_ERROR_KEYS = Object.keys(EXPRESSION_ERRORS) as ErrorKey[]; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx index 10b774648d735ae..afb45f90c6e52f9 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx @@ -83,6 +83,7 @@ export const EsQueryExpression = ({ thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, size: size ?? DEFAULT_VALUES.SIZE, esQuery: esQuery ?? DEFAULT_VALUES.QUERY, + searchType: 'esQuery', }); const setParam = useCallback( diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx index df44a8923183cbf..3b5e978b999c88e 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx @@ -5,29 +5,33 @@ * 2.0. */ -import React from 'react'; +import React, { memo, PropsWithChildren } from 'react'; import { i18n } from '@kbn/i18n'; +import deepEqual from 'fast-deep-equal'; import 'brace/theme/github'; import { EuiSpacer, EuiCallOut } from '@elastic/eui'; import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; -import { EsQueryAlertParams } from '../types'; -import { SearchSourceExpression } from './search_source_expression'; +import { ErrorKey, EsQueryAlertParams } from '../types'; +import { SearchSourceExpression, SearchSourceExpressionProps } from './search_source_expression'; import { EsQueryExpression } from './es_query_expression'; import { isSearchSourceAlert } from '../util'; +import { EXPRESSION_ERROR_KEYS } from '../constants'; -const expressionFieldsWithValidation = [ - 'index', - 'size', - 'timeField', - 'threshold0', - 'threshold1', - 'timeWindowSize', - 'searchType', - 'esQuery', - 'searchConfiguration', -]; +function areSearchSourceExpressionPropsEqual( + prevProps: Readonly>, + nextProps: Readonly> +) { + const areErrorsEqual = deepEqual(prevProps.errors, nextProps.errors); + const areRuleParamsEqual = deepEqual(prevProps.ruleParams, nextProps.ruleParams); + return areErrorsEqual && areRuleParamsEqual; +} + +const SearchSourceExpressionMemoized = memo( + SearchSourceExpression, + areSearchSourceExpressionPropsEqual +); export const EsQueryAlertTypeExpression: React.FunctionComponent< RuleTypeParamsExpressionProps @@ -35,11 +39,11 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent< const { ruleParams, errors } = props; const isSearchSource = isSearchSourceAlert(ruleParams); - const hasExpressionErrors = !!Object.keys(errors).find((errorKey) => { + const hasExpressionErrors = Object.keys(errors).some((errorKey) => { return ( - expressionFieldsWithValidation.includes(errorKey) && + EXPRESSION_ERROR_KEYS.includes(errorKey as ErrorKey) && errors[errorKey].length >= 1 && - ruleParams[errorKey as keyof EsQueryAlertParams] !== undefined + ruleParams[errorKey] !== undefined ); }); @@ -54,14 +58,13 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent< <> {hasExpressionErrors && ( <> - )} {isSearchSource ? ( - + ) : ( )} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx deleted file mode 100644 index 6747c60bb840ccb..000000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { injectI18n } from '@kbn/i18n-react'; -import { css } from '@emotion/react'; - -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { getDisplayValueFromFilter } from '@kbn/data-plugin/public'; -import { Filter } from '@kbn/data-plugin/common'; -import { DataView } from '@kbn/data-views-plugin/public'; -import { FilterItem } from '@kbn/unified-search-plugin/public'; - -const FilterItemComponent = injectI18n(FilterItem); - -interface ReadOnlyFilterItemsProps { - filters: Filter[]; - indexPatterns: DataView[]; -} - -const noOp = () => {}; - -export const ReadOnlyFilterItems = ({ filters, indexPatterns }: ReadOnlyFilterItemsProps) => { - const { uiSettings } = useKibana().services; - - const filterList = filters.map((filter, index) => { - const filterValue = getDisplayValueFromFilter(filter, indexPatterns); - return ( - - - - ); - }); - - return ( - - {filterList} - - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx index 7041bba0fe2ff01..d12833a3f258f4f 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx @@ -10,18 +10,12 @@ import React from 'react'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; -import { DataPublicPluginStart, ISearchStart } from '@kbn/data-plugin/public'; import { EsQueryAlertParams, SearchType } from '../types'; import { SearchSourceExpression } from './search_source_expression'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { act } from 'react-dom/test-utils'; -import { EuiCallOut, EuiLoadingSpinner } from '@elastic/eui'; -import { ReactWrapper } from 'enzyme'; - -const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - search: ISearchStart & { searchSource: { create: jest.MockedFunction } }; -}; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; const dataViewPluginMock = dataViewPluginMocks.createStartContract(); const chartsStartMock = chartPluginMock.createStartContract(); @@ -40,6 +34,18 @@ const defaultSearchSourceExpressionParams: EsQueryAlertParams { if (name === 'filter') { return []; @@ -48,7 +54,33 @@ const searchSourceMock = { }, }; -const setup = async (alertParams: EsQueryAlertParams) => { +const savedQueryMock = { + id: 'test-id', + attributes: { + title: 'test-filter-set', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, +}; + +jest.mock('./search_source_expression_form', () => ({ + SearchSourceExpressionForm: () =>
search source expression form mock
, +})); + +const dataMock = dataPluginMock.createStartContract(); +(dataMock.search.searchSource.create as jest.Mock).mockImplementation(() => + Promise.resolve(searchSourceMock) +); +(dataMock.dataViews.getIdsWithTitle as jest.Mock).mockImplementation(() => Promise.resolve([])); +(dataMock.query.savedQueries.getSavedQuery as jest.Mock).mockImplementation(() => + Promise.resolve(savedQueryMock) +); + +const setup = (alertParams: EsQueryAlertParams) => { const errors = { size: [], timeField: [], @@ -57,67 +89,58 @@ const setup = async (alertParams: EsQueryAlertParams) = }; const wrapper = mountWithIntl( - {}} - setRuleProperty={() => {}} - errors={errors} - unifiedSearch={unifiedSearchMock} - data={dataMock} - dataViews={dataViewPluginMock} - defaultActionGroupId="" - actionGroups={[]} - charts={chartsStartMock} - /> + + {}} + setRuleProperty={() => {}} + errors={errors} + unifiedSearch={unifiedSearchMock} + data={dataMock} + dataViews={dataViewPluginMock} + defaultActionGroupId="" + actionGroups={[]} + charts={chartsStartMock} + /> + ); return wrapper; }; -const rerender = async (wrapper: ReactWrapper) => { - const update = async () => +describe('SearchSourceAlertTypeExpression', () => { + test('should render correctly', async () => { + let wrapper = setup(defaultSearchSourceExpressionParams).children(); + + expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy(); + await act(async () => { await nextTick(); - wrapper.update(); }); - await update(); -}; + wrapper = await wrapper.update(); -describe('SearchSourceAlertTypeExpression', () => { - test('should render loading prompt', async () => { - dataMock.search.searchSource.create.mockImplementation(() => - Promise.resolve(() => searchSourceMock) - ); - - const wrapper = await setup(defaultSearchSourceExpressionParams); - - expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy(); + expect(wrapper.text().includes('search source expression form mock')).toBeTruthy(); }); test('should render error prompt', async () => { - dataMock.search.searchSource.create.mockImplementation(() => - Promise.reject(() => 'test error') + (dataMock.search.searchSource.create as jest.Mock).mockImplementationOnce(() => + Promise.reject(new Error('Cant find searchSource')) ); + let wrapper = setup(defaultSearchSourceExpressionParams).children(); - const wrapper = await setup(defaultSearchSourceExpressionParams); - await rerender(wrapper); - - expect(wrapper.find(EuiCallOut).exists()).toBeTruthy(); - }); - - test('should render SearchSourceAlertTypeExpression with expected components', async () => { - dataMock.search.searchSource.create.mockImplementation(() => - Promise.resolve(() => searchSourceMock) - ); + expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy(); - const wrapper = await setup(defaultSearchSourceExpressionParams); - await rerender(wrapper); + await act(async () => { + await nextTick(); + }); + wrapper = await wrapper.update(); - expect(wrapper.find('[data-test-subj="sizeValueExpression"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="thresholdExpression"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="forLastExpression"]').exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeTruthy(); }); }); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx index 1d54609223aaff4..26b2d074bfd8b54 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx @@ -5,36 +5,27 @@ * 2.0. */ -import React, { Fragment, useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import './search_source_expression.scss'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiSpacer, - EuiTitle, - EuiExpression, - EuiLoadingSpinner, - EuiEmptyPrompt, - EuiCallOut, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { Filter, ISearchSource } from '@kbn/data-plugin/common'; -import { - ForLastExpression, - RuleTypeParamsExpressionProps, - ThresholdExpression, - ValueExpression, -} from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiSpacer, EuiLoadingSpinner, EuiEmptyPrompt, EuiCallOut } from '@elastic/eui'; +import { ISearchSource } from '@kbn/data-plugin/common'; +import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { SavedQuery } from '@kbn/data-plugin/public'; import { EsQueryAlertParams, SearchType } from '../types'; +import { useTriggersAndActionsUiDeps } from '../util'; +import { SearchSourceExpressionForm } from './search_source_expression_form'; import { DEFAULT_VALUES } from '../constants'; -import { ReadOnlyFilterItems } from './read_only_filter_items'; + +export type SearchSourceExpressionProps = RuleTypeParamsExpressionProps< + EsQueryAlertParams +>; export const SearchSourceExpression = ({ ruleParams, + errors, setRuleParams, setRuleProperty, - data, - errors, -}: RuleTypeParamsExpressionProps>) => { +}: SearchSourceExpressionProps) => { const { searchConfiguration, thresholdComparator, @@ -43,48 +34,43 @@ export const SearchSourceExpression = ({ timeWindowUnit, size, } = ruleParams; - const [usedSearchSource, setUsedSearchSource] = useState(); - const [paramsError, setParamsError] = useState(); + const { data } = useTriggersAndActionsUiDeps(); - const [currentAlertParams, setCurrentAlertParams] = useState< - EsQueryAlertParams - >({ - searchConfiguration, - searchType: SearchType.searchSource, - timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE, - timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT, - threshold: threshold ?? DEFAULT_VALUES.THRESHOLD, - thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, - size: size ?? DEFAULT_VALUES.SIZE, - }); + const [searchSource, setSearchSource] = useState(); + const [savedQuery, setSavedQuery] = useState(); + const [paramsError, setParamsError] = useState(); const setParam = useCallback( - (paramField: string, paramValue: unknown) => { - setCurrentAlertParams((currentParams) => ({ - ...currentParams, - [paramField]: paramValue, - })); - setRuleParams(paramField, paramValue); - }, + (paramField: string, paramValue: unknown) => setRuleParams(paramField, paramValue), [setRuleParams] ); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => setRuleProperty('params', currentAlertParams), []); + useEffect(() => { + setRuleProperty('params', { + searchConfiguration, + searchType: SearchType.searchSource, + timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT, + threshold: threshold ?? DEFAULT_VALUES.THRESHOLD, + thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, + size: size ?? DEFAULT_VALUES.SIZE, + }); + + const initSearchSource = () => + data.search.searchSource + .create(searchConfiguration) + .then((fetchedSearchSource) => setSearchSource(fetchedSearchSource)) + .catch(setParamsError); + + initSearchSource(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data.search.searchSource, data.dataViews]); useEffect(() => { - async function initSearchSource() { - try { - const loadedSearchSource = await data.search.searchSource.create(searchConfiguration); - setUsedSearchSource(loadedSearchSource); - } catch (error) { - setParamsError(error); - } - } - if (searchConfiguration) { - initSearchSource(); + if (ruleParams.savedQueryId) { + data.query.savedQueries.getSavedQuery(ruleParams.savedQueryId).then(setSavedQuery); } - }, [data.search.searchSource, searchConfiguration]); + }, [data.query.savedQueries, ruleParams.savedQueryId]); if (paramsError) { return ( @@ -97,124 +83,17 @@ export const SearchSourceExpression = ({ ); } - if (!usedSearchSource) { + if (!searchSource) { return } />; } - const dataView = usedSearchSource.getField('index')!; - const query = usedSearchSource.getField('query')!; - const filters = (usedSearchSource.getField('filter') as Filter[]).filter( - ({ meta }) => !meta.disabled - ); - const dataViews = [dataView]; return ( - - -
- -
-
- - - } - iconType="iInCircle" - /> - - - {query.query !== '' && ( - - )} - {filters.length > 0 && ( - } - display="columns" - /> - )} - - - -
- -
-
- - - setParam('threshold', selectedThresholds) - } - onChangeSelectedThresholdComparator={(selectedThresholdComparator) => - setParam('thresholdComparator', selectedThresholdComparator) - } - /> - - setParam('timeWindowSize', selectedWindowSize) - } - onChangeWindowUnit={(selectedWindowUnit: string) => - setParam('timeWindowUnit', selectedWindowUnit) - } - /> - - -
- -
-
- - { - setParam('size', updatedValue); - }} - /> - -
+ ); }; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx new file mode 100644 index 000000000000000..afd6a156187ee63 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment, useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import deepEqual from 'fast-deep-equal'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Filter, DataView, Query, ISearchSource } from '@kbn/data-plugin/common'; +import { + ForLastExpression, + IErrorObject, + ThresholdExpression, + ValueExpression, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { SearchBar } from '@kbn/unified-search-plugin/public'; +import { mapAndFlattenFilters, SavedQuery, TimeHistory } from '@kbn/data-plugin/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { EsQueryAlertParams, SearchType } from '../types'; +import { DEFAULT_VALUES } from '../constants'; +import { DataViewSelectPopover } from '../../components/data_view_select_popover'; +import { useTriggersAndActionsUiDeps } from '../util'; + +interface LocalState { + index: DataView; + filter: Filter[]; + query: Query; + threshold: number[]; + timeWindowSize: number; + size: number; +} + +interface LocalStateAction { + type: SearchSourceParamsAction['type'] | ('threshold' | 'timeWindowSize' | 'size'); + payload: SearchSourceParamsAction['payload'] | (number[] | number); +} + +type LocalStateReducer = (prevState: LocalState, action: LocalStateAction) => LocalState; + +interface SearchSourceParamsAction { + type: 'index' | 'filter' | 'query'; + payload: DataView | Filter[] | Query; +} + +interface SearchSourceExpressionFormProps { + searchSource: ISearchSource; + ruleParams: EsQueryAlertParams; + errors: IErrorObject; + initialSavedQuery?: SavedQuery; + setParam: (paramField: string, paramValue: unknown) => void; +} + +const isSearchSourceParam = (action: LocalStateAction): action is SearchSourceParamsAction => { + return action.type === 'filter' || action.type === 'index' || action.type === 'query'; +}; + +export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProps) => { + const { data } = useTriggersAndActionsUiDeps(); + const { searchSource, ruleParams, errors, initialSavedQuery, setParam } = props; + const { thresholdComparator, timeWindowUnit } = ruleParams; + const [savedQuery, setSavedQuery] = useState(); + + const timeHistory = useMemo(() => new TimeHistory(new Storage(localStorage)), []); + + useEffect(() => setSavedQuery(initialSavedQuery), [initialSavedQuery]); + + const [{ index: dataView, query, filter: filters, threshold, timeWindowSize, size }, dispatch] = + useReducer( + (currentState, action) => { + if (isSearchSourceParam(action)) { + searchSource.setParent(undefined).setField(action.type, action.payload); + setParam('searchConfiguration', searchSource.getSerializedFields()); + } else { + setParam(action.type, action.payload); + } + return { ...currentState, [action.type]: action.payload }; + }, + { + index: searchSource.getField('index')!, + query: searchSource.getField('query')!, + filter: mapAndFlattenFilters(searchSource.getField('filter') as Filter[]), + threshold: ruleParams.threshold, + timeWindowSize: ruleParams.timeWindowSize, + size: ruleParams.size, + } + ); + const dataViews = useMemo(() => [dataView], [dataView]); + + const onSelectDataView = useCallback( + (newDataViewId) => + data.dataViews + .get(newDataViewId) + .then((newDataView) => dispatch({ type: 'index', payload: newDataView })), + [data.dataViews] + ); + + const onUpdateFilters = useCallback((newFilters) => { + dispatch({ type: 'filter', payload: mapAndFlattenFilters(newFilters) }); + }, []); + + const onChangeQuery = useCallback( + ({ query: newQuery }: { query?: Query }) => { + if (!deepEqual(newQuery, query)) { + dispatch({ type: 'query', payload: newQuery || { ...query, query: '' } }); + } + }, + [query] + ); + + // needs to change language mode only + const onQueryBarSubmit = ({ query: newQuery }: { query?: Query }) => { + if (newQuery?.language !== query.language) { + dispatch({ type: 'query', payload: { ...query, language: newQuery?.language } as Query }); + } + }; + + // Saved query + const onSavedQuery = useCallback((newSavedQuery: SavedQuery) => { + setSavedQuery(newSavedQuery); + const newFilters = newSavedQuery.attributes.filters; + if (newFilters) { + dispatch({ type: 'filter', payload: newFilters }); + } + }, []); + + const onClearSavedQuery = () => { + setSavedQuery(undefined); + dispatch({ type: 'query', payload: { ...query, query: '' } }); + }; + + // window size + const onChangeWindowUnit = useCallback( + (selectedWindowUnit: string) => setParam('timeWindowUnit', selectedWindowUnit), + [setParam] + ); + + const onChangeWindowSize = useCallback( + (selectedWindowSize?: number) => + selectedWindowSize && dispatch({ type: 'timeWindowSize', payload: selectedWindowSize }), + [] + ); + + // threshold + const onChangeSelectedThresholdComparator = useCallback( + (selectedThresholdComparator?: string) => + setParam('thresholdComparator', selectedThresholdComparator), + [setParam] + ); + + const onChangeSelectedThreshold = useCallback( + (selectedThresholds?: number[]) => + selectedThresholds && dispatch({ type: 'threshold', payload: selectedThresholds }), + [] + ); + + const onChangeSizeValue = useCallback( + (updatedValue: number) => dispatch({ type: 'size', payload: updatedValue }), + [] + ); + + return ( + + +
+ +
+
+ + + + + + + + + + + +
+ +
+
+ + + + + +
+ +
+
+ + + +
+ ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts index bccf6ed4ced4391..703570ad5faae84 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts @@ -7,6 +7,9 @@ import { RuleTypeParams } from '@kbn/alerting-plugin/common'; import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { EXPRESSION_ERRORS } from './constants'; export interface Comparator { text: string; @@ -19,7 +22,7 @@ export enum SearchType { searchSource = 'searchSource', } -export interface CommonAlertParams extends RuleTypeParams { +export interface CommonAlertParams extends RuleTypeParams { size: number; thresholdComparator?: string; threshold: number[]; @@ -28,8 +31,8 @@ export interface CommonAlertParams extends RuleTypeParams } export type EsQueryAlertParams = T extends SearchType.searchSource - ? CommonAlertParams & OnlySearchSourceAlertParams - : CommonAlertParams & OnlyEsQueryAlertParams; + ? CommonAlertParams & OnlySearchSourceAlertParams + : CommonAlertParams & OnlyEsQueryAlertParams; export interface OnlyEsQueryAlertParams { esQuery: string; @@ -39,4 +42,15 @@ export interface OnlyEsQueryAlertParams { export interface OnlySearchSourceAlertParams { searchType: 'searchSource'; searchConfiguration: SerializedSearchSourceFields; + savedQueryId?: string; +} + +export type DataViewOption = EuiComboBoxOptionOption; + +export type ExpressionErrors = typeof EXPRESSION_ERRORS; + +export type ErrorKey = keyof ExpressionErrors & unknown; + +export interface TriggersAndActionsUiDeps { + data: DataPublicPluginStart; } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts index 5b70da7cb3e80f8..1f57a133fa65a11 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts @@ -5,10 +5,13 @@ * 2.0. */ -import { EsQueryAlertParams, SearchType } from './types'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { EsQueryAlertParams, SearchType, TriggersAndActionsUiDeps } from './types'; export const isSearchSourceAlert = ( ruleParams: EsQueryAlertParams ): ruleParams is EsQueryAlertParams => { return ruleParams.searchType === 'searchSource'; }; + +export const useTriggersAndActionsUiDeps = () => useKibana().services; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts index 914dd6a4f5f9f4c..8a1135e75492f33 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts @@ -5,25 +5,17 @@ * 2.0. */ +import { defaultsDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ValidationResult, builtInComparators } from '@kbn/triggers-actions-ui-plugin/public'; -import { EsQueryAlertParams } from './types'; +import { EsQueryAlertParams, ExpressionErrors } from './types'; import { isSearchSourceAlert } from './util'; +import { EXPRESSION_ERRORS } from './constants'; export const validateExpression = (alertParams: EsQueryAlertParams): ValidationResult => { const { size, threshold, timeWindowSize, thresholdComparator } = alertParams; const validationResult = { errors: {} }; - const errors = { - index: new Array(), - timeField: new Array(), - esQuery: new Array(), - size: new Array(), - threshold0: new Array(), - threshold1: new Array(), - thresholdComparator: new Array(), - timeWindowSize: new Array(), - searchConfiguration: new Array(), - }; + const errors: ExpressionErrors = defaultsDeep({}, EXPRESSION_ERRORS); validationResult.errors = errors; if (!threshold || threshold.length === 0 || threshold[0] === undefined) { errors.threshold0.push( diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap index 65dff2bd3a6c6d6..fe53610caa316e4 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap @@ -30,6 +30,7 @@ exports[`should render BoundaryIndexExpression 1`] = ` "ensureDefaultIndexPattern": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "getIdsWithTitle": [MockFunction], "make": [Function], } } @@ -106,6 +107,7 @@ exports[`should render EntityIndexExpression 1`] = ` "ensureDefaultIndexPattern": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "getIdsWithTitle": [MockFunction], "make": [Function], } } @@ -188,6 +190,7 @@ exports[`should render EntityIndexExpression w/ invalid flag if invalid 1`] = ` "ensureDefaultIndexPattern": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "getIdsWithTitle": [MockFunction], "make": [Function], } } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts index 468729fb2120d23..884bf606d2f90cb 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts @@ -20,6 +20,7 @@ describe('ActionContext', () => { timeWindowUnit: 'm', thresholdComparator: '>', threshold: [4], + searchType: 'esQuery', }) as OnlyEsQueryAlertParams; const base: EsQueryAlertActionContext = { date: '2020-01-01T00:00:00.000Z', @@ -50,6 +51,7 @@ describe('ActionContext', () => { timeWindowUnit: 'm', thresholdComparator: 'between', threshold: [4, 5], + searchType: 'esQuery', }) as OnlyEsQueryAlertParams; const base: EsQueryAlertActionContext = { date: '2020-01-01T00:00:00.000Z', diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts index 3fce895a2bfd1f1..3304ca5e902f738 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts @@ -110,6 +110,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.LT, threshold: [0], + searchType: 'esQuery', }; expect(alertType.validate?.params?.validate(params)).toBeTruthy(); @@ -128,6 +129,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.BETWEEN, threshold: [0], + searchType: 'esQuery', }; expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot( @@ -145,6 +147,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.BETWEEN, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -174,6 +177,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -219,6 +223,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -267,6 +272,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -309,6 +315,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -380,6 +387,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -425,6 +433,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts index 5b41d7c55fe0a43..dfab69f445629e3 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts @@ -7,10 +7,12 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, Logger } from '@kbn/core/server'; +import { extractReferences, injectReferences } from '@kbn/data-plugin/common'; import { RuleType } from '../../types'; import { ActionContext } from './action_context'; import { EsQueryAlertParams, + EsQueryAlertParamsExtractedParams, EsQueryAlertParamsSchema, EsQueryAlertState, } from './alert_type_params'; @@ -18,13 +20,14 @@ import { STACK_ALERTS_FEATURE_ID } from '../../../common'; import { ExecutorOptions } from './types'; import { ActionGroupId, ES_QUERY_ID } from './constants'; import { executor } from './executor'; +import { isEsQueryAlert } from './util'; export function getAlertType( logger: Logger, core: CoreSetup ): RuleType< EsQueryAlertParams, - never, // Only use if defining useSavedObjectReferences hook + EsQueryAlertParamsExtractedParams, EsQueryAlertState, {}, ActionContext, @@ -159,6 +162,25 @@ export function getAlertType( { name: 'index', description: actionVariableContextIndexLabel }, ], }, + useSavedObjectReferences: { + extractReferences: (params) => { + if (isEsQueryAlert(params.searchType)) { + return { params: params as EsQueryAlertParamsExtractedParams, references: [] }; + } + const [searchConfiguration, references] = extractReferences(params.searchConfiguration); + const newParams = { ...params, searchConfiguration } as EsQueryAlertParamsExtractedParams; + return { params: newParams, references }; + }, + injectReferences: (params, references) => { + if (isEsQueryAlert(params.searchType)) { + return params; + } + return { + ...params, + searchConfiguration: injectReferences(params.searchConfiguration, references), + }; + }, + }, minimumLicenseRequired: 'basic', isExportable: true, executor: async (options: ExecutorOptions) => { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts index d6ba0468b7cbfb3..a1155fedb7a0294 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts @@ -23,6 +23,7 @@ const DefaultParams: Writable> = { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; describe('alertType Params validate()', () => { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts index f205fbd0327ce19..d32fce9debbc2e2 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import { validateTimeWindowUnits } from '@kbn/triggers-actions-ui-plugin/server'; import { RuleTypeState } from '@kbn/alerting-plugin/server'; +import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { Comparator } from '../../../common/comparator_types'; import { ComparatorFnNames } from '../lib'; import { getComparatorSchemaType } from '../lib/comparator'; @@ -21,13 +22,21 @@ export interface EsQueryAlertState extends RuleTypeState { latestTimestamp: string | undefined; } +export type EsQueryAlertParamsExtractedParams = Omit & { + searchConfiguration: SerializedSearchSourceFields & { + indexRefName: string; + }; +}; + const EsQueryAlertParamsSchemaProperties = { size: schema.number({ min: 0, max: ES_QUERY_MAX_HITS_PER_EXECUTION }), timeWindowSize: schema.number({ min: 1 }), timeWindowUnit: schema.string({ validate: validateTimeWindowUnits }), threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }), thresholdComparator: getComparatorSchemaType(validateComparator), - searchType: schema.nullable(schema.literal('searchSource')), + searchType: schema.oneOf([schema.literal('searchSource'), schema.literal('esQuery')], { + defaultValue: 'esQuery', + }), // searchSource alert param only searchConfiguration: schema.conditional( schema.siblingRef('searchType'), @@ -38,21 +47,21 @@ const EsQueryAlertParamsSchemaProperties = { // esQuery alert params only esQuery: schema.conditional( schema.siblingRef('searchType'), - schema.literal('searchSource'), - schema.never(), - schema.string({ minLength: 1 }) + schema.literal('esQuery'), + schema.string({ minLength: 1 }), + schema.never() ), index: schema.conditional( schema.siblingRef('searchType'), - schema.literal('searchSource'), - schema.never(), - schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }) + schema.literal('esQuery'), + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + schema.never() ), timeField: schema.conditional( schema.siblingRef('searchType'), - schema.literal('searchSource'), - schema.never(), - schema.string({ minLength: 1 }) + schema.literal('esQuery'), + schema.string({ minLength: 1 }), + schema.never() ), }; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts index 670f76f5e19dea5..7b4cc7521654bb0 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts @@ -18,6 +18,7 @@ describe('es_query executor', () => { esQuery: '{ "query": "test-query" }', index: ['test-index'], timeField: '', + searchType: 'esQuery', }; describe('tryToParseAsDate', () => { it.each<[string | number]>([['2019-01-01T00:00:00.000Z'], [1546300800000]])( diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts index 44708a1df90fd80..6e47c5f471d884b 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts @@ -16,13 +16,14 @@ import { fetchEsQuery } from './lib/fetch_es_query'; import { EsQueryAlertParams } from './alert_type_params'; import { fetchSearchSourceQuery } from './lib/fetch_search_source_query'; import { Comparator } from '../../../common/comparator_types'; +import { isEsQueryAlert } from './util'; export async function executor( logger: Logger, core: CoreSetup, options: ExecutorOptions ) { - const esQueryAlert = isEsQueryAlert(options); + const esQueryAlert = isEsQueryAlert(options.params.searchType); const { alertId, name, services, params, state } = options; const { alertFactory, scopedClusterClient, searchSourceClient } = services; const currentTimestamp = new Date().toISOString(); @@ -162,10 +163,6 @@ export function tryToParseAsDate(sortValue?: string | number | null): undefined } } -export function isEsQueryAlert(options: ExecutorOptions) { - return options.params.searchType !== 'searchSource'; -} - export function getChecksum(params: EsQueryAlertParams) { return sha256.create().update(JSON.stringify(params)); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts index 12b2ee02af1718e..8595870a849405a 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts @@ -10,7 +10,9 @@ import { ActionContext } from './action_context'; import { EsQueryAlertParams, EsQueryAlertState } from './alert_type_params'; import { ActionGroupId } from './constants'; -export type OnlyEsQueryAlertParams = Omit; +export type OnlyEsQueryAlertParams = Omit & { + searchType: 'esQuery'; +}; export type OnlySearchSourceAlertParams = Omit< EsQueryAlertParams, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts new file mode 100644 index 000000000000000..b58a362cd27e9a4 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EsQueryAlertParams } from './alert_type_params'; + +export function isEsQueryAlert(searchType: EsQueryAlertParams['searchType']) { + return searchType !== 'searchSource'; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx index 1bca80a08c936a2..6da565b13d91e17 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx @@ -724,10 +724,10 @@ export const RuleForm = ({ name="interval" data-test-subj="intervalInput" onChange={(e) => { - const interval = - e.target.value !== '' ? parseInt(e.target.value, 10) : undefined; + const value = e.target.value; + const interval = value !== '' ? parseInt(value, 10) : undefined; setRuleInterval(interval); - setScheduleProperty('interval', `${e.target.value}${ruleIntervalUnit}`); + setScheduleProperty('interval', `${value}${ruleIntervalUnit}`); }} /> diff --git a/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts b/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts index bae045fc93838fb..2cb77ac262ca679 100644 --- a/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts +++ b/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts @@ -30,6 +30,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const supertest = getService('supertest'); const queryBar = getService('queryBar'); const security = getService('security'); + const filterBar = getService('filterBar'); const SOURCE_DATA_INDEX = 'search-source-alert'; const OUTPUT_DATA_INDEX = 'search-source-alert-output'; @@ -47,17 +48,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { mappings: { properties: { '@timestamp': { type: 'date' }, - message: { type: 'text' }, + message: { type: 'keyword' }, }, }, }, }); const generateNewDocs = async (docsNumber: number) => { - const mockMessages = new Array(docsNumber).map((current) => `msg-${current}`); + const mockMessages = Array.from({ length: docsNumber }, (_, i) => `msg-${i}`); const dateNow = new Date().toISOString(); - for (const message of mockMessages) { - await es.transport.request({ + for await (const message of mockMessages) { + es.transport.request({ path: `/${SOURCE_DATA_INDEX}/_doc`, method: 'POST', body: { @@ -212,7 +213,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await navigateToDiscover(link); }; - const openAlertRule = async () => { + const openAlertRuleInManagement = async () => { await PageObjects.common.navigateToApp('management'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -229,7 +230,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await security.testUser.setRoles(['discover_alert']); - log.debug('create source index'); + log.debug('create source indices'); await createSourceIndex(); log.debug('generate documents'); @@ -250,8 +251,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - // delete only remaining output index - await es.transport.request({ + es.transport.request({ path: `/${OUTPUT_DATA_INDEX}`, method: 'DELETE', }); @@ -272,7 +272,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await defineSearchSourceAlert(RULE_NAME); await PageObjects.header.waitUntilLoadingHasFinished(); - await openAlertRule(); + await openAlertRuleInManagement(); await testSubjects.click('ruleDetails-viewInApp'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -298,10 +298,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should display warning about updated alert rule', async () => { - await openAlertRule(); + await openAlertRuleInManagement(); // change rule configuration await testSubjects.click('openEditRuleFlyoutButton'); + await queryBar.setQuery('message:msg-1'); + await filterBar.addFilter('message.keyword', 'is', 'msg-1'); + await testSubjects.click('thresholdPopover'); await testSubjects.setValue('alertThresholdInput', '1'); await testSubjects.click('saveEditedRuleButton'); @@ -311,7 +314,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await navigateToResults(); const { message, title } = await getLastToast(); - expect(await dataGrid.getDocCount()).to.be(5); + const queryString = await queryBar.getQueryString(); + const hasFilter = await filterBar.hasFilter('message.keyword', 'msg-1'); + + expect(queryString).to.be.equal('message:msg-1'); + expect(hasFilter).to.be.equal(true); + + expect(await dataGrid.getDocCount()).to.be(1); expect(title).to.be.equal('Alert rule has changed'); expect(message).to.be.equal( 'The displayed documents might not match the documents that triggered the alert because the rule configuration changed.'