Skip to content

Commit

Permalink
Alert list frontend pagination (#57142)
Browse files Browse the repository at this point in the history
  • Loading branch information
dplumlee authored Feb 18, 2020
1 parent 5c2e6d9 commit f6dc674
Show file tree
Hide file tree
Showing 15 changed files with 469 additions and 105 deletions.
55 changes: 33 additions & 22 deletions x-pack/plugins/endpoint/public/applications/endpoint/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,22 @@ import * as React from 'react';
import ReactDOM from 'react-dom';
import { CoreStart, AppMountParameters } from 'kibana/public';
import { I18nProvider, FormattedMessage } from '@kbn/i18n/react';
import { Route, BrowserRouter, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import { Route, Switch, BrowserRouter, useLocation } from 'react-router-dom';
import { Provider, useDispatch } from 'react-redux';
import { Store } from 'redux';
import { memo } from 'react';
import { appStoreFactory } from './store';
import { AlertIndex } from './view/alerts';
import { ManagementList } from './view/managing';
import { PolicyList } from './view/policy';
import { AppAction } from './store/action';
import { EndpointAppLocation } from './types';

/**
* 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) {
coreStart.http.get('/api/endpoint/hello-world');

const store = appStoreFactory(coreStart);

ReactDOM.render(<AppRoot basename={appBasePath} store={store} />, element);
Expand All @@ -31,6 +33,13 @@ export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMou
};
}

const RouteCapture = memo(({ children }) => {
const location: EndpointAppLocation = useLocation();
const dispatch: (action: AppAction) => unknown = useDispatch();
dispatch({ type: 'userChangedUrl', payload: location });
return <>{children}</>;
});

interface RouterProps {
basename: string;
store: Store;
Expand All @@ -40,25 +49,27 @@ const AppRoot: React.FunctionComponent<RouterProps> = React.memo(({ basename, st
<Provider store={store}>
<I18nProvider>
<BrowserRouter basename={basename}>
<Switch>
<Route
exact
path="/"
render={() => (
<h1 data-test-subj="welcomeTitle">
<FormattedMessage id="xpack.endpoint.welcomeTitle" defaultMessage="Hello World" />
</h1>
)}
/>
<Route path="/management" component={ManagementList} />
<Route path="/alerts" component={AlertIndex} />
<Route path="/policy" exact component={PolicyList} />
<Route
render={() => (
<FormattedMessage id="xpack.endpoint.notFound" defaultMessage="Page Not Found" />
)}
/>
</Switch>
<RouteCapture>
<Switch>
<Route
exact
path="/"
render={() => (
<h1 data-test-subj="welcomeTitle">
<FormattedMessage id="xpack.endpoint.welcomeTitle" defaultMessage="Hello World" />
</h1>
)}
/>
<Route path="/management" component={ManagementList} />
<Route path="/alerts" render={() => <AlertIndex />} />
<Route path="/policy" exact component={PolicyList} />
<Route
render={() => (
<FormattedMessage id="xpack.endpoint.notFound" defaultMessage="Page Not Found" />
)}
/>
</Switch>
</RouteCapture>
</BrowserRouter>
</I18nProvider>
</Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { Immutable } from '../../../../../common/types';
import { AlertListData } from '../../types';

interface ServerReturnedAlertsData {
type: 'serverReturnedAlertsData';
payload: AlertListData;
readonly type: 'serverReturnedAlertsData';
readonly payload: Immutable<AlertListData>;
}

export type AlertAction = ServerReturnedAlertsData;
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* 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 { Store, createStore, applyMiddleware } from 'redux';
import { History } from 'history';
import { alertListReducer } from './reducer';
import { AlertListState } from '../../types';
import { alertMiddlewareFactory } from './middleware';
import { AppAction } from '../action';
import { coreMock } from 'src/core/public/mocks';
import { AlertResultList } from '../../../../../common/types';
import { isOnAlertPage } from './selectors';
import { createBrowserHistory } from 'history';

describe('alert list tests', () => {
let store: Store<AlertListState, AppAction>;
let coreStart: ReturnType<typeof coreMock.createStart>;
let history: History<never>;
beforeEach(() => {
coreStart = coreMock.createStart();
history = createBrowserHistory();
const middleware = alertMiddlewareFactory(coreStart);
store = createStore(alertListReducer, applyMiddleware(middleware));
});
describe('when the user navigates to the alert list page', () => {
beforeEach(() => {
coreStart.http.get.mockImplementation(async () => {
const response: AlertResultList = {
alerts: [
{
'@timestamp': new Date(1542341895000),
agent: {
id: 'ced9c68e-b94a-4d66-bb4c-6106514f0a2f',
version: '3.0.0',
},
event: {
action: 'open',
},
file_classification: {
malware_classification: {
score: 3,
},
},
host: {
hostname: 'HD-c15-bc09190a',
ip: '10.179.244.14',
os: {
name: 'Windows',
},
},
thread: {},
},
],
total: 1,
request_page_size: 10,
request_page_index: 0,
result_from_index: 0,
};
return response;
});

// Simulates user navigating to the /alerts page
store.dispatch({
type: 'userChangedUrl',
payload: {
...history.location,
pathname: '/alerts',
},
});
});

it("should recognize it's on the alert list page", () => {
const actual = isOnAlertPage(store.getState());
expect(actual).toBe(true);
});

it('should return alertListData', () => {
const actualResponseLength = store.getState().alerts.length;
expect(actualResponseLength).toEqual(1);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* 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 { Store, createStore, applyMiddleware } from 'redux';
import { History } from 'history';
import { alertListReducer } from './reducer';
import { AlertListState } from '../../types';
import { alertMiddlewareFactory } from './middleware';
import { AppAction } from '../action';
import { coreMock } from 'src/core/public/mocks';
import { createBrowserHistory } from 'history';
import {
urlFromNewPageSizeParam,
paginationDataFromUrl,
urlFromNewPageIndexParam,
} from './selectors';

describe('alert list pagination', () => {
let store: Store<AlertListState, AppAction>;
let coreStart: ReturnType<typeof coreMock.createStart>;
let history: History<never>;
beforeEach(() => {
coreStart = coreMock.createStart();
history = createBrowserHistory();
const middleware = alertMiddlewareFactory(coreStart);
store = createStore(alertListReducer, applyMiddleware(middleware));
});
describe('when the user navigates to the alert list page', () => {
describe('when a new page size is passed', () => {
beforeEach(() => {
const urlPageSizeSelector = urlFromNewPageSizeParam(store.getState());
history.push(urlPageSizeSelector(1));
store.dispatch({ type: 'userChangedUrl', payload: history.location });
});
it('should modify the url correctly', () => {
const actualPaginationQuery = paginationDataFromUrl(store.getState());
expect(actualPaginationQuery).toMatchInlineSnapshot(`
Object {
"page_size": "1",
}
`);
});

describe('and then a new page index is passed', () => {
beforeEach(() => {
const urlPageIndexSelector = urlFromNewPageIndexParam(store.getState());
history.push(urlPageIndexSelector(1));
store.dispatch({ type: 'userChangedUrl', payload: history.location });
});
it('should modify the url in the correct order', () => {
const actualPaginationQuery = paginationDataFromUrl(store.getState());
expect(actualPaginationQuery).toMatchInlineSnapshot(`
Object {
"page_index": "1",
"page_size": "1",
}
`);
});
});
});

describe('when a new page index is passed', () => {
beforeEach(() => {
const urlPageIndexSelector = urlFromNewPageIndexParam(store.getState());
history.push(urlPageIndexSelector(1));
store.dispatch({ type: 'userChangedUrl', payload: history.location });
});
it('should modify the url correctly', () => {
const actualPaginationQuery = paginationDataFromUrl(store.getState());
expect(actualPaginationQuery).toMatchInlineSnapshot(`
Object {
"page_index": "1",
}
`);
});

describe('and then a new page size is passed', () => {
beforeEach(() => {
const urlPageSizeSelector = urlFromNewPageSizeParam(store.getState());
history.push(urlPageSizeSelector(1));
store.dispatch({ type: 'userChangedUrl', payload: history.location });
});
it('should modify the url correctly and reset index to `0`', () => {
const actualPaginationQuery = paginationDataFromUrl(store.getState());
expect(actualPaginationQuery).toMatchInlineSnapshot(`
Object {
"page_index": "0",
"page_size": "1",
}
`);
});
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { parse } from 'query-string';
import { HttpFetchQuery } from 'src/core/public';
import { HttpFetchQuery } from 'kibana/public';
import { AlertResultList } from '../../../../../common/types';
import { AppAction } from '../action';
import { MiddlewareFactory, AlertListData } from '../../types';

export const alertMiddlewareFactory: MiddlewareFactory = coreStart => {
const qp = parse(window.location.search.slice(1), { sort: false });
import { MiddlewareFactory, AlertListState } from '../../types';
import { isOnAlertPage, paginationDataFromUrl } from './selectors';

export const alertMiddlewareFactory: MiddlewareFactory<AlertListState> = coreStart => {
return api => next => async (action: AppAction) => {
next(action);
if (action.type === 'userNavigatedToPage' && action.payload === 'alertsPage') {
const response: AlertListData = await coreStart.http.get('/api/endpoint/alerts', {
query: qp as HttpFetchQuery,
const state = api.getState();
if (action.type === 'userChangedUrl' && isOnAlertPage(state)) {
const response: AlertResultList = await coreStart.http.get(`/api/endpoint/alerts`, {
query: paginationDataFromUrl(state) as HttpFetchQuery,
});
api.dispatch({ type: 'serverReturnedAlertsData', payload: response });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const initialState = (): AlertListState => {
request_page_index: 0,
result_from_index: 0,
total: 0,
location: undefined,
};
};

Expand All @@ -27,6 +28,11 @@ export const alertListReducer: Reducer<AlertListState, AppAction> = (
...state,
...action.payload,
};
} else if (action.type === 'userChangedUrl') {
return {
...state,
location: action.payload,
};
}

return state;
Expand Down
Loading

0 comments on commit f6dc674

Please sign in to comment.