[Discover] Enable esQuery alert for adhoc data views (#140885)

## Summary

Closes #142514 #142389

This PR does the following: 
- Enables to create `esQuery` (in KQL or Lucene mode) using adhoc data
views from discover and management pages
- Adds `explore matching indices` button to data view picker in alert
flyout
- Adding adhoc data views from alert flyout should propage them to a
main discover picker


### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))

Co-authored-by: Davis McPhee <davis.mcphee@elastic.co>
This commit is contained in:
Dmitry Tomashevich 2022-11-09 18:55:34 +03:00 committed by GitHub
parent 3d7b01e28b
commit a9162f7481
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 812 additions and 401 deletions

View file

@ -983,7 +983,11 @@ describe('SearchSource', () => {
},
];
const indexPattern123 = { id: '123', isPersisted: () => true } as DataView;
const indexPattern123 = {
id: '123',
isPersisted: jest.fn(() => true),
toSpec: jest.fn(),
} as unknown as DataView;
test('should return serialized fields', () => {
searchSource.setField('index', indexPattern123);
@ -991,6 +995,7 @@ describe('SearchSource', () => {
return filter;
});
const serializedFields = searchSource.getSerializedFields();
expect(indexPattern123.toSpec).toHaveBeenCalledTimes(0);
expect(serializedFields).toMatchSnapshot();
});
@ -1000,11 +1005,19 @@ describe('SearchSource', () => {
const childSearchSource = searchSource.createChild();
childSearchSource.setField('timeout', '100');
const serializedFields = childSearchSource.getSerializedFields(true);
expect(indexPattern123.toSpec).toHaveBeenCalledTimes(0);
expect(serializedFields).toMatchObject({
timeout: '100',
parent: { index: '123', from: 123 },
});
});
test('should use spec', () => {
indexPattern123.isPersisted = jest.fn(() => false);
searchSource.setField('index', indexPattern123);
searchSource.getSerializedFields(true, false);
expect(indexPattern123.toSpec).toHaveBeenCalledWith(false);
});
});
describe('fetch$', () => {

View file

@ -923,7 +923,7 @@ export class SearchSource {
/**
* serializes search source fields (which can later be passed to {@link ISearchStartSearchSource})
*/
public getSerializedFields(recurse = false): SerializedSearchSourceFields {
public getSerializedFields(recurse = false, includeFields = true): SerializedSearchSourceFields {
const {
filter: originalFilters,
aggs: searchSourceAggs,
@ -938,7 +938,9 @@ export class SearchSource {
...searchSourceFields,
};
if (index) {
serializedSearchSourceFields.index = index.isPersisted() ? index.id : index.toSpec();
serializedSearchSourceFields.index = index.isPersisted()
? index.id
: index.toSpec(includeFields);
}
if (sort) {
serializedSearchSourceFields.sort = !Array.isArray(sort) ? [sort] : sort;

View file

@ -188,6 +188,8 @@ async function mountComponent(
persistDataView: jest.fn(),
updateAdHocDataViewId: jest.fn(),
adHocDataViewList: [],
savedDataViewList: [],
updateDataViewList: jest.fn(),
};
const component = mountWithIntl(

View file

@ -72,6 +72,8 @@ export function DiscoverLayout({
persistDataView,
updateAdHocDataViewId,
adHocDataViewList,
savedDataViewList,
updateDataViewList,
}: DiscoverLayoutProps) {
const {
trackUiMetric,
@ -233,6 +235,8 @@ export function DiscoverLayout({
persistDataView={persistDataView}
updateAdHocDataViewId={updateAdHocDataViewId}
adHocDataViewList={adHocDataViewList}
savedDataViewList={savedDataViewList}
updateDataViewList={updateDataViewList}
/>
<EuiPageBody className="dscPageBody" aria-describedby="savedSearchTitle">
<SavedSearchURLConflictCallout

View file

@ -8,7 +8,7 @@
import type { Query, TimeRange, AggregateQuery } from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/public';
import { DataViewListItem, ISearchSource } from '@kbn/data-plugin/public';
import type { DataViewListItem, ISearchSource } from '@kbn/data-plugin/public';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import { DataTableRecord } from '../../../../types';
@ -35,6 +35,8 @@ export interface DiscoverLayoutProps {
state: AppState;
stateContainer: GetStateReturn;
persistDataView: (dataView: DataView) => Promise<DataView | undefined>;
updateDataViewList: (dataViews: DataView[]) => Promise<void>;
updateAdHocDataViewId: (dataView: DataView) => Promise<DataView>;
adHocDataViewList: DataView[];
savedDataViewList: DataViewListItem[];
}

View file

@ -47,6 +47,8 @@ function getProps(savePermissions = true): DiscoverTopNavProps {
persistDataView: jest.fn(),
updateAdHocDataViewId: jest.fn(),
adHocDataViewList: [],
savedDataViewList: [],
updateDataViewList: jest.fn(),
};
}

View file

@ -8,7 +8,7 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useHistory } from 'react-router-dom';
import type { Query, TimeRange, AggregateQuery } from '@kbn/es-query';
import { DataViewType, type DataView } from '@kbn/data-views-plugin/public';
import { DataViewListItem, DataViewType, type DataView } from '@kbn/data-views-plugin/public';
import type { DataViewPickerProps } from '@kbn/unified-search-plugin/public';
import { ENABLE_SQL } from '../../../../../common';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
@ -38,6 +38,8 @@ export type DiscoverTopNavProps = Pick<
persistDataView: (dataView: DataView) => Promise<DataView | undefined>;
updateAdHocDataViewId: (dataView: DataView) => Promise<DataView>;
adHocDataViewList: DataView[];
savedDataViewList: DataViewListItem[];
updateDataViewList: (DataViewEditorStart: DataView[]) => Promise<void>;
};
export const DiscoverTopNav = ({
@ -58,6 +60,8 @@ export const DiscoverTopNav = ({
persistDataView,
updateAdHocDataViewId,
adHocDataViewList,
savedDataViewList,
updateDataViewList,
}: DiscoverTopNavProps) => {
const history = useHistory();
@ -161,6 +165,8 @@ export const DiscoverTopNav = ({
searchSource,
onOpenSavedSearch,
isPlainRecord,
adHocDataViews: adHocDataViewList,
updateDataViewList,
persistDataView,
updateAdHocDataViewId,
}),
@ -174,8 +180,10 @@ export const DiscoverTopNav = ({
searchSource,
onOpenSavedSearch,
isPlainRecord,
adHocDataViewList,
persistDataView,
updateAdHocDataViewId,
updateDataViewList,
]
);
@ -213,6 +221,7 @@ export const DiscoverTopNav = ({
onChangeDataView,
textBasedLanguages: supportedTextBasedLanguages as DataViewPickerProps['textBasedLanguages'],
adHocDataViews: adHocDataViewList,
savedDataViewList,
};
const onTextBasedSavedAndExit = useCallback(

View file

@ -38,6 +38,8 @@ test('getTopNavLinks result', () => {
onOpenSavedSearch: () => {},
isPlainRecord: false,
persistDataView: jest.fn(),
updateDataViewList: jest.fn(),
adHocDataViews: [],
updateAdHocDataViewId: jest.fn(),
});
expect(topNavLinks).toMatchInlineSnapshot(`
@ -102,6 +104,8 @@ test('getTopNavLinks result for sql mode', () => {
onOpenSavedSearch: () => {},
isPlainRecord: true,
persistDataView: jest.fn(),
updateDataViewList: jest.fn(),
adHocDataViews: [],
updateAdHocDataViewId: jest.fn(),
});
expect(topNavLinks).toMatchInlineSnapshot(`

View file

@ -34,6 +34,8 @@ export const getTopNavLinks = ({
onOpenSavedSearch,
isPlainRecord,
persistDataView,
adHocDataViews,
updateDataViewList,
updateAdHocDataViewId,
}: {
dataView: DataView;
@ -45,6 +47,8 @@ export const getTopNavLinks = ({
searchSource: ISearchSource;
onOpenSavedSearch: (id: string) => void;
isPlainRecord: boolean;
adHocDataViews: DataView[];
updateDataViewList: (dataView: DataView[]) => Promise<void>;
persistDataView: (dataView: DataView) => Promise<DataView | undefined>;
updateAdHocDataViewId: (dataView: DataView) => Promise<DataView>;
}): TopNavMenuData[] => {
@ -75,16 +79,15 @@ export const getTopNavLinks = ({
defaultMessage: 'Alerts',
}),
run: async (anchorElement: HTMLElement) => {
const updatedDataView = await persistDataView(dataView);
if (updatedDataView) {
openAlertsPopover({
I18nContext: services.core.i18n.Context,
anchorElement,
searchSource: savedSearch.searchSource,
services,
adHocDataViews,
updateDataViewList,
savedQueryId: state.appStateContainer.getState().savedQuery,
});
}
},
testId: 'discoverAlertsButton',
};

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React from 'react';
import React, { ReactNode } from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks';
@ -16,6 +16,8 @@ import { discoverServiceMock } from '../../../../__mocks__/services';
import { dataViewWithTimefieldMock } from '../../../../__mocks__/data_view_with_timefield';
import { dataViewMock } from '../../../../__mocks__/data_view';
const Context = ({ children }: { children: ReactNode }) => <>{children}</>;
const mount = (dataView = dataViewMock) =>
mountWithIntl(
<KibanaContextProvider services={discoverServiceMock}>
@ -23,7 +25,11 @@ const mount = (dataView = dataViewMock) =>
searchSource={createSearchSourceMock({ index: dataView })}
anchorElement={document.createElement('div')}
savedQueryId={undefined}
onClose={() => {}}
adHocDataViews={[]}
services={discoverServiceMock}
updateDataViewList={jest.fn()}
onClose={jest.fn()}
I18nContext={Context}
/>
</KibanaContextProvider>
);

View file

@ -11,11 +11,10 @@ import ReactDOM from 'react-dom';
import { I18nStart } from '@kbn/core/public';
import { EuiWrappingPopover, EuiContextMenu } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { ISearchSource } from '@kbn/data-plugin/common';
import type { DataView, ISearchSource } from '@kbn/data-plugin/common';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { DiscoverServices } from '../../../../build_services';
import { updateSearchSource } from '../../utils/update_search_source';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
const container = document.createElement('div');
let isOpen = false;
@ -27,16 +26,27 @@ interface AlertsPopoverProps {
anchorElement: HTMLElement;
searchSource: ISearchSource;
savedQueryId?: string;
adHocDataViews: DataView[];
I18nContext: I18nStart['Context'];
services: DiscoverServices;
updateDataViewList: (dataViews: DataView[]) => Promise<void>;
}
interface EsQueryAlertMetaData {
isManagementPage?: boolean;
adHocDataViewList: DataView[];
}
export function AlertsPopover({
searchSource,
anchorElement,
savedQueryId,
adHocDataViews,
services,
onClose: originalOnClose,
updateDataViewList,
}: AlertsPopoverProps) {
const dataView = searchSource.getField('index')!;
const services = useDiscoverServices();
const { triggersActionsUi } = services;
const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false);
const onClose = useCallback(() => {
@ -63,20 +73,45 @@ export function AlertsPopover({
};
}, [savedQueryId, searchSource, services]);
const discoverMetadata: EsQueryAlertMetaData = useMemo(
() => ({
isManagementPage: false,
adHocDataViewList: adHocDataViews,
}),
[adHocDataViews]
);
const SearchThresholdAlertFlyout = useMemo(() => {
if (!alertFlyoutVisible) {
return;
}
const onFinishFlyoutInteraction = (metadata: EsQueryAlertMetaData) => {
updateDataViewList(metadata.adHocDataViewList);
};
return triggersActionsUi?.getAddAlertFlyout({
metadata: discoverMetadata,
consumer: 'discover',
onClose,
onClose: (_, metadata) => {
onFinishFlyoutInteraction(metadata as EsQueryAlertMetaData);
onClose();
},
onSave: async (metadata) => {
onFinishFlyoutInteraction(metadata as EsQueryAlertMetaData);
},
canChangeTrigger: false,
ruleTypeId: ALERT_TYPE_ID,
initialValues: {
params: getParams(),
},
initialValues: { params: getParams() },
});
}, [getParams, onClose, triggersActionsUi, alertFlyoutVisible]);
}, [
alertFlyoutVisible,
triggersActionsUi,
discoverMetadata,
getParams,
updateDataViewList,
onClose,
]);
const hasTimeFieldName = dataView.timeFieldName;
const panels = [
@ -145,13 +180,17 @@ export function openAlertsPopover({
anchorElement,
searchSource,
services,
adHocDataViews,
savedQueryId,
updateDataViewList,
}: {
I18nContext: I18nStart['Context'];
anchorElement: HTMLElement;
searchSource: ISearchSource;
services: DiscoverServices;
adHocDataViews: DataView[];
savedQueryId?: string;
updateDataViewList: (dataViews: DataView[]) => Promise<void>;
}) {
if (isOpen) {
closeAlertsPopover();
@ -169,6 +208,10 @@ export function openAlertsPopover({
anchorElement={anchorElement}
searchSource={searchSource}
savedQueryId={savedQueryId}
adHocDataViews={adHocDataViews}
I18nContext={I18nContext}
services={services}
updateDataViewList={updateDataViewList}
/>
</KibanaContextProvider>
</I18nContext>

View file

@ -56,12 +56,14 @@ export function DiscoverMainApp(props: DiscoverMainProps) {
onUpdateQuery,
persistDataView,
updateAdHocDataViewId,
updateDataViewList,
refetch$,
resetSavedSearch,
searchSource,
state,
stateContainer,
adHocDataViewList,
savedDataViewList,
} = useDiscoverState({
services,
history: usedHistory,
@ -120,7 +122,9 @@ export function DiscoverMainApp(props: DiscoverMainProps) {
stateContainer={stateContainer}
persistDataView={persistDataView}
updateAdHocDataViewId={updateAdHocDataViewId}
updateDataViewList={updateDataViewList}
adHocDataViewList={adHocDataViewList}
savedDataViewList={savedDataViewList}
/>
</DiscoverAppStateProvider>
);

View file

@ -14,20 +14,20 @@ import { discoverServiceMock } from '../../__mocks__/services';
import { DiscoverMainRoute } from './discover_main_route';
import { MemoryRouter } from 'react-router-dom';
import { dataViewMock } from '../../__mocks__/data_view';
import { SavedObject, ScopedHistory } from '@kbn/core/public';
import { SavedObject } from '@kbn/core/public';
import { DataViewSavedObjectAttrs } from '@kbn/data-views-plugin/common';
import { DiscoverMainApp } from './discover_main_app';
import { SearchSource } from '@kbn/data-plugin/common';
import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
import { findTestSubject } from '@elastic/eui/lib/test';
import { scopedHistoryMock } from '@kbn/core/public/mocks';
jest.mock('./discover_main_app', () => {
return {
DiscoverMainApp: jest.fn().mockReturnValue(<></>),
};
});
setScopedHistory({ location: {} } as ScopedHistory);
setScopedHistory(scopedHistoryMock.create());
describe('DiscoverMainRoute', () => {
test('renders the main app when hasESData=true & hasUserDataView=true ', async () => {
const component = mountComponent(true, true);

View file

@ -69,11 +69,11 @@ export const useAdHocDataViews = ({
prev.filter((d) => d.id && dataViewToUpdate.id && d.id !== dataViewToUpdate.id)
);
// update filters references
const uiActions = await getUiActions();
const trigger = uiActions.getTrigger(UPDATE_FILTER_REFERENCES_TRIGGER);
const action = uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION);
// execute shouldn't be awaited, this is important for pending history push cancellation
action?.execute({
trigger,
fromDataView: dataViewToUpdate.id,
@ -81,11 +81,12 @@ export const useAdHocDataViews = ({
usedDataViews: [],
} as ActionExecutionContext);
savedSearch.searchSource.setField('index', newDataView);
stateContainer.replaceUrlAppState({ index: newDataView.id });
setUrlTracking(newDataView);
return newDataView;
},
[dataViews, setUrlTracking, stateContainer]
[dataViews, setUrlTracking, stateContainer, savedSearch.searchSource]
);
const { openConfirmSavePrompt, updateSavedSearch } =
@ -105,5 +106,19 @@ export const useAdHocDataViews = ({
return currentDataView;
}, [stateContainer, openConfirmSavePrompt, savedSearch, updateSavedSearch]);
return { adHocDataViewList, persistDataView, updateAdHocDataViewId };
const onAddAdHocDataViews = useCallback((newDataViews: DataView[]) => {
setAdHocDataViewList((prev) => {
const newAdHocDataViews = newDataViews.filter(
(newDataView) => !prev.find((d) => d.id === newDataView.id)
);
return [...prev, ...newAdHocDataViews];
});
}, []);
return {
adHocDataViewList,
persistDataView,
updateAdHocDataViewId,
onAddAdHocDataViews,
};
};

View file

@ -8,7 +8,7 @@
import { useMemo, useEffect, useState, useCallback } from 'react';
import { isEqual } from 'lodash';
import { History } from 'history';
import { DataViewListItem, DataViewType } from '@kbn/data-views-plugin/public';
import { type DataViewListItem, type DataView, DataViewType } from '@kbn/data-views-plugin/public';
import { SavedSearch, getSavedSearch } from '@kbn/saved-search-plugin/public';
import type { SortOrder } from '@kbn/saved-search-plugin/public';
import { useTextBasedQueryLanguage } from './use_text_based_query_language';
@ -36,7 +36,7 @@ export function useDiscoverState({
history,
savedSearch,
setExpandedDoc,
dataViewList,
dataViewList: initialDataViewList,
}: {
services: DiscoverServices;
savedSearch: SavedSearch;
@ -124,16 +124,30 @@ export function useDiscoverState({
/**
* Adhoc data views functionality
*/
const { adHocDataViewList, persistDataView, updateAdHocDataViewId } = useAdHocDataViews({
const { adHocDataViewList, persistDataView, updateAdHocDataViewId, onAddAdHocDataViews } =
useAdHocDataViews({
dataView,
dataViews,
stateContainer,
savedSearch,
setUrlTracking,
dataViews,
toastNotifications,
filterManager,
toastNotifications,
});
const [savedDataViewList, setSavedDataViewList] = useState(initialDataViewList);
/**
* Updates data views selector state
*/
const updateDataViewList = useCallback(
async (newAdHocDataViews: DataView[]) => {
setSavedDataViewList(await data.dataViews.getIdsWithTitle());
onAddAdHocDataViews(newAdHocDataViews);
},
[data.dataViews, onAddAdHocDataViews]
);
/**
* Data fetching logic
*/
@ -153,7 +167,7 @@ export function useDiscoverState({
documents$: data$.documents$,
dataViews,
stateContainer,
dataViewList,
dataViewList: savedDataViewList,
savedSearch,
});
@ -306,7 +320,9 @@ export function useDiscoverState({
state,
stateContainer,
adHocDataViewList,
savedDataViewList,
persistDataView,
updateAdHocDataViewId,
updateDataViewList,
};
}

View file

@ -78,23 +78,11 @@ export function ViewAlertRoute() {
history.push(DISCOVER_MAIN_ROUTE);
return;
}
const calculatedChecksum = getCurrentChecksum(fetchedAlert.params);
// rule params changed
if (openActualAlert && calculatedChecksum !== queryParams.checksum) {
displayRuleChangedWarn();
}
// documents might be updated or deleted
else if (openActualAlert && calculatedChecksum === queryParams.checksum) {
displayPossibleDocsDiffInfoAlert();
}
const fetchedSearchSource = await fetchSearchSource(fetchedAlert);
if (!fetchedSearchSource) {
history.push(DISCOVER_MAIN_ROUTE);
return;
}
const dataView = fetchedSearchSource.getField('index');
const timeFieldName = dataView?.timeFieldName;
// data view fetch error
@ -104,7 +92,12 @@ export function ViewAlertRoute() {
return;
}
const dataViewSavedObject = await core.savedObjects.client.get('index-pattern', dataView.id!);
if (dataView.isPersisted()) {
const dataViewSavedObject = await core.savedObjects.client.get(
'index-pattern',
dataView.id!
);
const alertUpdatedAt = fetchedAlert.updatedAt;
const dataViewUpdatedAt = dataViewSavedObject.updatedAt!;
// data view updated after the last update of the alert rule
@ -114,13 +107,23 @@ export function ViewAlertRoute() {
) {
showDataViewUpdatedWarning();
}
}
const calculatedChecksum = getCurrentChecksum(fetchedAlert.params);
// rule params changed
if (openActualAlert && calculatedChecksum !== queryParams.checksum) {
displayRuleChangedWarn();
} else if (openActualAlert && calculatedChecksum === queryParams.checksum) {
// documents might be updated or deleted
displayPossibleDocsDiffInfoAlert();
}
const timeRange = openActualAlert
? { from: queryParams.from, to: queryParams.to }
: buildTimeRangeFilter(dataView, fetchedAlert, timeFieldName);
const state: DiscoverAppLocatorParams = {
query: fetchedSearchSource.getField('query') || data.query.queryString.getDefaultQuery(),
dataViewId: dataView.id,
dataViewSpec: dataView.toSpec(false),
timeRange,
};

View file

@ -52,6 +52,9 @@ import { DiscoverStartPlugins } from './plugin';
import { DiscoverContextAppLocator } from './application/context/services/locator';
import { DiscoverSingleDocLocator } from './application/doc/locator';
/**
* Location state of internal Discover history instance
*/
export interface HistoryLocationState {
referrer: string;
}

View file

@ -7,7 +7,7 @@
*/
import { i18n } from '@kbn/i18n';
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { css } from '@emotion/react';
import {
EuiPopover,
@ -24,16 +24,16 @@ import {
EuiFlexItem,
EuiButtonEmpty,
EuiToolTip,
EuiSpacer,
} from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { IUnifiedSearchPluginServices } from '../types';
import type { DataViewPickerPropsExtended } from '.';
import { type DataViewListItemEnhanced, DataViewsList } from './dataview_list';
import type { DataViewListItemEnhanced } from './dataview_list';
import type { TextBasedLanguagesListProps } from './text_languages_list';
import type { TextBasedLanguagesTransitionModalProps } from './text_languages_transition_modal';
import adhoc from './assets/adhoc.svg';
import { changeDataViewStyles } from './change_dataview.styles';
import { DataViewSelector } from './data_view_selector';
// local storage key for the text based languages transition modal
const TEXT_LANG_TRANSITION_MODAL_KEY = 'data.textLangTransitionModal';
@ -62,6 +62,7 @@ export function ChangeDataView({
isMissingCurrent,
currentDataViewId,
adHocDataViews,
savedDataViews,
onChangeDataView,
onAddField,
onDataViewCreated,
@ -77,9 +78,6 @@ export function ChangeDataView({
}: DataViewPickerPropsExtended) {
const { euiTheme } = useEuiTheme();
const [isPopoverOpen, setPopoverIsOpen] = useState(false);
const [noDataViewMatches, setNoDataViewMatches] = useState(false);
const [dataViewSearchString, setDataViewSearchString] = useState('');
const [indexMatches, setIndexMatches] = useState(0);
const [dataViewsList, setDataViewsList] = useState<DataViewListItemEnhanced[]>([]);
const [triggerLabel, setTriggerLabel] = useState('');
const [isTextBasedLangSelected, setIsTextBasedLangSelected] = useState(
@ -100,7 +98,9 @@ export function ChangeDataView({
useEffect(() => {
const fetchDataViews = async () => {
const dataViewsRefs: DataViewListItemEnhanced[] = await data.dataViews.getIdsWithTitle();
const dataViewsRefs: DataViewListItemEnhanced[] = savedDataViews
? savedDataViews
: await data.dataViews.getIdsWithTitle();
if (adHocDataViews?.length) {
adHocDataViews.forEach((adHocDataView) => {
if (adHocDataView.id) {
@ -116,25 +116,7 @@ export function ChangeDataView({
setDataViewsList(dataViewsRefs);
};
fetchDataViews();
}, [data, currentDataViewId, adHocDataViews]);
const pendingIndexMatch = useRef<undefined | NodeJS.Timeout>();
useEffect(() => {
async function checkIndices() {
if (dataViewSearchString !== '' && noDataViewMatches) {
const matches = await kibana.services.dataViews.getIndices({
pattern: dataViewSearchString,
isRollupIndex: () => false,
showAllIndices: false,
});
setIndexMatches(matches.length);
}
}
if (pendingIndexMatch.current) {
clearTimeout(pendingIndexMatch.current);
}
pendingIndexMatch.current = setTimeout(checkIndices, 250);
}, [dataViewSearchString, kibana.services.dataViews, noDataViewMatches]);
}, [data, currentDataViewId, adHocDataViews, savedDataViews]);
useEffect(() => {
if (trigger.label) {
@ -313,9 +295,13 @@ export function ChangeDataView({
</EuiFlexItem>
</EuiFlexGroup>
)}
<DataViewsList
<DataViewSelector
currentDataViewId={currentDataViewId}
searchListInputId={searchListInputId}
dataViewsList={dataViewsList}
selectableProps={selectableProps}
isTextBasedLangSelected={isTextBasedLangSelected}
setPopoverIsOpen={setPopoverIsOpen}
onChangeDataView={async (newId) => {
const dataView = await data.dataViews.get(newId);
await data.dataViews.refreshFields(dataView);
@ -336,58 +322,8 @@ export function ChangeDataView({
onChangeDataView(newId);
}
}}
currentDataViewId={currentDataViewId}
selectableProps={{
...(selectableProps || {}),
// @ts-expect-error Some EUI weirdness
searchProps: {
...(selectableProps?.searchProps || {}),
onChange: (value, matches) => {
selectableProps?.searchProps?.onChange?.(value, matches);
setNoDataViewMatches(matches.length === 0 && dataViewsList.length > 0);
setDataViewSearchString(value);
},
},
}}
searchListInputId={searchListInputId}
isTextBasedLangSelected={isTextBasedLangSelected}
onCreateDefaultAdHocDataView={onCreateDefaultAdHocDataView}
/>
{onCreateDefaultAdHocDataView && noDataViewMatches && indexMatches > 0 && (
<EuiFlexGroup
alignItems="center"
gutterSize="none"
justifyContent="spaceBetween"
data-test-subj="select-text-based-language-panel"
css={css`
margin: ${euiTheme.size.s};
margin-bottom: 0;
`}
>
<EuiFlexItem grow={true}>
<EuiButton
fullWidth
size="s"
onClick={() => {
setPopoverIsOpen(false);
onCreateDefaultAdHocDataView(dataViewSearchString);
}}
>
{i18n.translate(
'unifiedSearch.query.queryBar.indexPattern.createForMatchingIndices',
{
defaultMessage: `Explore {indicesLength, plural,
one {# matching index}
other {# matching indices}}`,
values: {
indicesLength: indexMatches,
},
}
)}
</EuiButton>
<EuiSpacer size="s" />
</EuiFlexItem>
</EuiFlexGroup>
)}
</>
);

View file

@ -0,0 +1,97 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { Fragment, useEffect, useRef, useState } from 'react';
import type { EuiSelectableProps } from '@elastic/eui';
import type { DataViewListItem } from '@kbn/data-views-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { DataViewsList } from './dataview_list';
import { IUnifiedSearchPluginServices } from '../types';
import { ExploreMatchingButton } from './explore_matching_button';
interface DataViewSelectorProps {
currentDataViewId?: string;
searchListInputId?: string;
dataViewsList: DataViewListItem[];
selectableProps?: EuiSelectableProps;
isTextBasedLangSelected: boolean;
setPopoverIsOpen: (isOpen: boolean) => void;
onChangeDataView: (dataViewId: string) => void;
onCreateDefaultAdHocDataView?: (pattern: string) => void;
}
export const DataViewSelector = ({
currentDataViewId,
searchListInputId,
dataViewsList,
selectableProps,
isTextBasedLangSelected,
setPopoverIsOpen,
onChangeDataView,
onCreateDefaultAdHocDataView,
}: DataViewSelectorProps) => {
const kibana = useKibana<IUnifiedSearchPluginServices>();
const { dataViews } = kibana.services;
const [noDataViewMatches, setNoDataViewMatches] = useState(false);
const [dataViewSearchString, setDataViewSearchString] = useState('');
const [indexMatches, setIndexMatches] = useState(0);
const pendingIndexMatch = useRef<undefined | NodeJS.Timeout>();
useEffect(() => {
async function checkIndices() {
if (dataViewSearchString !== '' && noDataViewMatches) {
const matches = await dataViews.getIndices({
pattern: dataViewSearchString,
isRollupIndex: () => false,
showAllIndices: false,
});
setIndexMatches(matches.length);
}
}
pendingIndexMatch.current = setTimeout(checkIndices, 250);
return () => {
if (pendingIndexMatch.current) {
clearTimeout(pendingIndexMatch.current);
}
};
}, [dataViewSearchString, dataViews, noDataViewMatches]);
return (
<Fragment>
<DataViewsList
dataViewsList={dataViewsList}
onChangeDataView={onChangeDataView}
currentDataViewId={currentDataViewId}
selectableProps={{
...(selectableProps || {}),
// @ts-expect-error Some EUI weirdness
searchProps: {
...(selectableProps?.searchProps || {}),
onChange: (value, matches) => {
selectableProps?.searchProps?.onChange?.(value, matches);
setNoDataViewMatches(matches.length === 0 && dataViewsList.length > 0);
setDataViewSearchString(value);
},
},
}}
searchListInputId={searchListInputId}
isTextBasedLangSelected={isTextBasedLangSelected}
/>
<ExploreMatchingButton
noDataViewMatches={noDataViewMatches}
indexMatches={indexMatches}
dataViewSearchString={dataViewSearchString}
setPopoverIsOpen={setPopoverIsOpen}
onCreateDefaultAdHocDataView={onCreateDefaultAdHocDataView}
/>
</Fragment>
);
};

View file

@ -0,0 +1,67 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
interface ExploreMatchingButtonProps {
noDataViewMatches: boolean;
indexMatches: number;
dataViewSearchString: string;
onCreateDefaultAdHocDataView?: (pattern: string) => void;
setPopoverIsOpen: (isOpen: boolean) => void;
}
export const ExploreMatchingButton = ({
noDataViewMatches,
indexMatches,
dataViewSearchString,
setPopoverIsOpen,
onCreateDefaultAdHocDataView,
}: ExploreMatchingButtonProps) => {
const { euiTheme } = useEuiTheme();
if (onCreateDefaultAdHocDataView && noDataViewMatches && indexMatches > 0) {
return (
<EuiFlexGroup
alignItems="center"
gutterSize="none"
justifyContent="spaceBetween"
data-test-subj="select-text-based-language-panel"
css={css`
margin: ${euiTheme.size.s};
margin-bottom: 0;
`}
>
<EuiFlexItem grow={true}>
<EuiButton
fullWidth
size="s"
onClick={() => {
setPopoverIsOpen(false);
onCreateDefaultAdHocDataView(dataViewSearchString);
}}
>
{i18n.translate('unifiedSearch.query.queryBar.indexPattern.createForMatchingIndices', {
defaultMessage: `Explore {indicesLength, plural,
one {# matching index}
other {# matching indices}}`,
values: {
indicesLength: indexMatches,
},
})}
</EuiButton>
<EuiSpacer size="s" />
</EuiFlexItem>
</EuiFlexGroup>
);
}
return null;
};

View file

@ -8,7 +8,7 @@
import React from 'react';
import type { EuiButtonProps, EuiSelectableProps } from '@elastic/eui';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { DataView, DataViewListItem } from '@kbn/data-views-plugin/public';
import type { AggregateQuery, Query } from '@kbn/es-query';
import { ChangeDataView } from './change_dataview';
@ -54,6 +54,10 @@ export interface DataViewPickerProps {
* The adHocDataviews.
*/
adHocDataViews?: DataView[];
/**
* Saved data views
*/
savedDataViews?: DataViewListItem[];
/**
* EuiSelectable properties.
*/
@ -102,6 +106,7 @@ export const DataViewPicker = ({
isMissingCurrent,
currentDataViewId,
adHocDataViews,
savedDataViews,
onChangeDataView,
onEditDataView,
onAddField,
@ -126,6 +131,7 @@ export const DataViewPicker = ({
onCreateDefaultAdHocDataView={onCreateDefaultAdHocDataView}
trigger={trigger}
adHocDataViews={adHocDataViews}
savedDataViews={savedDataViews}
selectableProps={selectableProps}
textBasedLanguages={textBasedLanguages}
onSaveTextLanguageQuery={onSaveTextLanguageQuery}

View file

@ -21,6 +21,7 @@ export { SearchBar } from './search_bar';
export type { FilterItemsProps } from './filter_bar';
export { FilterLabel, FilterItem, FilterItems } from './filter_bar';
export { DataViewsList } from './dataview_picker/dataview_list';
export { DataViewSelector } from './dataview_picker/data_view_selector';
export { DataViewPicker } from './dataview_picker';
export type { DataViewPickerProps } from './dataview_picker';

View file

@ -38,12 +38,20 @@ export type StatefulSearchBarProps<QT extends Query | AggregateQuery = Query> =
useDefaultBehaviors?: boolean;
savedQueryId?: string;
onSavedQueryIdChange?: (savedQueryId?: string) => void;
onFiltersUpdated?: (filters: Filter[]) => void;
};
// Respond to user changing the filters
const defaultFiltersUpdated = (queryService: QueryStart) => {
const defaultFiltersUpdated = (
queryService: QueryStart,
onFiltersUpdated?: (filters: Filter[]) => void
) => {
return (filters: Filter[]) => {
if (onFiltersUpdated) {
onFiltersUpdated(filters);
} else {
queryService.filterManager.setFilters(filters);
}
};
};
@ -206,7 +214,7 @@ export function createSearchBar({
isRefreshPaused={refreshInterval.pause}
filters={filters}
query={query}
onFiltersUpdated={defaultFiltersUpdated(data.query)}
onFiltersUpdated={defaultFiltersUpdated(data.query, props.onFiltersUpdated)}
onRefreshChange={defaultOnRefreshChange(data.query)}
savedQuery={savedQuery}
onQuerySubmit={defaultOnQuerySubmit(props, data.query, query)}

View file

@ -14,8 +14,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const toasts = getService('toasts');
const esArchiver = getService('esArchiver');
const filterBar = getService('filterBar');
const dashboardAddPanel = getService('dashboardAddPanel');
const fieldEditor = getService('fieldEditor');
const dashboardAddPanel = getService('dashboardAddPanel');
const kibanaServer = getService('kibanaServer');
const retry = getService('retry');
const queryBar = getService('queryBar');
@ -132,7 +132,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should update data view id when saving data view from hoc one', async () => {
const prevDataViewId = await PageObjects.discover.getCurrentDataViewId();
await testSubjects.click('discoverAlertsButton');
await testSubjects.click('shareTopNavButton');
await testSubjects.click('confirmModalConfirmButton');
await PageObjects.header.waitUntilLoadingHasFinished();

View file

@ -67,6 +67,11 @@ export class ToastsService extends FtrService {
return await list.findByCssSelector(`.euiToast:nth-child(${index})`);
}
public async getToastContent(index: number) {
const elem = await this.getToastElement(index);
return await elem.getVisibleText();
}
public async getAllToastElements() {
const list = await this.getGlobalToastList();
return await list.findAllByCssSelector(`.euiToast`);

View file

@ -117,7 +117,6 @@ export const SearchPanel: FC<Props> = ({
displayStyle={'inPage'}
isClearable={true}
customSubmitButton={<div />}
// @ts-expect-error onFiltersUpdated is a valid prop on SearchBar
onFiltersUpdated={(filters: Filter[]) => searchHandler({ filters })}
/>
</EuiFlexItem>

View file

@ -10,6 +10,7 @@ import { EuiThemeComputed, useEuiTheme } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { DataView } from '@kbn/data-plugin/common';
import { i18n } from '@kbn/i18n';
import type { Filter } from '@kbn/es-query';
import { SecuritySolutionContext } from '../../../application/security_solution_context';
import * as TEST_SUBJECTS from '../test_subjects';
import type { FindingsBaseURLQuery } from '../types';
@ -49,7 +50,6 @@ export const FindingsSearchBar = ({
isLoading={loading}
indexPatterns={[dataView]}
onQuerySubmit={setQuery}
// @ts-expect-error onFiltersUpdated is a valid prop on SearchBar
onFiltersUpdated={(value: Filter[]) => setQuery({ filters: value })}
placeholder={i18n.translate('xpack.csp.findings.searchBar.searchPlaceholder', {
defaultMessage: 'Search findings (eg. rule.section : "API Server" )',

View file

@ -136,7 +136,6 @@ export const SearchPanel: FC<Props> = ({
onQuerySubmit={(params: { dateRange: TimeRange; query?: Query | undefined }) =>
searchHandler({ query: params.query })
}
// @ts-expect-error onFiltersUpdated is a valid prop on SearchBar
onFiltersUpdated={(filters: Filter[]) => searchHandler({ filters })}
indexPatterns={[dataView]}
placeholder={i18n.translate('xpack.dataVisualizer.searchPanel.queryBarPlaceholderText', {

View file

@ -86,7 +86,7 @@ type Props = Omit<
},
AlertContextMeta
>,
'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch'
'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' | 'onChangeMetaData'
>;
export const defaultExpression = {

View file

@ -39,7 +39,7 @@ type AlertParams = RuleTypeParams &
type Props = Omit<
RuleTypeParamsExpressionProps<AlertParams, AlertContextMeta>,
'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch'
'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' | 'onChangeMetaData'
>;
export const defaultExpression = {

View file

@ -42,7 +42,7 @@ const FILTER_TYPING_DEBOUNCE_MS = 500;
type Props = Omit<
RuleTypeParamsExpressionProps<RuleTypeParams & AlertParams, AlertContextMeta>,
'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch'
'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' | 'onChangeMetaData'
>;
const defaultExpression = {

View file

@ -70,7 +70,6 @@ export const UnifiedSearchBar = ({ dataView }: Props) => {
onClearSavedQuery={onClearSavedQuery}
showSaveQuery
showQueryInput
// @ts-expect-error onFiltersUpdated is a valid prop on SearchBar
onFiltersUpdated={onFilterChange}
/>
);

View file

@ -143,6 +143,7 @@ describe('alert_form', () => {
operation="create"
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
onChangeMetaData={() => {}}
/>
</KibanaReactContext.Provider>
</I18nProvider>

View file

@ -1,12 +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 { schema, TypeOf } from '@kbn/config-schema';
export const configSchema = schema.object({});
export type Config = TypeOf<typeof configSchema>;

View file

@ -5,8 +5,4 @@
* 2.0.
*/
// TODO: https://github.com/elastic/kibana/issues/110895
/* eslint-disable @kbn/eslint/no_export_all */
export * from './config';
export { STACK_ALERTS_FEATURE_ID } from './constants';

View file

@ -19,6 +19,8 @@ export interface StackAlertsPublicSetupDeps {
}
export class StackAlertsPublicPlugin implements Plugin<Setup, Start, StackAlertsPublicSetupDeps> {
constructor() {}
public setup(core: CoreSetup, { triggersActionsUi, alerting }: StackAlertsPublicSetupDeps) {
registerRuleTypes({
ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry,

View file

@ -10,46 +10,72 @@ import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { DataViewSelectPopover, DataViewSelectPopoverProps } from './data_view_select_popover';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import type { DataView } from '@kbn/data-views-plugin/public';
import { indexPatternEditorPluginMock as dataViewEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks';
import { act } from 'react-dom/test-utils';
const props: DataViewSelectPopoverProps = {
onSelectDataView: () => {},
dataViewName: 'kibana_sample_data_logs',
dataViewId: 'mock-data-logs-id',
};
const dataViewOptions = [
{
const selectedDataView = {
id: 'mock-data-logs-id',
namespaces: ['default'],
title: 'kibana_sample_data_logs',
},
isTimeBased: jest.fn(),
isPersisted: jest.fn(() => true),
getName: () => 'kibana_sample_data_logs',
} as unknown as DataView;
const props: DataViewSelectPopoverProps = {
onSelectDataView: () => {},
onChangeMetaData: () => {},
dataView: selectedDataView,
};
const dataViewIds = ['mock-data-logs-id', 'mock-ecommerce-id', 'mock-test-id'];
const dataViewOptions = [
selectedDataView,
{
id: 'mock-flyghts-id',
namespaces: ['default'],
title: 'kibana_sample_data_flights',
isTimeBased: jest.fn(),
isPersisted: jest.fn(() => true),
getName: () => 'kibana_sample_data_flights',
},
{
id: 'mock-ecommerce-id',
namespaces: ['default'],
title: 'kibana_sample_data_ecommerce',
typeMeta: {},
isTimeBased: jest.fn(),
isPersisted: jest.fn(() => true),
getName: () => 'kibana_sample_data_ecommerce',
},
{
id: 'mock-test-id',
namespaces: ['default'],
title: 'test',
typeMeta: {},
isTimeBased: jest.fn(),
isPersisted: jest.fn(() => true),
getName: () => 'test',
},
];
const mount = () => {
const dataViewsMock = dataViewPluginMocks.createStartContract();
dataViewsMock.getIdsWithTitle.mockImplementation(() => Promise.resolve(dataViewOptions));
dataViewsMock.getIds = jest.fn().mockImplementation(() => Promise.resolve(dataViewIds));
dataViewsMock.get = jest
.fn()
.mockImplementation((id: string) =>
Promise.resolve(dataViewOptions.find((current) => current.id === id))
);
const dataViewEditorMock = dataViewEditorPluginMock.createStartContract();
return {
wrapper: mountWithIntl(
<KibanaContextProvider services={{ data: { dataViews: dataViewsMock } }}>
<KibanaContextProvider
services={{ dataViews: dataViewsMock, dataViewEditor: dataViewEditorMock }}
>
<DataViewSelectPopover {...props} />
</KibanaContextProvider>
),
@ -66,10 +92,10 @@ describe('DataViewSelectPopover', () => {
wrapper.update();
});
expect(dataViewsMock.getIdsWithTitle).toHaveBeenCalled();
expect(dataViewsMock.getIds).toHaveBeenCalled();
expect(wrapper.find('[data-test-subj="selectDataViewExpression"]').exists()).toBeTruthy();
const getIdsWithTitleResult = await dataViewsMock.getIdsWithTitle.mock.results[0].value;
expect(getIdsWithTitleResult).toBe(dataViewOptions);
const getIdsResult = await dataViewsMock.getIds.mock.results[0].value;
expect(getIdsResult).toBe(dataViewIds);
});
});

View file

@ -14,56 +14,97 @@ import {
EuiExpression,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiPopover,
EuiPopoverFooter,
EuiPopoverTitle,
EuiText,
useEuiPaddingCSS,
} 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';
import type { DataViewListItem, DataView } from '@kbn/data-views-plugin/public';
import { DataViewSelector } from '@kbn/unified-search-plugin/public';
import { useTriggerUiActionServices } from '../es_query/util';
import { EsQueryRuleMetaData } from '../es_query/types';
export interface DataViewSelectPopoverProps {
onSelectDataView: (newDataViewId: string) => void;
dataViewName?: string;
dataViewId?: string;
dataView: DataView;
metadata?: EsQueryRuleMetaData;
onSelectDataView: (selectedDataView: DataView) => void;
onChangeMetaData: (metadata: EsQueryRuleMetaData) => void;
}
const toDataViewListItem = (dataView: DataView): DataViewListItem => {
return {
id: dataView.id!,
title: dataView.title,
name: dataView.name,
};
};
export const DataViewSelectPopover: React.FunctionComponent<DataViewSelectPopoverProps> = ({
metadata = { adHocDataViewList: [], isManagementPage: true },
dataView,
onSelectDataView,
dataViewName,
dataViewId,
onChangeMetaData,
}) => {
const { data, dataViewEditor } = useTriggersAndActionsUiDeps();
const [dataViewItems, setDataViewsItems] = useState<DataViewListItem[]>();
const { dataViews, dataViewEditor } = useTriggerUiActionServices();
const [dataViewItems, setDataViewsItems] = useState<DataViewListItem[]>([]);
const [dataViewPopoverOpen, setDataViewPopoverOpen] = useState(false);
const closeDataViewEditor = useRef<() => void | undefined>();
const loadDataViews = useCallback(async () => {
const fetchedDataViewItems = await data.dataViews.getIdsWithTitle();
setDataViewsItems(fetchedDataViewItems);
}, [setDataViewsItems, data.dataViews]);
const allDataViewItems = useMemo(
() => [...dataViewItems, ...metadata.adHocDataViewList.map(toDataViewListItem)],
[dataViewItems, metadata.adHocDataViewList]
);
const closeDataViewPopover = useCallback(() => setDataViewPopoverOpen(false), []);
const onChangeDataView = useCallback(
async (selectedDataViewId: string) => {
const selectedDataView = await dataViews.get(selectedDataViewId);
onSelectDataView(selectedDataView);
closeDataViewPopover();
},
[closeDataViewPopover, dataViews, onSelectDataView]
);
const loadPersistedDataViews = useCallback(async () => {
const ids = await dataViews.getIds();
const dataViewsList = await Promise.all(ids.map((id) => dataViews.get(id)));
setDataViewsItems(dataViewsList.map(toDataViewListItem));
}, [dataViews]);
const onAddAdHocDataView = useCallback(
(adHocDataView: DataView) => {
onChangeMetaData({
...metadata,
adHocDataViewList: [...metadata.adHocDataViewList, adHocDataView],
});
},
[metadata, onChangeMetaData]
);
const createDataView = useMemo(
() =>
dataViewEditor?.userPermissions.editDataView()
dataViewEditor.userPermissions.editDataView()
? () => {
closeDataViewEditor.current = dataViewEditor.openEditor({
onSave: async (createdDataView) => {
if (createdDataView.id) {
await onSelectDataView(createdDataView.id);
await loadDataViews();
if (!createdDataView.isPersisted()) {
onAddAdHocDataView(createdDataView);
}
await loadPersistedDataViews();
await onChangeDataView(createdDataView.id);
}
},
allowAdHocDataView: true,
});
}
: undefined,
[dataViewEditor, onSelectDataView, loadDataViews]
[dataViewEditor, loadPersistedDataViews, onChangeDataView, onAddAdHocDataView]
);
useEffect(() => {
@ -76,12 +117,25 @@ export const DataViewSelectPopover: React.FunctionComponent<DataViewSelectPopove
}, []);
useEffect(() => {
loadDataViews();
}, [loadDataViews]);
loadPersistedDataViews();
}, [loadPersistedDataViews]);
const createDataViewButtonPadding = useEuiPaddingCSS('left');
if (!dataViewItems) {
const onCreateDefaultAdHocDataView = useCallback(
async (pattern: string) => {
const newDataView = await dataViews.create({ title: pattern });
if (newDataView.fields.getByName('@timestamp')?.type === 'date') {
newDataView.timeFieldName = '@timestamp';
}
onAddAdHocDataView(newDataView);
onChangeDataView(newDataView.id!);
},
[dataViews, onAddAdHocDataView, onChangeDataView]
);
if (!allDataViewItems) {
return null;
}
@ -96,7 +150,7 @@ export const DataViewSelectPopover: React.FunctionComponent<DataViewSelectPopove
defaultMessage: 'data view',
})}
value={
dataViewName ??
dataView.getName() ??
i18n.translate('xpack.stackAlerts.components.ui.alertParams.dataViewPlaceholder', {
defaultMessage: 'Select a data view',
})
@ -105,7 +159,7 @@ export const DataViewSelectPopover: React.FunctionComponent<DataViewSelectPopove
onClick={() => {
setDataViewPopoverOpen(true);
}}
isInvalid={!dataViewId}
isInvalid={!dataView.id}
/>
}
isOpen={dataViewPopoverOpen}
@ -136,24 +190,14 @@ export const DataViewSelectPopover: React.FunctionComponent<DataViewSelectPopove
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverTitle>
<EuiFormRow
id="indexSelectSearchBox"
fullWidth
css={`
.euiPanel {
padding: 0;
}
`}
>
<DataViewsList
dataViewsList={dataViewItems}
onChangeDataView={(newId) => {
onSelectDataView(newId);
closeDataViewPopover();
}}
currentDataViewId={dataViewId}
<DataViewSelector
currentDataViewId={dataView.id}
dataViewsList={allDataViewItems}
setPopoverIsOpen={setDataViewPopoverOpen}
onChangeDataView={onChangeDataView}
onCreateDefaultAdHocDataView={onCreateDefaultAdHocDataView}
isTextBasedLangSelected={false}
/>
</EuiFormRow>
{createDataView ? (
<EuiPopoverFooter paddingSize="none">
<EuiButtonEmpty

View file

@ -159,6 +159,7 @@ describe('EsQueryRuleTypeExpression', () => {
defaultActionGroupId=""
actionGroups={[]}
charts={chartsStartMock}
onChangeMetaData={() => {}}
/>
);

View file

@ -19,7 +19,7 @@ import { getFields, RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-
import { parseDuration } from '@kbn/alerting-plugin/common';
import { hasExpressionValidationErrors } from '../validation';
import { buildSortedEventsQuery } from '../../../../common/build_sorted_events_query';
import { EsQueryRuleParams, SearchType } from '../types';
import { EsQueryRuleParams, EsQueryRuleMetaData, SearchType } from '../types';
import { IndexSelectPopover } from '../../components/index_select_popover';
import { DEFAULT_VALUES } from '../constants';
import { RuleCommonExpressions } from '../rule_common_expressions';
@ -33,7 +33,7 @@ interface KibanaDeps {
}
export const EsQueryExpression: React.FC<
RuleTypeParamsExpressionProps<EsQueryRuleParams<SearchType.esQuery>>
RuleTypeParamsExpressionProps<EsQueryRuleParams<SearchType.esQuery>, EsQueryRuleMetaData>
> = ({ ruleParams, setRuleParams, setRuleProperty, errors, data }) => {
const {
index,

View file

@ -13,7 +13,7 @@ import { httpServiceMock } from '@kbn/core/public/mocks';
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 { CommonRuleParams, EsQueryRuleParams, SearchType } from '../types';
import { CommonRuleParams, EsQueryRuleMetaData, EsQueryRuleParams, SearchType } from '../types';
import { EsQueryRuleTypeExpression } from './expression';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { Subject } from 'rxjs';
@ -22,6 +22,7 @@ import { IUiSettingsClient } from '@kbn/core/public';
import { findTestSubject } from '@elastic/eui/lib/test';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { act } from 'react-dom/test-utils';
import { indexPatternEditorPluginMock as dataViewEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks';
import { ReactWrapper } from 'enzyme';
jest.mock('@kbn/kibana-react-plugin/public', () => {
@ -87,6 +88,8 @@ const searchSourceFieldsMock = {
id: '90943e30-9a47-11e8-b64d-95841ca0b247',
title: 'kibana_sample_data_logs',
fields: [],
getName: () => 'kibana_sample_data_logs',
isPersisted: () => true,
},
};
@ -122,11 +125,15 @@ const savedQueryMock = {
};
const dataMock = dataPluginMock.createStartContract();
const dataViewsMock = dataViewPluginMocks.createStartContract();
const dataViewEditorMock = dataViewEditorPluginMock.createStartContract();
(dataMock.search.searchSource.create as jest.Mock).mockImplementation(() =>
Promise.resolve(searchSourceMock)
);
(dataMock.dataViews.getIdsWithTitle as jest.Mock).mockImplementation(() => Promise.resolve([]));
dataMock.dataViews.getDefaultDataView = jest.fn(() => Promise.resolve(null));
(dataViewsMock.getIds as jest.Mock) = jest.fn().mockImplementation(() => Promise.resolve([]));
dataViewsMock.getDefaultDataView = jest.fn(() => Promise.resolve(null));
dataViewsMock.get = jest.fn();
(dataMock.query.savedQueries.getSavedQuery as jest.Mock).mockImplementation(() =>
Promise.resolve(savedQueryMock)
);
@ -137,7 +144,8 @@ dataMock.query.savedQueries.findSavedQueries = jest.fn(() =>
const Wrapper: React.FC<{
ruleParams: EsQueryRuleParams<SearchType.searchSource> | EsQueryRuleParams<SearchType.esQuery>;
}> = ({ ruleParams }) => {
metadata?: EsQueryRuleMetaData;
}> = ({ ruleParams, metadata }) => {
const [currentRuleParams, setCurrentRuleParams] = useState<CommonRuleParams>(ruleParams);
const errors = {
index: [],
@ -170,23 +178,29 @@ const Wrapper: React.FC<{
defaultActionGroupId=""
actionGroups={[]}
charts={chartsStartMock}
metadata={metadata}
onChangeMetaData={jest.fn()}
/>
);
};
const setup = (
ruleParams: EsQueryRuleParams<SearchType.searchSource> | EsQueryRuleParams<SearchType.esQuery>
ruleParams: EsQueryRuleParams<SearchType.searchSource> | EsQueryRuleParams<SearchType.esQuery>,
metadata?: EsQueryRuleMetaData
) => {
return mountWithIntl(
<KibanaContextProvider
services={{
data: dataMock,
dataViews: dataViewsMock,
uiSettings: uiSettingsMock,
docLinks: docLinksMock,
http: httpMock,
unifiedSearch: unifiedSearchMock,
dataViewEditor: dataViewEditorMock,
}}
>
<Wrapper ruleParams={ruleParams} />
<Wrapper ruleParams={ruleParams} metadata={metadata} />
</KibanaContextProvider>
);
};
@ -236,10 +250,10 @@ describe('EsQueryRuleTypeExpression', () => {
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy();
});
test('should render QueryDSL view without the form type chooser if some rule params were passed', async () => {
test('should render QueryDSL view without the form type chooser', async () => {
let wrapper: ReactWrapper;
await act(async () => {
wrapper = setup(defaultEsQueryRuleParams);
wrapper = setup(defaultEsQueryRuleParams, { adHocDataViewList: [], isManagementPage: false });
wrapper = await wrapper.update();
});
expect(findTestSubject(wrapper!, 'queryFormTypeChooserTitle').exists()).toBeFalsy();
@ -247,10 +261,13 @@ describe('EsQueryRuleTypeExpression', () => {
expect(findTestSubject(wrapper!, 'selectIndexExpression').exists()).toBeTruthy();
});
test('should render KQL and Lucene view without the form type chooser if some rule params were passed', async () => {
test('should render KQL and Lucene view without the form type chooser', async () => {
let wrapper: ReactWrapper;
await act(async () => {
wrapper = setup(defaultSearchSourceRuleParams);
wrapper = setup(defaultSearchSourceRuleParams, {
adHocDataViewList: [],
isManagementPage: false,
});
wrapper = await wrapper.update();
});
wrapper = await wrapper!.update();

View file

@ -5,13 +5,13 @@
* 2.0.
*/
import React, { memo, PropsWithChildren, useCallback, useRef } from 'react';
import React, { memo, PropsWithChildren, useCallback } from 'react';
import deepEqual from 'fast-deep-equal';
import 'brace/theme/github';
import { EuiCallOut, EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
import { EsQueryRuleParams, SearchType } from '../types';
import { EsQueryRuleParams, EsQueryRuleMetaData, SearchType } from '../types';
import { SearchSourceExpression, SearchSourceExpressionProps } from './search_source_expression';
import { EsQueryExpression } from './es_query_expression';
import { QueryFormTypeChooser } from './query_form_type_chooser';
@ -33,11 +33,12 @@ const SearchSourceExpressionMemoized = memo<SearchSourceExpressionProps>(
);
export const EsQueryRuleTypeExpression: React.FunctionComponent<
RuleTypeParamsExpressionProps<EsQueryRuleParams>
RuleTypeParamsExpressionProps<EsQueryRuleParams, EsQueryRuleMetaData>
> = (props) => {
const { ruleParams, errors, setRuleProperty, setRuleParams } = props;
const isSearchSource = isSearchSourceRule(ruleParams);
const isManagementPage = useRef(!Object.keys(ruleParams).length).current;
// metadata provided only when open alert from Discover page
const isManagementPage = props.metadata?.isManagementPage ?? true;
const formTypeSelected = useCallback(
(searchType: SearchType | null) => {

View file

@ -20,6 +20,7 @@ import { IUiSettingsClient } from '@kbn/core/public';
import { findTestSubject } from '@elastic/eui/lib/test';
import { copyToClipboard, EuiLoadingSpinner } from '@elastic/eui';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { indexPatternEditorPluginMock as dataViewEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks';
import { ReactWrapper } from 'enzyme';
jest.mock('@elastic/eui', () => {
@ -81,6 +82,9 @@ const searchSourceFieldsMock = {
id: '90943e30-9a47-11e8-b64d-95841ca0b247',
title: 'kibana_sample_data_logs',
fields: [],
isPersisted: () => true,
getName: () => 'kibana_sample_data_logs',
isTimeBased: () => true,
},
};
@ -182,8 +186,9 @@ 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.dataViews.getDefaultDataView = jest.fn(() => Promise.resolve(null));
(dataViewPluginMock.getIds as jest.Mock) = jest.fn().mockImplementation(() => Promise.resolve([]));
dataViewPluginMock.getDefaultDataView = jest.fn(() => Promise.resolve(null));
dataViewPluginMock.get = jest.fn();
(dataMock.query.savedQueries.getSavedQuery as jest.Mock).mockImplementation(() =>
Promise.resolve(savedQueryMock)
);
@ -198,9 +203,18 @@ const setup = (alertParams: EsQueryRuleParams<SearchType.searchSource>) => {
timeWindowSize: [],
searchConfiguration: [],
};
const dataViewEditorMock = dataViewEditorPluginMock.createStartContract();
return mountWithIntl(
<KibanaContextProvider services={{ data: dataMock, uiSettings: uiSettingsMock }}>
<KibanaContextProvider
services={{
dataViews: dataViewPluginMock,
data: dataMock,
uiSettings: uiSettingsMock,
dataViewEditor: dataViewEditorMock,
unifiedSearch: unifiedSearchMock,
}}
>
<SearchSourceExpression
ruleInterval="1m"
ruleThrottle="1m"
@ -215,6 +229,8 @@ const setup = (alertParams: EsQueryRuleParams<SearchType.searchSource>) => {
defaultActionGroupId=""
actionGroups={[]}
charts={chartsStartMock}
metadata={{ adHocDataViewList: [] }}
onChangeMetaData={jest.fn()}
/>
</KibanaContextProvider>
);

View file

@ -11,13 +11,14 @@ import { EuiSpacer, EuiLoadingSpinner, EuiEmptyPrompt, EuiCallOut } from '@elast
import { ISearchSource } from '@kbn/data-plugin/common';
import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
import { SavedQuery } from '@kbn/data-plugin/public';
import { EsQueryRuleParams, SearchType } from '../types';
import { useTriggersAndActionsUiDeps } from '../util';
import { EsQueryRuleMetaData, EsQueryRuleParams, SearchType } from '../types';
import { SearchSourceExpressionForm } from './search_source_expression_form';
import { DEFAULT_VALUES } from '../constants';
import { useTriggerUiActionServices } from '../util';
export type SearchSourceExpressionProps = RuleTypeParamsExpressionProps<
EsQueryRuleParams<SearchType.searchSource>
EsQueryRuleParams<SearchType.searchSource>,
EsQueryRuleMetaData
>;
export const SearchSourceExpression = ({
@ -25,6 +26,8 @@ export const SearchSourceExpression = ({
errors,
setRuleParams,
setRuleProperty,
metadata,
onChangeMetaData,
}: SearchSourceExpressionProps) => {
const {
thresholdComparator,
@ -36,7 +39,7 @@ export const SearchSourceExpression = ({
searchConfiguration,
excludeHitsFromPreviousRun,
} = ruleParams;
const { data } = useTriggersAndActionsUiDeps();
const { data } = useTriggerUiActionServices();
const [searchSource, setSearchSource] = useState<ISearchSource>();
const [savedQuery, setSavedQuery] = useState<SavedQuery>();
@ -112,6 +115,8 @@ export const SearchSourceExpression = ({
errors={errors}
initialSavedQuery={savedQuery}
setParam={setParam}
metadata={metadata}
onChangeMetaData={onChangeMetaData}
/>
);
};

View file

@ -8,21 +8,26 @@
import React, { Fragment, useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import deepEqual from 'fast-deep-equal';
import { lastValueFrom } from 'rxjs';
import { Filter } from '@kbn/es-query';
import type { Filter, Query } from '@kbn/es-query';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiSpacer, EuiTitle } from '@elastic/eui';
import { DataView, Query, ISearchSource, getTime } from '@kbn/data-plugin/common';
import { IErrorObject } from '@kbn/triggers-actions-ui-plugin/public';
import { SearchBar, SearchBarProps } from '@kbn/unified-search-plugin/public';
import { mapAndFlattenFilters, SavedQuery, TimeHistory } from '@kbn/data-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { CommonRuleParams, EsQueryRuleParams, SearchType } from '../types';
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 { STACK_ALERTS_FEATURE_ID } from '../../../../common';
import { CommonRuleParams, EsQueryRuleMetaData, EsQueryRuleParams, SearchType } from '../types';
import { DEFAULT_VALUES } from '../constants';
import { DataViewSelectPopover } from '../../components/data_view_select_popover';
import { useTriggersAndActionsUiDeps } from '../util';
import { RuleCommonExpressions } from '../rule_common_expressions';
import { totalHitsToNumber } from '../test_query_row';
import { hasExpressionValidationErrors } from '../validation';
import { useTriggerUiActionServices } from '../util';
const HIDDEN_FILTER_PANEL_OPTIONS: SearchBarProps['hiddenFilterPanelOptions'] = [
'pinFilter',
@ -66,8 +71,10 @@ interface SearchSourceExpressionFormProps {
searchSource: ISearchSource;
ruleParams: EsQueryRuleParams<SearchType.searchSource>;
errors: IErrorObject;
metadata?: EsQueryRuleMetaData;
initialSavedQuery?: SavedQuery;
setParam: (paramField: string, paramValue: unknown) => void;
onChangeMetaData: (metadata: EsQueryRuleMetaData) => void;
}
const isSearchSourceParam = (action: LocalStateAction): action is SearchSourceParamsAction => {
@ -75,12 +82,11 @@ const isSearchSourceParam = (action: LocalStateAction): action is SearchSourcePa
};
export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProps) => {
const { data } = useTriggersAndActionsUiDeps();
const services = useTriggerUiActionServices();
const unifiedSearch = services.unifiedSearch;
const { searchSource, errors, initialSavedQuery, setParam, ruleParams } = props;
const [savedQuery, setSavedQuery] = useState<SavedQuery>();
const timeHistory = useMemo(() => new TimeHistory(new Storage(localStorage)), []);
useEffect(() => setSavedQuery(initialSavedQuery), [initialSavedQuery]);
const [ruleConfiguration, dispatch] = useReducer<LocalStateReducer>(
@ -110,11 +116,8 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
const dataViews = useMemo(() => (dataView ? [dataView] : []), [dataView]);
const onSelectDataView = useCallback(
(newDataViewId) =>
data.dataViews
.get(newDataViewId)
.then((newDataView) => dispatch({ type: 'index', payload: newDataView })),
[data.dataViews]
(newDataView: DataView) => dispatch({ type: 'index', payload: newDataView }),
[]
);
const onUpdateFilters = useCallback((newFilters) => {
@ -228,9 +231,10 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
<EuiSpacer size="s" />
<DataViewSelectPopover
dataViewName={dataView?.getName?.() ?? dataView?.title}
dataViewId={dataView?.id}
dataView={dataView}
metadata={props.metadata}
onSelectDataView={onSelectDataView}
onChangeMetaData={props.onChangeMetaData}
/>
{Boolean(dataView?.id) && (
@ -245,7 +249,8 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
</h5>
</EuiTitle>
<EuiSpacer size="xs" />
<SearchBar
<unifiedSearch.ui.SearchBar
appName={STACK_ALERTS_FEATURE_ID}
onQuerySubmit={onQueryBarSubmit}
onQueryChange={onChangeQuery}
suggestionsSize="s"
@ -266,7 +271,6 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
showSubmitButton={false}
dateRangeFrom={undefined}
dateRangeTo={undefined}
timeHistory={timeHistory}
hiddenFilterPanelOptions={HIDDEN_FILTER_PANEL_OPTIONS}
/>
</>

View file

@ -8,8 +8,10 @@
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 { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { DataViewsPublicPluginStart, DataView } from '@kbn/data-views-plugin/public';
import { EXPRESSION_ERRORS } from './constants';
export interface Comparator {
@ -32,6 +34,11 @@ export interface CommonRuleParams extends RuleTypeParams {
excludeHitsFromPreviousRun: boolean;
}
export interface EsQueryRuleMetaData {
adHocDataViewList: DataView[];
isManagementPage?: boolean;
}
export type EsQueryRuleParams<T = SearchType> = T extends SearchType.searchSource
? CommonRuleParams & OnlySearchSourceRuleParams
: CommonRuleParams & OnlyEsQueryRuleParams;
@ -55,6 +62,8 @@ export type ExpressionErrors = typeof EXPRESSION_ERRORS;
export type ErrorKey = keyof ExpressionErrors & unknown;
export interface TriggersAndActionsUiDeps {
dataViews: DataViewsPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
data: DataPublicPluginStart;
dataViewEditor: DataViewEditorStart;
}

View file

@ -14,4 +14,4 @@ export const isSearchSourceRule = (
return ruleParams.searchType === 'searchSource';
};
export const useTriggersAndActionsUiDeps = () => useKibana<TriggersAndActionsUiDeps>().services;
export const useTriggerUiActionServices = () => useKibana<TriggersAndActionsUiDeps>().services;

View file

@ -100,6 +100,18 @@ export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationRes
defaultMessage: 'Data view is required.',
})
);
} else if (
typeof ruleParams.searchConfiguration.index === 'object' &&
!Object.hasOwn(ruleParams.searchConfiguration.index, 'timeFieldName')
) {
errors.index.push(
i18n.translate(
'xpack.stackAlerts.esQuery.ui.validation.error.requiredDataViewTimeFieldText',
{
defaultMessage: 'Data view should have a time field.',
}
)
);
}
return validationResult;
}

View file

@ -101,6 +101,7 @@ describe('IndexThresholdRuleTypeExpression', () => {
defaultActionGroupId=""
actionGroups={[]}
charts={chartsStartMock}
onChangeMetaData={() => {}}
/>
);

View file

@ -5,11 +5,15 @@
* 2.0.
*/
import { get } from 'lodash';
import { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor, PluginInitializerContext } from '@kbn/core/server';
import { AlertingBuiltinsPlugin } from './plugin';
import { configSchema, Config } from '../common/config';
export { ID as INDEX_THRESHOLD_ID } from './rule_types/index_threshold/rule_type';
export const configSchema = schema.object({});
export type Config = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<Config> = {
exposeToBrowser: {},
schema: configSchema,

View file

@ -207,7 +207,10 @@ describe('rule_add', () => {
expect(wrapper.find('[data-test-subj="saveRuleButton"]').exists()).toBeTruthy();
wrapper.find('[data-test-subj="cancelSaveRuleButton"]').last().simulate('click');
expect(onClose).toHaveBeenCalledWith(RuleFlyoutCloseReason.CANCELED);
expect(onClose).toHaveBeenCalledWith(RuleFlyoutCloseReason.CANCELED, {
fields: ['test'],
test: 'some value',
});
});
it('renders a confirm close modal if the flyout is closed after inputs have changed', async () => {
@ -301,7 +304,10 @@ describe('rule_add', () => {
wrapper.update();
});
expect(onClose).toHaveBeenCalledWith(RuleFlyoutCloseReason.SAVED);
expect(onClose).toHaveBeenCalledWith(RuleFlyoutCloseReason.SAVED, {
test: 'some value',
fields: ['test'],
});
});
it('should enforce any default interval', async () => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useReducer, useMemo, useState, useEffect } from 'react';
import React, { useReducer, useMemo, useState, useEffect, useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiTitle, EuiFlyoutHeader, EuiFlyout, EuiFlyoutBody, EuiPortal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -47,11 +47,13 @@ const RuleAdd = ({
initialValues,
reloadRules,
onSave,
metadata,
metadata: initialMetadata,
filteredRuleTypes,
...props
}: RuleAddProps) => {
const onSaveHandler = onSave ?? reloadRules;
const [metadata, setMetadata] = useState(initialMetadata);
const onChangeMetaData = useCallback((newMetadata) => setMetadata(newMetadata), []);
const initialRule: InitialRule = useMemo(() => {
return {
@ -177,7 +179,7 @@ const RuleAdd = ({
) {
setIsConfirmRuleCloseModalOpen(true);
} else {
onClose(RuleFlyoutCloseReason.CANCELED);
onClose(RuleFlyoutCloseReason.CANCELED, metadata);
}
};
@ -185,9 +187,9 @@ const RuleAdd = ({
const savedRule = await onSaveRule();
setIsSaving(false);
if (savedRule) {
onClose(RuleFlyoutCloseReason.SAVED);
onClose(RuleFlyoutCloseReason.SAVED, metadata);
if (onSaveHandler) {
onSaveHandler();
onSaveHandler(metadata);
}
}
};
@ -263,6 +265,7 @@ const RuleAdd = ({
ruleTypeRegistry={ruleTypeRegistry}
metadata={metadata}
filteredRuleTypes={filteredRuleTypes}
onChangeMetaData={onChangeMetaData}
/>
</EuiFlyoutBody>
<RuleAddFooter
@ -308,7 +311,7 @@ const RuleAdd = ({
<ConfirmRuleClose
onConfirm={() => {
setIsConfirmRuleCloseModalOpen(false);
onClose(RuleFlyoutCloseReason.CANCELED);
onClose(RuleFlyoutCloseReason.CANCELED, metadata);
}}
onCancel={() => {
setIsConfirmRuleCloseModalOpen(false);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useReducer, useState, useEffect } from 'react';
import React, { useReducer, useState, useEffect, useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiTitle,
@ -52,7 +52,7 @@ export const RuleEdit = ({
onSave,
ruleTypeRegistry,
actionTypeRegistry,
metadata,
metadata: initialMetadata,
...props
}: RuleEditProps) => {
const onSaveHandler = onSave ?? reloadRules;
@ -71,6 +71,9 @@ export const RuleEdit = ({
);
const [config, setConfig] = useState<TriggersActionsUiConfig>({ isUsingSecurity: false });
const [metadata, setMetadata] = useState(initialMetadata);
const onChangeMetaData = useCallback((newMetadata) => setMetadata(newMetadata), []);
const {
http,
notifications: { toasts },
@ -119,7 +122,7 @@ export const RuleEdit = ({
if (hasRuleChanged(rule, initialRule, true)) {
setIsConfirmRuleCloseModalOpen(true);
} else {
onClose(RuleFlyoutCloseReason.CANCELED);
onClose(RuleFlyoutCloseReason.CANCELED, metadata);
}
};
@ -140,9 +143,9 @@ export const RuleEdit = ({
},
})
);
onClose(RuleFlyoutCloseReason.SAVED);
onClose(RuleFlyoutCloseReason.SAVED, metadata);
if (onSaveHandler) {
onSaveHandler();
onSaveHandler(metadata);
}
} else {
setRule(
@ -214,6 +217,7 @@ export const RuleEdit = ({
}
)}
metadata={metadata}
onChangeMetaData={onChangeMetaData}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
@ -280,7 +284,7 @@ export const RuleEdit = ({
<ConfirmRuleClose
onConfirm={() => {
setIsConfirmRuleCloseModalOpen(false);
onClose(RuleFlyoutCloseReason.CANCELED);
onClose(RuleFlyoutCloseReason.CANCELED, metadata);
}}
onCancel={() => {
setIsConfirmRuleCloseModalOpen(false);

View file

@ -211,6 +211,7 @@ describe('rule_form', () => {
operation="create"
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
onChangeMetaData={jest.fn()}
/>
);
@ -334,6 +335,7 @@ describe('rule_form', () => {
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
connectorFeatureId={featureId}
onChangeMetaData={jest.fn()}
/>
);
@ -578,6 +580,7 @@ describe('rule_form', () => {
operation="create"
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
onChangeMetaData={jest.fn()}
/>
);
@ -644,6 +647,7 @@ describe('rule_form', () => {
operation="create"
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
onChangeMetaData={jest.fn()}
/>
);

View file

@ -99,6 +99,7 @@ interface RuleFormProps<MetaData = Record<string, any>> {
metadata?: MetaData;
filteredRuleTypes?: string[];
connectorFeatureId?: string;
onChangeMetaData: (metadata: MetaData) => void;
}
export const RuleForm = ({
@ -115,6 +116,7 @@ export const RuleForm = ({
metadata,
filteredRuleTypes: ruleTypeToFilter,
connectorFeatureId = AlertingConnectorFeatureId,
onChangeMetaData,
}: RuleFormProps) => {
const {
notifications: { toasts },
@ -522,6 +524,7 @@ export const RuleForm = ({
data={data}
dataViews={dataViews}
unifiedSearch={unifiedSearch}
onChangeMetaData={onChangeMetaData}
/>
</Suspense>
</EuiErrorBoundary>

View file

@ -322,6 +322,7 @@ export interface RuleTypeParamsExpressionProps<
key: Prop,
value: SanitizedRule<Params>[Prop] | null
) => void;
onChangeMetaData: (metadata: MetaData) => void;
errors: IErrorObject;
defaultActionGroupId: string;
actionGroups: Array<ActionGroup<ActionGroupIds>>;
@ -359,10 +360,10 @@ export interface RuleEditProps<MetaData = Record<string, any>> {
initialRule: Rule;
ruleTypeRegistry: RuleTypeRegistryContract;
actionTypeRegistry: ActionTypeRegistryContract;
onClose: (reason: RuleFlyoutCloseReason) => void;
onClose: (reason: RuleFlyoutCloseReason, metadata?: MetaData) => void;
/** @deprecated use `onSave` as a callback after an alert is saved*/
reloadRules?: () => Promise<void>;
onSave?: () => Promise<void>;
onSave?: (metadata?: MetaData) => Promise<void>;
metadata?: MetaData;
ruleType?: RuleType<string, string>;
}
@ -371,13 +372,13 @@ export interface RuleAddProps<MetaData = Record<string, any>> {
consumer: string;
ruleTypeRegistry: RuleTypeRegistryContract;
actionTypeRegistry: ActionTypeRegistryContract;
onClose: (reason: RuleFlyoutCloseReason) => void;
onClose: (reason: RuleFlyoutCloseReason, metadata?: MetaData) => void;
ruleTypeId?: string;
canChangeTrigger?: boolean;
initialValues?: Partial<Rule>;
/** @deprecated use `onSave` as a callback after an alert is saved*/
reloadRules?: () => Promise<void>;
onSave?: () => Promise<void>;
onSave?: (metadata?: MetaData) => Promise<void>;
metadata?: MetaData;
ruleTypeIndex?: RuleTypeIndex;
filteredRuleTypes?: string[];

View file

@ -7,7 +7,6 @@
import expect from '@kbn/expect';
import { asyncForEach } from '@kbn/std';
import { last } from 'lodash';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
@ -32,12 +31,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const security = getService('security');
const filterBar = getService('filterBar');
const find = getService('find');
const toasts = getService('toasts');
const SOURCE_DATA_INDEX = 'search-source-alert';
const OUTPUT_DATA_INDEX = 'search-source-alert-output';
const ACTION_TYPE_ID = '.index';
const RULE_NAME = 'test-search-source-alert';
let sourceDataViewId: string;
let sourceAdHocDataViewId: string;
let outputDataViewId: string;
let connectorId: string;
@ -115,6 +116,22 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
.expect(200);
};
const deleteDataView = async (dataViewId: string) => {
return await supertest
.delete(`/api/data_views/data_view/${dataViewId}`)
.set('kbn-xsrf', 'foo')
.expect(200);
};
const deleteIndexes = (indexes: string[]) => {
indexes.forEach((current) => {
es.transport.request({
path: `/${current}`,
method: 'DELETE',
});
});
};
const createConnector = async (): Promise<string> => {
const { body: createdAction } = await supertest
.post(`/api/actions/connector`)
@ -133,16 +150,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const deleteConnector = (id: string) =>
supertest.delete(`/api/actions/connector/${id}`).set('kbn-xsrf', 'foo').expect(204, '');
const deleteDataViews = (dataViews: string[]) =>
asyncForEach(
dataViews,
async (dataView: string) =>
await supertest
.delete(`/api/data_views/data_view/${dataView}`)
.set('kbn-xsrf', 'foo')
.expect(200)
);
const defineSearchSourceAlert = async (alertName: string) => {
await testSubjects.click('discoverAlertsButton');
await testSubjects.click('discoverCreateAlertButton');
@ -168,41 +175,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await testSubjects.click('saveRuleButton');
};
const getLastToast = async () => {
const toastList = await testSubjects.find('globalToastList');
const titles = await toastList.findAllByTestSubject('euiToastHeader');
const lastTitleElement = last(titles)!;
const title = await lastTitleElement.getVisibleText();
const messages = await toastList.findAllByTestSubject('euiToastBody');
const lastMessageElement = last(messages)!;
const message = await lastMessageElement.getVisibleText();
return { message, title };
};
const getErrorToastTitle = async () => {
const toastList = await testSubjects.find('globalToastList');
const title = await (
await toastList.findByCssSelector(
'[class*="euiToast-danger"] > [data-test-subj="euiToastHeader"]'
)
).getVisibleText();
return title;
};
const openOutputIndex = async () => {
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.selectIndexPattern(OUTPUT_DATA_INDEX);
const [{ id: alertId }] = await getAlertsByName(RULE_NAME);
await queryBar.setQuery(`alert_id:${alertId}`);
await retry.waitFor('document explorer contains alert', async () => {
await queryBar.submitQuery();
await PageObjects.discover.waitUntilSearchingHasFinished();
return (await dataGrid.getDocCount()) > 0;
});
};
const getResultsLink = async () => {
// getting the link
await dataGrid.clickRowToggle();
@ -214,24 +186,32 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
return link;
};
const navigateToDiscover = async (link: string) => {
// following ling provided by alert to see documents triggered the alert
const openAlertResults = async (ruleName: string, dataViewId?: string) => {
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.clickNewSearchButton(); // reset params
await PageObjects.discover.selectIndexPattern(OUTPUT_DATA_INDEX);
const [{ id: alertId }] = await getAlertsByName(ruleName);
await filterBar.addFilter('alert_id', 'is', alertId);
await PageObjects.discover.waitUntilSearchingHasFinished();
const link = await getResultsLink();
await filterBar.removeFilter('alert_id'); // clear filter bar
// follow url provided by alert to see documents triggered the alert
const baseUrl = deployment.getHostPort();
await browser.navigateTo(baseUrl + link);
await PageObjects.discover.waitUntilSearchingHasFinished();
await retry.waitFor('navigate to discover', async () => {
const currentUrl = await browser.getCurrentUrl();
return currentUrl.includes(sourceDataViewId);
const currentDataViewId = await PageObjects.discover.getCurrentDataViewId();
return dataViewId ? currentDataViewId === dataViewId : true;
});
};
const navigateToResults = async () => {
const link = await getResultsLink();
await navigateToDiscover(link);
};
const openAlertRuleInManagement = async () => {
const openAlertRuleInManagement = async (ruleName: string) => {
await PageObjects.common.navigateToApp('management');
await PageObjects.header.waitUntilLoadingHasFinished();
@ -239,7 +219,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.header.waitUntilLoadingHasFinished();
const rulesList = await testSubjects.find('rulesList');
const alertRule = await rulesList.findByCssSelector('[title="test-search-source-alert"]');
const alertRule = await rulesList.findByCssSelector(`[title="${ruleName}"]`);
await alertRule.click();
await PageObjects.header.waitUntilLoadingHasFinished();
};
@ -269,18 +249,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
after(async () => {
es.transport.request({
path: `/${OUTPUT_DATA_INDEX}`,
method: 'DELETE',
});
await deleteDataViews([sourceDataViewId, outputDataViewId]);
deleteIndexes([OUTPUT_DATA_INDEX, SOURCE_DATA_INDEX]);
await deleteDataView(outputDataViewId);
await deleteConnector(connectorId);
const alertsToDelete = await getAlertsByName(RULE_NAME);
const alertsToDelete = await getAlertsByName('test');
await deleteAlerts(alertsToDelete.map((alertItem: { id: string }) => alertItem.id));
await security.testUser.restoreDefaults();
});
it('should navigate to discover via view in app link', async () => {
it('should navigate to alert results via view in app link', async () => {
await PageObjects.common.navigateToApp('discover');
await PageObjects.discover.waitUntilSearchingHasFinished();
await PageObjects.discover.selectIndexPattern(SOURCE_DATA_INDEX);
@ -290,33 +267,35 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await defineSearchSourceAlert(RULE_NAME);
await PageObjects.header.waitUntilLoadingHasFinished();
await openAlertRuleInManagement();
await openAlertRuleInManagement(RULE_NAME);
await testSubjects.click('ruleDetails-viewInApp');
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.waitFor('navigate to discover', async () => {
const currentUrl = await browser.getCurrentUrl();
return currentUrl.includes(sourceDataViewId);
const currentDataViewId = await PageObjects.discover.getCurrentDataViewId();
return sourceDataViewId ? currentDataViewId === sourceDataViewId : true;
});
expect(await dataGrid.getDocCount()).to.be(5);
});
it('should open documents triggered the alert', async () => {
await openOutputIndex();
await navigateToResults();
it('should navigate to alert results via link provided in notification', async () => {
await openAlertResults(RULE_NAME, sourceDataViewId);
const { message, title } = await getLastToast();
expect(await dataGrid.getDocCount()).to.be(5);
expect(title).to.be.equal('Displayed documents may vary');
expect(message).to.be.equal(
'The displayed documents might differ from the documents that triggered the alert. Some documents might have been added or deleted.'
expect(await toasts.getToastCount()).to.be.equal(1);
const content = await toasts.getToastContent(1);
expect(content).to.equal(
`Displayed documents may vary\nThe displayed documents might differ from the documents that triggered the alert. Some documents might have been added or deleted.`
);
const selectedDataView = await PageObjects.discover.getCurrentlySelectedDataView();
expect(selectedDataView).to.be.equal('search-source-alert');
expect(await dataGrid.getDocCount()).to.be(5);
});
it('should display warning about updated alert rule', async () => {
await openAlertRuleInManagement();
await openAlertRuleInManagement(RULE_NAME);
// change rule configuration
await testSubjects.click('openEditRuleFlyoutButton');
@ -328,21 +307,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await testSubjects.click('saveEditedRuleButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await openOutputIndex();
await navigateToResults();
await openAlertResults(RULE_NAME, sourceDataViewId);
const { message, title } = await getLastToast();
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.'
expect(await toasts.getToastCount()).to.be.equal(1);
const content = await toasts.getToastContent(1);
expect(content).to.equal(
`Alert rule has changed\nThe displayed documents might not match the documents that triggered the alert because the rule configuration changed.`
);
expect(await dataGrid.getDocCount()).to.be(1);
});
it('should display warning about recently updated data view', async () => {
@ -356,45 +334,82 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await testSubjects.click('tab-sourceFilters');
await testSubjects.click('fieldFilterInput');
await PageObjects.common.sleep(15000);
const input = await find.activeElement();
await input.type('message');
await testSubjects.click('addFieldFilterButton');
await openOutputIndex();
await navigateToResults();
await openAlertResults(RULE_NAME, sourceDataViewId);
await openOutputIndex();
await navigateToResults();
const { message, title } = await getLastToast();
expect(await toasts.getToastCount()).to.be(2);
const firstContent = await toasts.getToastContent(1);
expect(firstContent).to.equal(
`Data View has changed\nData view has been updated after the last update of the alert rule.`
);
const secondContent = await toasts.getToastContent(2);
expect(secondContent).to.equal(
`Alert rule has changed\nThe displayed documents might not match the documents that triggered the alert because the rule configuration changed.`
);
expect(await dataGrid.getDocCount()).to.be(1);
expect(title).to.be.equal('Data View has changed');
expect(message).to.be.equal(
'Data view has been updated after the last update of the alert rule.'
);
});
it('should display not found index error', async () => {
await openOutputIndex();
const link = await getResultsLink();
await navigateToDiscover(link);
await PageObjects.discover.selectIndexPattern(OUTPUT_DATA_INDEX);
await es.transport.request({
path: `/${SOURCE_DATA_INDEX}`,
method: 'DELETE',
});
await browser.refresh();
await deleteDataView(sourceDataViewId);
await navigateToDiscover(link);
// rty to open alert results after index deletion
await openAlertResults(RULE_NAME);
const title = await getErrorToastTitle();
expect(title).to.be.equal(
'No matching indices found: No indices match "search-source-alert"'
expect(await toasts.getToastCount()).to.be(1);
const firstContent = await toasts.getToastContent(1);
expect(firstContent).to.equal(
`Error fetching search source\nCould not locate that data view (id: ${sourceDataViewId}), click here to re-create it`
);
});
it('should navigate to alert results via view in app link using adhoc data view', async () => {
await PageObjects.discover.createAdHocDataView('search-source-', true);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.timePicker.setCommonlyUsedTime('Last_15 minutes');
await PageObjects.discover.addRuntimeField('runtime-message-field', `emit('mock-message')`);
// create an alert
await defineSearchSourceAlert('test-adhoc-alert');
await PageObjects.header.waitUntilLoadingHasFinished();
sourceAdHocDataViewId = await PageObjects.discover.getCurrentDataViewId();
// navigate to discover using view in app link
await openAlertRuleInManagement('test-adhoc-alert');
await testSubjects.click('ruleDetails-viewInApp');
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.waitFor('navigate to discover', async () => {
const currentDataViewId = await PageObjects.discover.getCurrentDataViewId();
return currentDataViewId === sourceAdHocDataViewId;
});
const selectedDataView = await PageObjects.discover.getCurrentlySelectedDataView();
expect(selectedDataView).to.be.equal('search-source-*');
const documentCell = await dataGrid.getCellElement(0, 3);
const firstRowContent = await documentCell.getVisibleText();
expect(firstRowContent.includes('runtime-message-fieldmock-message_id')).to.be.equal(true);
});
it('should navigate to alert results via link provided in notification using adhoc data view', async () => {
await openAlertResults('test-adhoc-alert', sourceAdHocDataViewId);
expect(await toasts.getToastCount()).to.be.equal(1);
const content = await toasts.getToastContent(1);
expect(content).to.equal(
`Displayed documents may vary\nThe displayed documents might differ from the documents that triggered the alert. Some documents might have been added or deleted.`
);
expect(await dataGrid.getDocCount()).to.be(5);
const selectedDataView = await PageObjects.discover.getCurrentlySelectedDataView();
expect(selectedDataView).to.be.equal('search-source-*');
});
});
}