-
Notifications
You must be signed in to change notification settings - Fork 8.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Task/hostlist pagination #63722
Task/hostlist pagination #63722
Changes from all commits
ab8fe71
9b0e3dc
fd95269
2fb8c40
8a5f7a7
f17ecfa
7e86f16
ef20c60
2ebf7b5
3b2d2a3
ff737a7
dbdec58
0f69d0c
58e637e
05f928f
913c428
42e547a
971352a
22eda80
16a1113
58cc53d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
/* | ||
* 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 { CoreStart, HttpSetup } from 'kibana/public'; | ||
import { DepsStartMock, depsStartMock } from '../../mocks'; | ||
import { AppAction, HostState, HostIndexUIQueryParams } from '../../types'; | ||
import { Immutable, HostResultList } from '../../../../../common/types'; | ||
import { History, createBrowserHistory } from 'history'; | ||
import { hostMiddlewareFactory } from './middleware'; | ||
import { applyMiddleware, Store, createStore } from 'redux'; | ||
import { hostListReducer } from './reducer'; | ||
import { coreMock } from 'src/core/public/mocks'; | ||
import { urlFromQueryParams } from '../../view/hosts/url_from_query_params'; | ||
import { uiQueryParams } from './selectors'; | ||
import { mockHostResultList } from './mock_host_result_list'; | ||
import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../test_utils'; | ||
|
||
describe('host list pagination: ', () => { | ||
let fakeCoreStart: jest.Mocked<CoreStart>; | ||
let depsStart: DepsStartMock; | ||
let fakeHttpServices: jest.Mocked<HttpSetup>; | ||
let history: History<never>; | ||
let store: Store<Immutable<HostState>, Immutable<AppAction>>; | ||
let queryParams: () => HostIndexUIQueryParams; | ||
let waitForAction: MiddlewareActionSpyHelper['waitForAction']; | ||
let actionSpyMiddleware; | ||
const getEndpointListApiResponse = (): HostResultList => { | ||
return mockHostResultList({ request_page_size: 1, request_page_index: 1, total: 10 }); | ||
}; | ||
|
||
let historyPush: (params: HostIndexUIQueryParams) => void; | ||
beforeEach(() => { | ||
fakeCoreStart = coreMock.createStart(); | ||
depsStart = depsStartMock(); | ||
fakeHttpServices = fakeCoreStart.http as jest.Mocked<HttpSetup>; | ||
history = createBrowserHistory(); | ||
const middleware = hostMiddlewareFactory(fakeCoreStart, depsStart); | ||
({ actionSpyMiddleware, waitForAction } = createSpyMiddleware<HostState>()); | ||
store = createStore(hostListReducer, applyMiddleware(middleware, actionSpyMiddleware)); | ||
|
||
history.listen(location => { | ||
store.dispatch({ type: 'userChangedUrl', payload: location }); | ||
}); | ||
|
||
queryParams = () => uiQueryParams(store.getState()); | ||
|
||
historyPush = (nextQueryParams: HostIndexUIQueryParams): void => { | ||
return history.push(urlFromQueryParams(nextQueryParams)); | ||
}; | ||
}); | ||
|
||
describe('when the user enteres the host list for the first time', () => { | ||
it('the api is called with page_index and page_size defaulting to 0 and 10 respectively', async () => { | ||
const apiResponse = getEndpointListApiResponse(); | ||
fakeHttpServices.post.mockResolvedValue(apiResponse); | ||
expect(fakeHttpServices.post).not.toHaveBeenCalled(); | ||
|
||
store.dispatch({ | ||
type: 'userChangedUrl', | ||
payload: { | ||
...history.location, | ||
pathname: '/hosts', | ||
}, | ||
}); | ||
await waitForAction('serverReturnedHostList'); | ||
expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/endpoint/metadata', { | ||
body: JSON.stringify({ | ||
paging_properties: [{ page_index: '0' }, { page_size: '10' }], | ||
}), | ||
}); | ||
}); | ||
}); | ||
describe('when a new page size is passed', () => { | ||
it('should modify the url correctly', () => { | ||
historyPush({ ...queryParams(), page_size: '20' }); | ||
expect(queryParams()).toMatchInlineSnapshot(` | ||
Object { | ||
"page_index": "0", | ||
"page_size": "20", | ||
} | ||
`); | ||
}); | ||
}); | ||
describe('when an invalid page size is passed', () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are there any other variations you should be testing? Example: what if multiple Also - might be worth testing that the parameters get correcly passed down to the middleware/API. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I agree with @paul-tavares - @parkiino we should test the following invalid values as well:
If it's too much of a lift, I can also test these examples manually and create another ticket to automate these tests in the future. I will also manually test when page_index is more than what we expect to make sure it results in the same bug I found in the policy list URL pagination |
||
it('should modify the page size in the url to the default page size', () => { | ||
historyPush({ ...queryParams(), page_size: '1' }); | ||
expect(queryParams()).toEqual({ page_index: '0', page_size: '10' }); | ||
}); | ||
}); | ||
|
||
describe('when a negative page size is passed', () => { | ||
it('should modify the page size in the url to the default page size', () => { | ||
historyPush({ ...queryParams(), page_size: '-1' }); | ||
expect(queryParams()).toEqual({ page_index: '0', page_size: '10' }); | ||
}); | ||
}); | ||
|
||
describe('when a new page index is passed', () => { | ||
it('should modify the page index in the url correctly', () => { | ||
historyPush({ ...queryParams(), page_index: '2' }); | ||
expect(queryParams()).toEqual({ page_index: '2', page_size: '10' }); | ||
}); | ||
}); | ||
|
||
describe('when a negative page index is passed', () => { | ||
it('should modify the page index in the url to the default page index', () => { | ||
historyPush({ ...queryParams(), page_index: '-2' }); | ||
expect(queryParams()).toEqual({ page_index: '0', page_size: '10' }); | ||
}); | ||
}); | ||
|
||
describe('when invalid params are passed in the url', () => { | ||
it('ignores non-numeric values for page_index and page_size', () => { | ||
historyPush({ ...queryParams, page_index: 'one', page_size: 'fifty' }); | ||
expect(queryParams()).toEqual({ page_index: '0', page_size: '10' }); | ||
}); | ||
|
||
it('ignores unknown url search params', () => { | ||
store.dispatch({ | ||
type: 'userChangedUrl', | ||
payload: { | ||
...history.location, | ||
pathname: '/hosts', | ||
search: '?foo=bar', | ||
}, | ||
}); | ||
expect(queryParams()).toEqual({ page_index: '0', page_size: '10' }); | ||
}); | ||
|
||
it('ignores multiple values of the same query params except the last value', () => { | ||
store.dispatch({ | ||
type: 'userChangedUrl', | ||
payload: { | ||
...history.location, | ||
pathname: '/hosts', | ||
search: '?page_index=2&page_index=3&page_size=20&page_size=50', | ||
}, | ||
}); | ||
expect(queryParams()).toEqual({ page_index: '3', page_size: '50' }); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -9,21 +9,23 @@ import { coreMock } from '../../../../../../../../src/core/public/mocks'; | |||
import { History, createBrowserHistory } from 'history'; | ||||
import { hostListReducer, hostMiddlewareFactory } from './index'; | ||||
import { HostResultList, Immutable } from '../../../../../common/types'; | ||||
import { HostListState } from '../../types'; | ||||
import { HostState } from '../../types'; | ||||
import { AppAction } from '../action'; | ||||
import { listData } from './selectors'; | ||||
import { DepsStartMock, depsStartMock } from '../../mocks'; | ||||
import { mockHostResultList } from './mock_host_result_list'; | ||||
import { createSpyMiddleware, MiddlewareActionSpyHelper } from '../test_utils'; | ||||
|
||||
describe('host list middleware', () => { | ||||
const sleep = (ms = 100) => new Promise(wakeup => setTimeout(wakeup, ms)); | ||||
let fakeCoreStart: jest.Mocked<CoreStart>; | ||||
let depsStart: DepsStartMock; | ||||
let fakeHttpServices: jest.Mocked<HttpSetup>; | ||||
type HostListStore = Store<Immutable<HostListState>, Immutable<AppAction>>; | ||||
type HostListStore = Store<Immutable<HostState>, Immutable<AppAction>>; | ||||
let store: HostListStore; | ||||
let getState: HostListStore['getState']; | ||||
let dispatch: HostListStore['dispatch']; | ||||
let waitForAction: MiddlewareActionSpyHelper['waitForAction']; | ||||
let actionSpyMiddleware; | ||||
|
||||
let history: History<never>; | ||||
const getEndpointListApiResponse = (): HostResultList => { | ||||
|
@@ -33,15 +35,16 @@ describe('host list middleware', () => { | |||
fakeCoreStart = coreMock.createStart({ basePath: '/mock' }); | ||||
depsStart = depsStartMock(); | ||||
fakeHttpServices = fakeCoreStart.http as jest.Mocked<HttpSetup>; | ||||
({ actionSpyMiddleware, waitForAction } = createSpyMiddleware<HostState>()); | ||||
store = createStore( | ||||
hostListReducer, | ||||
applyMiddleware(hostMiddlewareFactory(fakeCoreStart, depsStart)) | ||||
applyMiddleware(hostMiddlewareFactory(fakeCoreStart, depsStart), actionSpyMiddleware) | ||||
); | ||||
getState = store.getState; | ||||
dispatch = store.dispatch; | ||||
history = createBrowserHistory(); | ||||
}); | ||||
test('handles `userChangedUrl`', async () => { | ||||
it('handles `userChangedUrl`', async () => { | ||||
const apiResponse = getEndpointListApiResponse(); | ||||
fakeHttpServices.post.mockResolvedValue(apiResponse); | ||||
expect(fakeHttpServices.post).not.toHaveBeenCalled(); | ||||
|
@@ -53,10 +56,10 @@ describe('host list middleware', () => { | |||
pathname: '/hosts', | ||||
}, | ||||
}); | ||||
await sleep(); | ||||
await waitForAction('serverReturnedHostList'); | ||||
expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/endpoint/metadata', { | ||||
body: JSON.stringify({ | ||||
paging_properties: [{ page_index: 0 }, { page_size: 10 }], | ||||
paging_properties: [{ page_index: '0' }, { page_size: '10' }], | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the page_index and page_size are strings because they are coming from the url now There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This does not seem valid. The Server Schema for the metadata POST request indicates these should be numbers.
if you are sending strings to the server now (in the browser) and that is succeeding, then perhaps the platform translates it back to a number 🤷♂️ |
||||
}), | ||||
}); | ||||
expect(listData(getState())).toEqual(apiResponse.hosts.map(hostInfo => hostInfo.metadata)); | ||||
|
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -4,34 +4,64 @@ | |||||||
* you may not use this file except in compliance with the Elastic License. | ||||||||
*/ | ||||||||
|
||||||||
import { HostResultList } from '../../../../../common/types'; | ||||||||
import { isOnHostPage, hasSelectedHost, uiQueryParams, listData } from './selectors'; | ||||||||
import { HostState } from '../../types'; | ||||||||
import { ImmutableMiddlewareFactory } from '../../types'; | ||||||||
import { pageIndex, pageSize, isOnHostPage, hasSelectedHost, uiQueryParams } from './selectors'; | ||||||||
import { HostListState } from '../../types'; | ||||||||
|
||||||||
export const hostMiddlewareFactory: ImmutableMiddlewareFactory<HostListState> = coreStart => { | ||||||||
export const hostMiddlewareFactory: ImmutableMiddlewareFactory<HostState> = coreStart => { | ||||||||
return ({ getState, dispatch }) => next => async action => { | ||||||||
next(action); | ||||||||
const state = getState(); | ||||||||
if ( | ||||||||
(action.type === 'userChangedUrl' && | ||||||||
isOnHostPage(state) && | ||||||||
hasSelectedHost(state) !== true) || | ||||||||
action.type === 'userPaginatedHostList' | ||||||||
action.type === 'userChangedUrl' && | ||||||||
isOnHostPage(state) && | ||||||||
hasSelectedHost(state) !== true | ||||||||
) { | ||||||||
const hostPageIndex = pageIndex(state); | ||||||||
const hostPageSize = pageSize(state); | ||||||||
const response = await coreStart.http.post('/api/endpoint/metadata', { | ||||||||
body: JSON.stringify({ | ||||||||
paging_properties: [{ page_index: hostPageIndex }, { page_size: hostPageSize }], | ||||||||
}), | ||||||||
}); | ||||||||
response.request_page_index = hostPageIndex; | ||||||||
dispatch({ | ||||||||
type: 'serverReturnedHostList', | ||||||||
payload: response, | ||||||||
}); | ||||||||
const { page_index: pageIndex, page_size: pageSize } = uiQueryParams(state); | ||||||||
try { | ||||||||
const response = await coreStart.http.post<HostResultList>('/api/endpoint/metadata', { | ||||||||
body: JSON.stringify({ | ||||||||
paging_properties: [{ page_index: pageIndex }, { page_size: pageSize }], | ||||||||
}), | ||||||||
}); | ||||||||
response.request_page_index = Number(pageIndex); | ||||||||
dispatch({ | ||||||||
type: 'serverReturnedHostList', | ||||||||
payload: response, | ||||||||
}); | ||||||||
} catch (error) { | ||||||||
dispatch({ | ||||||||
type: 'serverFailedToReturnHostList', | ||||||||
payload: error, | ||||||||
}); | ||||||||
} | ||||||||
} | ||||||||
if (action.type === 'userChangedUrl' && hasSelectedHost(state) !== false) { | ||||||||
// If user navigated directly to a host details page, load the host list | ||||||||
if (listData(state).length === 0) { | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a list API call was done, but there were no hosts in the list, then this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that's true. i'm not sure what to do at that point once the kql bar is introduced. maybe it will be possible to additionally check if filters are active at that point? otherwise, for now i think this makes the most sense for the details page There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah, this would be where perhaps we should track wether the list has been loaded (once) at least. Not a big deal. I guess we can re-evaluate when we introduce filtering |
||||||||
const { page_index: pageIndex, page_size: pageSize } = uiQueryParams(state); | ||||||||
try { | ||||||||
const response = await coreStart.http.post('/api/endpoint/metadata', { | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was going to suggest that we add typing to
Suggested change
But I just realized that you then get an error because There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure i'm following. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @paul-tavares i think i can type the response, but wouldn't it be HostResultList? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My mistake - @parkiino you are correct - you should use |
||||||||
body: JSON.stringify({ | ||||||||
paging_properties: [{ page_index: pageIndex }, { page_size: pageSize }], | ||||||||
}), | ||||||||
}); | ||||||||
response.request_page_index = Number(pageIndex); | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the purpose of this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the api doesn't return the page index that the ui would use so we manually set it in the ui There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider typing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the server returns what would originally be returned from an elastic search query, which is the page index within elastic i think? or something like that. so it seemed to make more sense to be consistent with that |
||||||||
dispatch({ | ||||||||
type: 'serverReturnedHostList', | ||||||||
payload: response, | ||||||||
}); | ||||||||
} catch (error) { | ||||||||
dispatch({ | ||||||||
type: 'serverFailedToReturnHostList', | ||||||||
payload: error, | ||||||||
}); | ||||||||
paul-tavares marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
return; | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
// call the host details api | ||||||||
const { selected_host: selectedHost } = uiQueryParams(state); | ||||||||
try { | ||||||||
const response = await coreStart.http.get(`/api/endpoint/metadata/${selectedHost}`); | ||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@parkiino - are these values the defaults we are using? It would be good to test the defaults for this test case and have a separate test case for invalid values that revert to defaults.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the defaults are going to be page_index: 0, and page_size: 10