Skip to content

Commit

Permalink
[Endpoint] ERT-82 Alerts search bar (#59702) (#60288)
Browse files Browse the repository at this point in the history
* Add SearchBar UI. Query, filter, and dateRange work

* Sync url with search bar, fix excluded filters on BE

* fix welcome title

* Use KibanaReactContext, fix indexPattern fetch

* Mock data plugin, api and ui tests pass

* Add view and functional tests

* use observables to handle filter updates

* address comments
  • Loading branch information
peluja1012 authored Mar 16, 2020
1 parent 0500350 commit 541a9cf
Show file tree
Hide file tree
Showing 29 changed files with 526 additions and 107 deletions.
7 changes: 3 additions & 4 deletions x-pack/plugins/endpoint/common/schema/alert_index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import { schema, Type } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { decode } from 'rison-node';
import { fromKueryExpression } from '../../../../../src/plugins/data/common';
import { EndpointAppConstants } from '../types';

/**
Expand Down Expand Up @@ -44,10 +43,10 @@ export const alertingIndexGetQuerySchema = schema.object(
schema.string({
validate(value) {
try {
fromKueryExpression(value);
decode(value);
} catch (err) {
return i18n.translate('xpack.endpoint.alerts.errors.bad_kql', {
defaultMessage: 'must be valid KQL',
return i18n.translate('xpack.endpoint.alerts.errors.bad_rison', {
defaultMessage: 'must be a valid rison-encoded string',
});
}
},
Expand Down
2 changes: 1 addition & 1 deletion x-pack/plugins/endpoint/kibana.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "1.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "endpoint"],
"requiredPlugins": ["features", "embeddable"],
"requiredPlugins": ["features", "embeddable", "data", "dataEnhanced"],
"server": true,
"ui": true
}
19 changes: 14 additions & 5 deletions x-pack/plugins/endpoint/public/applications/endpoint/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Provider } from 'react-redux';
import { Store } from 'redux';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
import { RouteCapture } from './view/route_capture';
import { EndpointPluginStartDependencies } from '../../plugin';
import { appStoreFactory } from './store';
import { AlertIndex } from './view/alerts';
import { ManagementList } from './view/managing';
Expand All @@ -22,10 +23,17 @@ import { HeaderNavigation } from './components/header_nav';
/**
* This module will be loaded asynchronously to reduce the bundle size of your plugin's main bundle.
*/
export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMountParameters) {
export function renderApp(
coreStart: CoreStart,
depsStart: EndpointPluginStartDependencies,
{ appBasePath, element }: AppMountParameters
) {
coreStart.http.get('/api/endpoint/hello-world');
const store = appStoreFactory(coreStart);
ReactDOM.render(<AppRoot basename={appBasePath} store={store} coreStart={coreStart} />, element);
const store = appStoreFactory({ coreStart, depsStart });
ReactDOM.render(
<AppRoot basename={appBasePath} store={store} coreStart={coreStart} depsStart={depsStart} />,
element
);
return () => {
ReactDOM.unmountComponentAtNode(element);
};
Expand All @@ -35,13 +43,14 @@ interface RouterProps {
basename: string;
store: Store;
coreStart: CoreStart;
depsStart: EndpointPluginStartDependencies;
}

const AppRoot: React.FunctionComponent<RouterProps> = React.memo(
({ basename, store, coreStart: { http, notifications } }) => (
({ basename, store, coreStart: { http, notifications }, depsStart: { data } }) => (
<Provider store={store}>
<I18nProvider>
<KibanaContextProvider services={{ http, notifications }}>
<KibanaContextProvider services={{ http, notifications, data }}>
<BrowserRouter basename={basename}>
<RouteCapture>
<HeaderNavigation basename={basename} />
Expand Down
58 changes: 58 additions & 0 deletions x-pack/plugins/endpoint/public/applications/endpoint/mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import {
dataPluginMock,
Start as DataPublicStartMock,
} from '../../../../../../src/plugins/data/public/mocks';

type DataMock = Omit<DataPublicStartMock, 'indexPatterns' | 'query'> & {
indexPatterns: Omit<DataPublicStartMock['indexPatterns'], 'getFieldsForWildcard'> & {
getFieldsForWildcard: jest.Mock;
};
// We can't Omit (override) 'query' here because FilterManager is a class not an interface.
// Because of this, wherever FilterManager is used tsc expects some FilterManager private fields
// like filters, updated$, fetch$ to be part of the type. Omit removes these private fields when used.
query: DataPublicStartMock['query'] & {
filterManager: {
setFilters: jest.Mock;
getUpdates$: jest.Mock;
};
};
ui: DataPublicStartMock['ui'] & {
SearchBar: jest.Mock;
};
};

/**
* Type for our app's depsStart (plugin start dependencies)
*/
export interface DepsStartMock {
data: DataMock;
}

/**
* Returns a mock of our app's depsStart (plugin start dependencies)
*/
export const depsStartMock: () => DepsStartMock = () => {
const dataMock: DataMock = (dataPluginMock.createStartContract() as unknown) as DataMock;
dataMock.indexPatterns.getFieldsForWildcard = jest.fn();
dataMock.query.filterManager.setFilters = jest.fn();
dataMock.query.filterManager.getUpdates$ = jest.fn(() => {
return {
subscribe: jest.fn(() => {
return {
unsubscribe: jest.fn(),
};
}),
};
}) as DataMock['query']['filterManager']['getUpdates$'];
dataMock.ui.SearchBar = jest.fn();

return {
data: dataMock,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { IIndexPattern } from 'src/plugins/data/public';
import { Immutable, AlertData } from '../../../../../common/types';
import { AlertListData } from '../../types';

Expand All @@ -17,4 +18,12 @@ interface ServerReturnedAlertDetailsData {
readonly payload: Immutable<AlertData>;
}

export type AlertAction = ServerReturnedAlertsData | ServerReturnedAlertDetailsData;
interface ServerReturnedSearchBarIndexPatterns {
type: 'serverReturnedSearchBarIndexPatterns';
payload: IIndexPattern[];
}

export type AlertAction =
| ServerReturnedAlertsData
| ServerReturnedAlertDetailsData
| ServerReturnedSearchBarIndexPatterns;
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,24 @@ import { AlertListState } from '../../types';
import { alertMiddlewareFactory } from './middleware';
import { AppAction } from '../action';
import { coreMock } from 'src/core/public/mocks';
import { DepsStartMock, depsStartMock } from '../../mocks';
import { createBrowserHistory } from 'history';
import { mockAlertResultList } from './mock_alert_result_list';

describe('alert details tests', () => {
let store: Store<AlertListState, AppAction>;
let coreStart: ReturnType<typeof coreMock.createStart>;
let depsStart: DepsStartMock;
let history: History<never>;
/**
* A function that waits until a selector returns true.
*/
let selectorIsTrue: (selector: (state: AlertListState) => boolean) => Promise<void>;
beforeEach(() => {
coreStart = coreMock.createStart();
depsStart = depsStartMock();
history = createBrowserHistory();
const middleware = alertMiddlewareFactory(coreStart);
const middleware = alertMiddlewareFactory(coreStart, depsStart);
store = createStore(alertListReducer, applyMiddleware(middleware));

selectorIsTrue = async selector => {
Expand All @@ -42,9 +46,9 @@ describe('alert details tests', () => {
});
describe('when the user is on the alert list page with a selected alert in the url', () => {
beforeEach(() => {
const firstResponse: Promise<unknown> = Promise.resolve(1);
const secondResponse: Promise<unknown> = Promise.resolve(2);
coreStart.http.get.mockReturnValueOnce(firstResponse).mockReturnValueOnce(secondResponse);
const firstResponse: Promise<unknown> = Promise.resolve(mockAlertResultList());
coreStart.http.get.mockReturnValue(firstResponse);
depsStart.data.indexPatterns.getFieldsForWildcard.mockReturnValue(Promise.resolve([]));

// Simulates user navigating to the /alerts page
store.dispatch({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AlertListState } from '../../types';
import { alertMiddlewareFactory } from './middleware';
import { AppAction } from '../action';
import { coreMock } from 'src/core/public/mocks';
import { DepsStartMock, depsStartMock } from '../../mocks';
import { AlertResultList } from '../../../../../common/types';
import { isOnAlertPage } from './selectors';
import { createBrowserHistory } from 'history';
Expand All @@ -19,19 +20,39 @@ import { mockAlertResultList } from './mock_alert_result_list';
describe('alert list tests', () => {
let store: Store<AlertListState, AppAction>;
let coreStart: ReturnType<typeof coreMock.createStart>;
let depsStart: DepsStartMock;
let history: History<never>;
/**
* A function that waits until a selector returns true.
*/
let selectorIsTrue: (selector: (state: AlertListState) => boolean) => Promise<void>;
beforeEach(() => {
coreStart = coreMock.createStart();
depsStart = depsStartMock();
history = createBrowserHistory();
const middleware = alertMiddlewareFactory(coreStart);
const middleware = alertMiddlewareFactory(coreStart, depsStart);
store = createStore(alertListReducer, applyMiddleware(middleware));

selectorIsTrue = async selector => {
// If the selector returns true, we're done
while (selector(store.getState()) !== true) {
// otherwise, wait til the next state change occurs
await new Promise(resolve => {
const unsubscribe = store.subscribe(() => {
unsubscribe();
resolve();
});
});
}
};
});
describe('when the user navigates to the alert list page', () => {
beforeEach(() => {
coreStart.http.get.mockImplementation(async () => {
const response: AlertResultList = mockAlertResultList();
return response;
});
depsStart.data.indexPatterns.getFieldsForWildcard.mockReturnValue(Promise.resolve([]));

// Simulates user navigating to the /alerts page
store.dispatch({
Expand All @@ -48,9 +69,8 @@ describe('alert list tests', () => {
expect(actual).toBe(true);
});

it('should return alertListData', () => {
const actualResponseLength = store.getState().alerts.length;
expect(actualResponseLength).toEqual(1);
it('should return alertListData', async () => {
await selectorIsTrue(state => state.alerts.length === 1);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import { AlertListState, AlertingIndexUIQueryParams } from '../../types';
import { alertMiddlewareFactory } from './middleware';
import { AppAction } from '../action';
import { coreMock } from 'src/core/public/mocks';
import { DepsStartMock, depsStartMock } from '../../mocks';
import { createBrowserHistory } from 'history';
import { uiQueryParams } from './selectors';
import { urlFromQueryParams } from '../../view/alerts/url_from_query_params';

describe('alert list pagination', () => {
let store: Store<AlertListState, AppAction>;
let coreStart: ReturnType<typeof coreMock.createStart>;
let depsStart: DepsStartMock;
let history: History<never>;
let queryParams: () => AlertingIndexUIQueryParams;
/**
Expand All @@ -26,9 +28,10 @@ describe('alert list pagination', () => {
let historyPush: (params: AlertingIndexUIQueryParams) => void;
beforeEach(() => {
coreStart = coreMock.createStart();
depsStart = depsStartMock();
history = createBrowserHistory();

const middleware = alertMiddlewareFactory(coreStart);
const middleware = alertMiddlewareFactory(coreStart, depsStart);
store = createStore(alertListReducer, applyMiddleware(middleware));

history.listen(location => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,40 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { IIndexPattern } from 'src/plugins/data/public';
import { AlertResultList, AlertData } from '../../../../../common/types';
import { AppAction } from '../action';
import { MiddlewareFactory, AlertListState } from '../../types';
import { isOnAlertPage, apiQueryParams, hasSelectedAlert, uiQueryParams } from './selectors';
import { cloneHttpFetchQuery } from '../../../../common/clone_http_fetch_query';
import { EndpointAppConstants } from '../../../../../common/types';

export const alertMiddlewareFactory: MiddlewareFactory<AlertListState> = (coreStart, depsStart) => {
async function fetchIndexPatterns(): Promise<IIndexPattern[]> {
const { indexPatterns } = depsStart.data;
const indexName = EndpointAppConstants.ALERT_INDEX_NAME;
const fields = await indexPatterns.getFieldsForWildcard({ pattern: indexName });
const indexPattern: IIndexPattern = {
title: indexName,
fields,
};

return [indexPattern];
}

export const alertMiddlewareFactory: MiddlewareFactory<AlertListState> = coreStart => {
return api => next => async (action: AppAction) => {
next(action);
const state = api.getState();
if (action.type === 'userChangedUrl' && isOnAlertPage(state)) {
const patterns = await fetchIndexPatterns();
api.dispatch({ type: 'serverReturnedSearchBarIndexPatterns', payload: patterns });

const response: AlertResultList = await coreStart.http.get(`/api/endpoint/alerts`, {
query: cloneHttpFetchQuery(apiQueryParams(state)),
});
api.dispatch({ type: 'serverReturnedAlertsData', payload: response });
}

if (action.type === 'userChangedUrl' && isOnAlertPage(state) && hasSelectedAlert(state)) {
const uiParams = uiQueryParams(state);
const response: AlertData = await coreStart.http.get(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const initialState = (): AlertListState => {
pageIndex: 0,
total: 0,
location: undefined,
searchBar: {
patterns: [],
},
};
};

Expand Down Expand Up @@ -49,6 +52,14 @@ export const alertListReducer: Reducer<AlertListState, AppAction> = (
...state,
alertDetails: action.payload,
};
} else if (action.type === 'serverReturnedSearchBarIndexPatterns') {
return {
...state,
searchBar: {
...state.searchBar,
patterns: action.payload,
},
};
}

return state;
Expand Down
Loading

0 comments on commit 541a9cf

Please sign in to comment.