Commit 9429abd1 authored by David Pisek's avatar David Pisek Committed by Natalia Tepluhina

Add pagination support to ILSD project selector

This commit adds actions, mutations and helpers to support
pagination within the instance security dashboard's project selector.
parent 6a739930
...@@ -12,6 +12,7 @@ export default { ...@@ -12,6 +12,7 @@ export default {
}, },
computed: { computed: {
...mapState('projectSelector', [ ...mapState('projectSelector', [
'pageInfo',
'projects', 'projects',
'selectedProjects', 'selectedProjects',
'projectSearchResults', 'projectSearchResults',
...@@ -26,6 +27,7 @@ export default { ...@@ -26,6 +27,7 @@ export default {
methods: { methods: {
...mapActions('projectSelector', [ ...mapActions('projectSelector', [
'fetchSearchResults', 'fetchSearchResults',
'fetchSearchResultsNextPage',
'addProjects', 'addProjects',
'clearSearchResults', 'clearSearchResults',
'toggleSelectedProject', 'toggleSelectedProject',
...@@ -62,8 +64,10 @@ export default { ...@@ -62,8 +64,10 @@ export default {
:show-loading-indicator="isSearchingProjects" :show-loading-indicator="isSearchingProjects"
:show-minimum-search-query-message="messages.minimumQuery" :show-minimum-search-query-message="messages.minimumQuery"
:show-search-error-message="messages.searchError" :show-search-error-message="messages.searchError"
:total-results="pageInfo.total"
@searched="searched" @searched="searched"
@projectClicked="projectClicked" @projectClicked="projectClicked"
@bottomReached="fetchSearchResultsNextPage"
/> />
<div class="mb-3"> <div class="mb-3">
<gl-button :disabled="!canAddProjects" variant="success" @click="addProjects"> <gl-button :disabled="!canAddProjects" variant="success" @click="addProjects">
......
...@@ -2,10 +2,14 @@ import Api from '~/api'; ...@@ -2,10 +2,14 @@ import Api from '~/api';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __, s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import addPageInfo from './utils/add_page_info';
import * as types from './mutation_types'; import * as types from './mutation_types';
const API_MINIMUM_QUERY_LENGTH = 3; const API_MINIMUM_QUERY_LENGTH = 3;
const searchProjects = (searchQuery, searchOptions) =>
Api.projects(searchQuery, searchOptions).then(addPageInfo);
export const toggleSelectedProject = ({ commit, state }, project) => { export const toggleSelectedProject = ({ commit, state }, project) => {
const isProject = ({ id }) => id === project.id; const isProject = ({ id }) => id === project.id;
...@@ -141,7 +145,7 @@ export const receiveRemoveProjectError = ({ commit }) => { ...@@ -141,7 +145,7 @@ export const receiveRemoveProjectError = ({ commit }) => {
createFlash(__('Something went wrong, unable to remove project')); createFlash(__('Something went wrong, unable to remove project'));
}; };
export const fetchSearchResults = ({ state, dispatch }) => { export const fetchSearchResults = ({ state, dispatch, commit }) => {
const { searchQuery } = state; const { searchQuery } = state;
dispatch('requestSearchResults'); dispatch('requestSearchResults');
...@@ -149,17 +153,30 @@ export const fetchSearchResults = ({ state, dispatch }) => { ...@@ -149,17 +153,30 @@ export const fetchSearchResults = ({ state, dispatch }) => {
return dispatch('setMinimumQueryMessage'); return dispatch('setMinimumQueryMessage');
} }
return Api.projects(searchQuery, {}) return searchProjects(searchQuery)
.then(results => dispatch('receiveSearchResultsSuccess', results)) .then(payload => commit(types.RECEIVE_SEARCH_RESULTS_SUCCESS, payload))
.catch(() => dispatch('receiveSearchResultsError')); .catch(() => dispatch('receiveSearchResultsError'));
}; };
export const requestSearchResults = ({ commit }) => { export const fetchSearchResultsNextPage = ({ state, dispatch, commit }) => {
commit(types.REQUEST_SEARCH_RESULTS); const {
searchQuery,
pageInfo: { totalPages, page, nextPage },
} = state;
if (totalPages <= page) {
return Promise.resolve();
}
const searchOptions = { page: nextPage };
return searchProjects(searchQuery, searchOptions)
.then(payload => commit(types.RECEIVE_SEARCH_RESULTS_SUCCESS, payload))
.catch(() => dispatch('receiveSearchResultsError'));
}; };
export const receiveSearchResultsSuccess = ({ commit }, results) => { export const requestSearchResults = ({ commit }) => {
commit(types.RECEIVE_SEARCH_RESULTS_SUCCESS, results); commit(types.REQUEST_SEARCH_RESULTS);
}; };
export const receiveSearchResultsError = ({ commit }) => { export const receiveSearchResultsError = ({ commit }) => {
......
...@@ -24,3 +24,5 @@ export const RECEIVE_SEARCH_RESULTS_SUCCESS = 'RECEIVE_SEARCH_RESULTS_SUCCESS'; ...@@ -24,3 +24,5 @@ export const RECEIVE_SEARCH_RESULTS_SUCCESS = 'RECEIVE_SEARCH_RESULTS_SUCCESS';
export const RECEIVE_SEARCH_RESULTS_ERROR = 'RECEIVE_SEARCH_RESULTS_ERROR'; export const RECEIVE_SEARCH_RESULTS_ERROR = 'RECEIVE_SEARCH_RESULTS_ERROR';
export const SET_MINIMUM_QUERY_MESSAGE = 'SET_MINIMUM_QUERY_MESSAGE'; export const SET_MINIMUM_QUERY_MESSAGE = 'SET_MINIMUM_QUERY_MESSAGE';
export const RECEIVE_NEXT_PAGE_SUCCESS = 'RECEIVE_NEXT_PAGE_SUCCESS';
...@@ -53,8 +53,9 @@ export default { ...@@ -53,8 +53,9 @@ export default {
state.messages.minimumQuery = false; state.messages.minimumQuery = false;
state.searchCount += 1; state.searchCount += 1;
}, },
[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, results) { [types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, { data, pageInfo }) {
state.projectSearchResults = results.data; state.projectSearchResults = data;
state.pageInfo = pageInfo;
state.messages.noResults = state.projectSearchResults.length === 0; state.messages.noResults = state.projectSearchResults.length === 0;
state.messages.searchError = false; state.messages.searchError = false;
...@@ -73,6 +74,7 @@ export default { ...@@ -73,6 +74,7 @@ export default {
}, },
[types.SET_MINIMUM_QUERY_MESSAGE](state) { [types.SET_MINIMUM_QUERY_MESSAGE](state) {
state.projectSearchResults = []; state.projectSearchResults = [];
state.pageInfo.total = 0;
state.messages.noResults = false; state.messages.noResults = false;
state.messages.searchError = false; state.messages.searchError = false;
...@@ -80,4 +82,9 @@ export default { ...@@ -80,4 +82,9 @@ export default {
state.searchCount = Math.max(0, state.searchCount - 1); state.searchCount = Math.max(0, state.searchCount - 1);
}, },
[types.RECEIVE_NEXT_PAGE_SUCCESS](state, { data, pageInfo }) {
state.projectSearchResults = state.projectSearchResults.concat(data);
state.pageInfo = pageInfo;
},
}; };
...@@ -17,4 +17,10 @@ export default () => ({ ...@@ -17,4 +17,10 @@ export default () => ({
minimumQuery: false, minimumQuery: false,
}, },
searchCount: 0, searchCount: 0,
pageInfo: {
page: 0,
nextPage: 0,
total: 0,
totalPages: 0,
},
}); });
import { flow } from 'lodash';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
/**
* Takes an object containing pagination related data (eg.: page, nextPage, ...)
* and returns a new object which has this data grouped in a 'pageInfo' property
*
* @param {{page: *, nextPage: *, total: *, totalPages: *}}
* @returns {{pageInfo: {total: *, nextPage: *, totalPages: *, page: *}}}
*/
const groupPageInfo = ({ page, nextPage, total, totalPages }) => ({
pageInfo: { page, nextPage, total, totalPages },
});
/**
* Returns an XHR-response's headers property
*
* @param {{headers}} res
* @returns {*}
*/
const getHeaders = res => res.headers;
/**
* Takes an XHR-response object and returns an object containing pagination related
* data
*
* @param {{headers}}
* @returns {{pageInfo: {}}}
*/
const pageInfo = flow(
getHeaders,
normalizeHeaders,
parseIntPagination,
groupPageInfo,
);
/**
* Takes an XHR-response object and adds pagination related data do it
* (eg.: page, nextPage, total, totalPages)
*
* @param {Object} res
* @return {Object}
*/
const addPageInfo = res => (res?.headers ? { ...res, ...pageInfo(res) } : res);
export default addPageInfo;
---
title: Add pagination to Instance Level Security Dashboard project selector
merge_request: 26138
author:
type: fixed
...@@ -30,6 +30,7 @@ describe('Project Manager component', () => { ...@@ -30,6 +30,7 @@ describe('Project Manager component', () => {
actions: { actions: {
setSearchQuery: jest.fn(), setSearchQuery: jest.fn(),
fetchSearchResults: jest.fn(), fetchSearchResults: jest.fn(),
fetchSearchResultsNextPage: jest.fn(),
addProjects: jest.fn(), addProjects: jest.fn(),
clearSearchResults: jest.fn(), clearSearchResults: jest.fn(),
toggleSelectedProject: jest.fn(), toggleSelectedProject: jest.fn(),
...@@ -104,11 +105,24 @@ describe('Project Manager component', () => { ...@@ -104,11 +105,24 @@ describe('Project Manager component', () => {
expect(getProjectList().exists()).toBe(true); expect(getProjectList().exists()).toBe(true);
}); });
it('dispatches the right actions when the project-list emits a projectRemoved event', () => { it('dispatches the right actions when the project-list emits a "bottomReached" event', () => {
const projectSelector = getProjectSelector();
const fetchSearchResultsNextPageAction = getMockAction('fetchSearchResultsNextPage');
expect(fetchSearchResultsNextPageAction).toHaveBeenCalledTimes(0);
projectSelector.vm.$emit('bottomReached');
expect(fetchSearchResultsNextPageAction).toHaveBeenCalledTimes(1);
});
it('dispatches the right actions when the project-list emits a "projectRemoved" event', () => {
const mockProject = { remove_path: 'foo' }; const mockProject = { remove_path: 'foo' };
const projectList = wrapper.find(ProjectList); const projectList = getProjectList();
const removeProjectAction = getMockAction('removeProject'); const removeProjectAction = getMockAction('removeProject');
expect(removeProjectAction).toHaveBeenCalledTimes(0);
projectList.vm.$emit('projectRemoved', mockProject); projectList.vm.$emit('projectRemoved', mockProject);
expect(removeProjectAction).toHaveBeenCalledTimes(1); expect(removeProjectAction).toHaveBeenCalledTimes(1);
......
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createState from 'ee/security_dashboard/store/modules/project_selector/state'; import createState from 'ee/security_dashboard/store/modules/project_selector/state';
import * as types from 'ee/security_dashboard/store/modules/project_selector/mutation_types'; import * as types from 'ee/security_dashboard/store/modules/project_selector/mutation_types';
import * as actions from 'ee/security_dashboard/store/modules/project_selector/actions'; import * as actions from 'ee/security_dashboard/store/modules/project_selector/actions';
import createFlash from '~/flash'; import createFlash from '~/flash';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
jest.mock('~/api');
jest.mock('~/flash'); jest.mock('~/flash');
describe('projectSelector actions', () => { describe('EE projectSelector actions', () => {
const getMockProjects = n => [...Array(n).keys()].map(i => ({ id: i, name: `project-${i}` })); const getMockProjects = n => [...Array(n).keys()].map(i => ({ id: i, name: `project-${i}` }));
const mockAddEndpoint = 'mock-add_endpoint'; const mockAddEndpoint = 'mock-add_endpoint';
...@@ -22,6 +20,20 @@ describe('projectSelector actions', () => { ...@@ -22,6 +20,20 @@ describe('projectSelector actions', () => {
let mockDispatchContext; let mockDispatchContext;
let state; let state;
const pageInfo = {
page: 1,
nextPage: 2,
total: 50,
totalPages: 5,
};
const responseHeaders = {
'X-Next-Page': pageInfo.nextPage,
'X-Page': pageInfo.page,
'X-Total': pageInfo.total,
'X-Total-Pages': pageInfo.totalPages,
};
beforeEach(() => { beforeEach(() => {
mockAxios = new MockAdapter(axios); mockAxios = new MockAdapter(axios);
mockDispatchContext = { dispatch: () => {}, commit: () => {}, state }; mockDispatchContext = { dispatch: () => {}, commit: () => {}, state };
...@@ -71,7 +83,23 @@ describe('projectSelector actions', () => { ...@@ -71,7 +83,23 @@ describe('projectSelector actions', () => {
}); });
describe('addProjects', () => { describe('addProjects', () => {
it('posts selected project ids to project add endpoint', () => { it(`posts the selected project's ids to the add-endpoint`, () => {
const projectIds = ['1', '2'];
state.selectedProjects = [{ id: projectIds[0], name: '1' }, { id: projectIds[1], name: '2' }];
state.projectEndpoints.add = mockAddEndpoint;
mockAxios.onPost(mockAddEndpoint).replyOnce(200, mockResponse);
actions.addProjects({ state, dispatch: () => {} });
return waitForPromises().then(() => {
const requestData = mockAxios.history.post[0].data;
expect(requestData).toBe(JSON.stringify({ project_ids: projectIds }));
});
});
it('dispatches the correct actions when the request is successful', () => {
state.projectEndpoints.add = mockAddEndpoint; state.projectEndpoints.add = mockAddEndpoint;
mockAxios.onPost(mockAddEndpoint).replyOnce(200, mockResponse); mockAxios.onPost(mockAddEndpoint).replyOnce(200, mockResponse);
...@@ -406,6 +434,25 @@ describe('projectSelector actions', () => { ...@@ -406,6 +434,25 @@ describe('projectSelector actions', () => {
}); });
}); });
describe('setSearchQuery', () => {
it('commits the REQUEST_SEARCH_RESULTS mutation', () => {
const payload = 'search-query';
return testAction(
actions.setSearchQuery,
payload,
state,
[
{
type: types.SET_SEARCH_QUERY,
payload,
},
],
[],
);
});
});
describe('fetchSearchResults', () => { describe('fetchSearchResults', () => {
it.each([null, undefined, false, NaN, 0, ''])( it.each([null, undefined, false, NaN, 0, ''])(
'dispatches setMinimumQueryMessage if the search query is falsy', 'dispatches setMinimumQueryMessage if the search query is falsy',
...@@ -452,11 +499,35 @@ describe('projectSelector actions', () => { ...@@ -452,11 +499,35 @@ describe('projectSelector actions', () => {
); );
it('dispatches the correct actions when the query is valid', () => { it('dispatches the correct actions when the query is valid', () => {
const mockProjects = [{ id: 0, name: 'mock-name1' }]; const projects = [{ id: 0, name: 'mock-name1' }];
Api.projects.mockResolvedValueOnce(mockProjects);
mockAxios.onGet().replyOnce(200, projects, responseHeaders);
state.searchQuery = 'mock-query'; state.searchQuery = 'mock-query';
return testAction( return testAction(
actions.fetchSearchResults,
null,
state,
[
{
type: types.RECEIVE_SEARCH_RESULTS_SUCCESS,
payload: { data: projects, headers: responseHeaders, pageInfo },
},
],
[
{
type: 'requestSearchResults',
},
],
);
});
it('dispatches the correct actions when the request is not successful', () => {
mockAxios.onGet(mockListEndpoint).replyOnce(500);
state.searchQuery = 'mock-query';
testAction(
actions.fetchSearchResults, actions.fetchSearchResults,
null, null,
state, state,
...@@ -466,8 +537,7 @@ describe('projectSelector actions', () => { ...@@ -466,8 +537,7 @@ describe('projectSelector actions', () => {
type: 'requestSearchResults', type: 'requestSearchResults',
}, },
{ {
type: 'receiveSearchResultsSuccess', type: 'receiveSearchResultsError',
payload: mockProjects,
}, },
], ],
); );
...@@ -489,25 +559,6 @@ describe('projectSelector actions', () => { ...@@ -489,25 +559,6 @@ describe('projectSelector actions', () => {
)); ));
}); });
describe('receiveSearchResultsSuccess', () => {
it('commits the RECEIVE_SEARCH_RESULTS_SUCCESS mutation', () => {
const mockProjects = [{ id: 0, name: 'mock-project1' }];
return testAction(
actions.receiveSearchResultsSuccess,
mockProjects,
state,
[
{
type: types.RECEIVE_SEARCH_RESULTS_SUCCESS,
payload: mockProjects,
},
],
[],
);
});
});
describe('receiveSearchResultsError', () => { describe('receiveSearchResultsError', () => {
it('commits the RECEIVE_SEARCH_RESULTS_ERROR mutation', () => it('commits the RECEIVE_SEARCH_RESULTS_ERROR mutation', () =>
testAction( testAction(
...@@ -523,6 +574,59 @@ describe('projectSelector actions', () => { ...@@ -523,6 +574,59 @@ describe('projectSelector actions', () => {
)); ));
}); });
describe('fetchSearchResultsNextPage', () => {
describe('when the current page-index is smaller than the number of total pages', () => {
beforeEach(() => {
state.pageInfo.totalPages = 2;
state.pageInfo.page = 1;
});
it('dispatches the "receiveNextPageSuccess" action if the request is successful', () => {
const projects = [{ id: 0, name: 'mock-name1' }];
mockAxios.onGet().replyOnce(200, projects, responseHeaders);
return testAction(
actions.fetchSearchResultsNextPage,
null,
state,
[
{
type: types.RECEIVE_SEARCH_RESULTS_SUCCESS,
payload: { data: projects, headers: responseHeaders, pageInfo },
},
],
[],
);
});
it('dispatches the "receiveSearchResultsError" action if the request is not successful', () => {
mockAxios.onGet(mockListEndpoint).replyOnce(500);
return testAction(
actions.fetchSearchResultsNextPage,
null,
state,
[],
[
{
type: 'receiveSearchResultsError',
},
],
);
});
});
describe('when the current page-index is equal to the number of total pages', () => {
it('does not commit any mutations or dispatch any actions', () => {
state.pageInfo.totalPages = 1;
state.pageInfo.page = 1;
return testAction(actions.fetchSearchResultsNextPage, [], state);
});
});
});
describe('setProjectEndpoints', () => { describe('setProjectEndpoints', () => {
it('commits project list and add endpoints', () => { it('commits project list and add endpoints', () => {
const payload = { const payload = {
......
import addPageInfo from 'ee/security_dashboard/store/modules/project_selector/utils/add_page_info';
describe('EE Project Selector store utils', () => {
describe('addPageInfo', () => {
it('takes an API response and adds a "pageInfo" property that contains the headers pagination data', () => {
const responseData = {
data: { foo: 'bar ' },
headers: {
'X-Next-Page': 0,
'X-Page': 0,
'X-Total': 0,
'X-Total-Pages': 0,
},
};
const responseDataWithPageInfo = addPageInfo(responseData);
expect(responseDataWithPageInfo).toStrictEqual({
...responseData,
pageInfo: {
page: 0,
nextPage: 0,
total: 0,
totalPages: 0,
},
});
});
it.each([{}, { foo: 'foo' }, null, undefined, false])(
'returns the original input if it does not contain a header property',
input => {
expect(addPageInfo(input)).toBe(input);
},
);
});
});
...@@ -2,7 +2,7 @@ import createState from 'ee/security_dashboard/store/modules/project_selector/st ...@@ -2,7 +2,7 @@ import createState from 'ee/security_dashboard/store/modules/project_selector/st
import mutations from 'ee/security_dashboard/store/modules/project_selector/mutations'; import mutations from 'ee/security_dashboard/store/modules/project_selector/mutations';
import * as types from 'ee/security_dashboard/store/modules/project_selector/mutation_types'; import * as types from 'ee/security_dashboard/store/modules/project_selector/mutation_types';
describe('projectsSelector mutations', () => { describe('EE projectsSelector mutations', () => {
let state; let state;
beforeEach(() => { beforeEach(() => {
...@@ -332,6 +332,14 @@ describe('projectsSelector mutations', () => { ...@@ -332,6 +332,14 @@ describe('projectsSelector mutations', () => {
expect(state.projectSearchResults).toHaveLength(0); expect(state.projectSearchResults).toHaveLength(0);
}); });
it('sets "pageInfo.total" to be 0', () => {
state.pageInfo.total = 1;
mutations[types.SET_MINIMUM_QUERY_MESSAGE](state);
expect(state.pageInfo.total).toBe(0);
});
it('sets "messages.noResults" to be false', () => { it('sets "messages.noResults" to be false', () => {
state.messages.noResults = true; state.messages.noResults = true;
...@@ -364,4 +372,24 @@ describe('projectsSelector mutations', () => { ...@@ -364,4 +372,24 @@ describe('projectsSelector mutations', () => {
expect(state.searchCount).toBe(0); expect(state.searchCount).toBe(0);
}); });
}); });
describe('RECEIVE_NEXT_PAGE_SUCCESS', () => {
it('appends the data from the payload to "projectsSearchResults"', () => {
state.projectSearchResults = ['foo'];
const payload = { data: ['bar'] };
mutations[types.RECEIVE_NEXT_PAGE_SUCCESS](state, payload);
expect(state.projectSearchResults).toEqual(['foo', 'bar']);
});
it('sets "pageInfo" to be the given payload', () => {
const payload = { pageInfo: { foo: 'foo', bar: 'bar' } };
mutations[types.RECEIVE_NEXT_PAGE_SUCCESS](state, payload);
expect(state.pageInfo).toEqual(payload.pageInfo);
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment