diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index aee9990bc0b1f30d24128f4ef5da3deb7bc1e884..6ec77186298e137feefa18088f05bc762686596e 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -5,6 +5,8 @@ import { joinPaths } from './lib/utils/url_utility'; import flash from '~/flash'; import { __ } from '~/locale'; +const DEFAULT_PER_PAGE = 20; + const Api = { groupsPath: '/api/:version/groups.json', groupPath: '/api/:version/groups/:id', @@ -66,7 +68,7 @@ const Api = { params: Object.assign( { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }, options, ), @@ -90,7 +92,7 @@ const Api = { .get(url, { params: { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }, }) .then(({ data }) => callback(data)); @@ -101,7 +103,7 @@ const Api = { const url = Api.buildUrl(Api.projectsPath); const defaults = { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, simple: true, }; @@ -126,7 +128,7 @@ const Api = { .get(url, { params: { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, ...options, }, }) @@ -235,7 +237,7 @@ const Api = { const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId); const defaults = { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }; return axios .get(url, { @@ -325,7 +327,7 @@ const Api = { params: Object.assign( { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }, options, ), @@ -355,7 +357,7 @@ const Api = { const url = Api.buildUrl(Api.userProjectsPath).replace(':id', userId); const defaults = { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }; return axios .get(url, { @@ -371,7 +373,7 @@ const Api = { return axios.get(url, { params: { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, ...options, }, }); @@ -403,10 +405,15 @@ const Api = { return axios.post(url); }, - releases(id) { + releases(id, options = {}) { const url = Api.buildUrl(this.releasesPath).replace(':id', encodeURIComponent(id)); - return axios.get(url); + return axios.get(url, { + params: { + per_page: DEFAULT_PER_PAGE, + ...options, + }, + }); }, release(projectPath, tagName) { diff --git a/app/assets/javascripts/releases/list/components/app.vue b/app/assets/javascripts/releases/list/components/app.vue index 5a06c4fec58d6841201c4601f79ced3d2371f67a..a414b3ccd4ec09e2c7761a100afd7637ff5acb41 100644 --- a/app/assets/javascripts/releases/list/components/app.vue +++ b/app/assets/javascripts/releases/list/components/app.vue @@ -1,6 +1,12 @@ <script> import { mapState, mapActions } from 'vuex'; import { GlSkeletonLoading, GlEmptyState } from '@gitlab/ui'; +import { + getParameterByName, + historyPushState, + buildUrlWithCurrentLocation, +} from '~/lib/utils/common_utils'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import ReleaseBlock from './release_block.vue'; export default { @@ -9,6 +15,7 @@ export default { GlSkeletonLoading, GlEmptyState, ReleaseBlock, + TablePagination, }, props: { projectId: { @@ -25,7 +32,7 @@ export default { }, }, computed: { - ...mapState(['isLoading', 'releases', 'hasError']), + ...mapState(['isLoading', 'releases', 'hasError', 'pageInfo']), shouldRenderEmptyState() { return !this.releases.length && !this.hasError && !this.isLoading; }, @@ -34,10 +41,17 @@ export default { }, }, created() { - this.fetchReleases(this.projectId); + this.fetchReleases({ + page: getParameterByName('page'), + projectId: this.projectId, + }); }, methods: { ...mapActions(['fetchReleases']), + onChangePage(page) { + historyPushState(buildUrlWithCurrentLocation(`?page=${page}`)); + this.fetchReleases({ page, projectId: this.projectId }); + }, }, }; </script> @@ -67,6 +81,8 @@ export default { :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" /> </div> + + <table-pagination v-if="!isLoading" :change="onChangePage" :page-info="pageInfo" /> </div> </template> <style> diff --git a/app/assets/javascripts/releases/list/store/actions.js b/app/assets/javascripts/releases/list/store/actions.js index e0a922d5ef6b7f35b6faef56384152d63bd38b90..b15fb69226f3b57d390dbcf517a6c2da56291a51 100644 --- a/app/assets/javascripts/releases/list/store/actions.js +++ b/app/assets/javascripts/releases/list/store/actions.js @@ -2,6 +2,7 @@ import * as types from './mutation_types'; import createFlash from '~/flash'; import { __ } from '~/locale'; import api from '~/api'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; /** * Commits a mutation to update the state while the main endpoint is being requested. @@ -16,17 +17,19 @@ export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES); * * @param {String} projectId */ -export const fetchReleases = ({ dispatch }, projectId) => { +export const fetchReleases = ({ dispatch }, { page = '1', projectId }) => { dispatch('requestReleases'); api - .releases(projectId) - .then(({ data }) => dispatch('receiveReleasesSuccess', data)) + .releases(projectId, { page }) + .then(response => dispatch('receiveReleasesSuccess', response)) .catch(() => dispatch('receiveReleasesError')); }; -export const receiveReleasesSuccess = ({ commit }, data) => - commit(types.RECEIVE_RELEASES_SUCCESS, data); +export const receiveReleasesSuccess = ({ commit }, { data, headers }) => { + const pageInfo = parseIntPagination(normalizeHeaders(headers)); + commit(types.RECEIVE_RELEASES_SUCCESS, { data, pageInfo }); +}; export const receiveReleasesError = ({ commit }) => { commit(types.RECEIVE_RELEASES_ERROR); diff --git a/app/assets/javascripts/releases/list/store/mutations.js b/app/assets/javascripts/releases/list/store/mutations.js index b97dc6cb0abec6a98577b55b593947f9ea95a5d4..99fc096264aeeb9ddbeb18831691c942b160206a 100644 --- a/app/assets/javascripts/releases/list/store/mutations.js +++ b/app/assets/javascripts/releases/list/store/mutations.js @@ -13,13 +13,15 @@ export default { * Sets isLoading to false. * Sets hasError to false. * Sets the received data + * Sets the received pagination information * @param {Object} state - * @param {Object} data + * @param {Object} resp */ - [types.RECEIVE_RELEASES_SUCCESS](state, data) { + [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) { state.hasError = false; state.isLoading = false; state.releases = data; + state.pageInfo = pageInfo; }, /** diff --git a/app/assets/javascripts/releases/list/store/state.js b/app/assets/javascripts/releases/list/store/state.js index bf25e651c99bb48c757cffa9863de7483d7e16ec..c251f56c9c551b9d720032d7fdc5f76947b15281 100644 --- a/app/assets/javascripts/releases/list/store/state.js +++ b/app/assets/javascripts/releases/list/store/state.js @@ -2,4 +2,5 @@ export default () => ({ isLoading: false, hasError: false, releases: [], + pageInfo: {}, }); diff --git a/changelogs/unreleased/feat-ui-releases-pagination.yml b/changelogs/unreleased/feat-ui-releases-pagination.yml new file mode 100644 index 0000000000000000000000000000000000000000..8f6efe8ca0192a1efecb19b19fb1e3dff87f53e8 --- /dev/null +++ b/changelogs/unreleased/feat-ui-releases-pagination.yml @@ -0,0 +1,5 @@ +--- +title: Implement pagination for project releases page +merge_request: 19912 +author: Fabio Huser +type: added diff --git a/spec/javascripts/releases/list/components/app_spec.js b/spec/javascripts/releases/list/components/app_spec.js index 471c442e497bc46a17ab8a4f1296f5fbfb39d651..994488581d7c9a33998de371f5ffd9e5679bc94f 100644 --- a/spec/javascripts/releases/list/components/app_spec.js +++ b/spec/javascripts/releases/list/components/app_spec.js @@ -1,15 +1,22 @@ import Vue from 'vue'; +import _ from 'underscore'; import app from '~/releases/list/components/app.vue'; import createStore from '~/releases/list/store'; import api from '~/api'; import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { resetStore } from '../store/helpers'; -import { releases } from '../../mock_data'; +import { + pageInfoHeadersWithoutPagination, + pageInfoHeadersWithPagination, + release, + releases, +} from '../../mock_data'; describe('Releases App ', () => { const Component = Vue.extend(app); let store; let vm; + let releasesPagination; const props = { projectId: 'gitlab-ce', @@ -19,6 +26,7 @@ describe('Releases App ', () => { beforeEach(() => { store = createStore(); + releasesPagination = _.range(21).map(index => ({ ...release, tag_name: `${index}.00` })); }); afterEach(() => { @@ -28,7 +36,7 @@ describe('Releases App ', () => { describe('while loading', () => { beforeEach(() => { - spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [] })); + spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [], headers: {} })); vm = mountComponentWithStore(Component, { props, store }); }); @@ -36,6 +44,7 @@ describe('Releases App ', () => { expect(vm.$el.querySelector('.js-loading')).not.toBeNull(); expect(vm.$el.querySelector('.js-empty-state')).toBeNull(); expect(vm.$el.querySelector('.js-success-state')).toBeNull(); + expect(vm.$el.querySelector('.gl-pagination')).toBeNull(); setTimeout(() => { done(); @@ -45,7 +54,9 @@ describe('Releases App ', () => { describe('with successful request', () => { beforeEach(() => { - spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: releases })); + spyOn(api, 'releases').and.returnValue( + Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }), + ); vm = mountComponentWithStore(Component, { props, store }); }); @@ -54,6 +65,27 @@ describe('Releases App ', () => { expect(vm.$el.querySelector('.js-loading')).toBeNull(); expect(vm.$el.querySelector('.js-empty-state')).toBeNull(); expect(vm.$el.querySelector('.js-success-state')).not.toBeNull(); + expect(vm.$el.querySelector('.gl-pagination')).toBeNull(); + + done(); + }, 0); + }); + }); + + describe('with successful request and pagination', () => { + beforeEach(() => { + spyOn(api, 'releases').and.returnValue( + Promise.resolve({ data: releasesPagination, headers: pageInfoHeadersWithPagination }), + ); + vm = mountComponentWithStore(Component, { props, store }); + }); + + it('renders success state', done => { + setTimeout(() => { + expect(vm.$el.querySelector('.js-loading')).toBeNull(); + expect(vm.$el.querySelector('.js-empty-state')).toBeNull(); + expect(vm.$el.querySelector('.js-success-state')).not.toBeNull(); + expect(vm.$el.querySelector('.gl-pagination')).not.toBeNull(); done(); }, 0); @@ -62,7 +94,7 @@ describe('Releases App ', () => { describe('with empty request', () => { beforeEach(() => { - spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [] })); + spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [], headers: {} })); vm = mountComponentWithStore(Component, { props, store }); }); @@ -71,6 +103,7 @@ describe('Releases App ', () => { expect(vm.$el.querySelector('.js-loading')).toBeNull(); expect(vm.$el.querySelector('.js-empty-state')).not.toBeNull(); expect(vm.$el.querySelector('.js-success-state')).toBeNull(); + expect(vm.$el.querySelector('.gl-pagination')).toBeNull(); done(); }, 0); diff --git a/spec/javascripts/releases/list/store/actions_spec.js b/spec/javascripts/releases/list/store/actions_spec.js index 8e78a631a5f9e212e89ea3098ce60a9b8f7e20f1..c4b49c39e2810cd1aa5dc43b313776f3d0a43be7 100644 --- a/spec/javascripts/releases/list/store/actions_spec.js +++ b/spec/javascripts/releases/list/store/actions_spec.js @@ -7,14 +7,17 @@ import { import state from '~/releases/list/store/state'; import * as types from '~/releases/list/store/mutation_types'; import api from '~/api'; +import { parseIntPagination } from '~/lib/utils/common_utils'; import testAction from 'spec/helpers/vuex_action_helper'; -import { releases } from '../../mock_data'; +import { pageInfoHeadersWithoutPagination, releases } from '../../mock_data'; describe('Releases State actions', () => { let mockedState; + let pageInfo; beforeEach(() => { mockedState = state(); + pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination); }); describe('requestReleases', () => { @@ -25,12 +28,16 @@ describe('Releases State actions', () => { describe('fetchReleases', () => { describe('success', () => { - it('dispatches requestReleases and receiveReleasesSuccess ', done => { - spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: releases })); + it('dispatches requestReleases and receiveReleasesSuccess', done => { + spyOn(api, 'releases').and.callFake((id, options) => { + expect(id).toEqual(1); + expect(options.page).toEqual('1'); + return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }); + }); testAction( fetchReleases, - releases, + { projectId: 1 }, mockedState, [], [ @@ -38,7 +45,31 @@ describe('Releases State actions', () => { type: 'requestReleases', }, { - payload: releases, + payload: { data: releases, headers: pageInfoHeadersWithoutPagination }, + type: 'receiveReleasesSuccess', + }, + ], + done, + ); + }); + + it('dispatches requestReleases and receiveReleasesSuccess on page two', done => { + spyOn(api, 'releases').and.callFake((_, options) => { + expect(options.page).toEqual('2'); + return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }); + }); + + testAction( + fetchReleases, + { page: '2', projectId: 1 }, + mockedState, + [], + [ + { + type: 'requestReleases', + }, + { + payload: { data: releases, headers: pageInfoHeadersWithoutPagination }, type: 'receiveReleasesSuccess', }, ], @@ -48,12 +79,12 @@ describe('Releases State actions', () => { }); describe('error', () => { - it('dispatches requestReleases and receiveReleasesError ', done => { + it('dispatches requestReleases and receiveReleasesError', done => { spyOn(api, 'releases').and.returnValue(Promise.reject()); testAction( fetchReleases, - null, + { projectId: null }, mockedState, [], [ @@ -74,9 +105,9 @@ describe('Releases State actions', () => { it('should commit RECEIVE_RELEASES_SUCCESS mutation', done => { testAction( receiveReleasesSuccess, - releases, + { data: releases, headers: pageInfoHeadersWithoutPagination }, mockedState, - [{ type: types.RECEIVE_RELEASES_SUCCESS, payload: releases }], + [{ type: types.RECEIVE_RELEASES_SUCCESS, payload: { pageInfo, data: releases } }], [], done, ); diff --git a/spec/javascripts/releases/list/store/mutations_spec.js b/spec/javascripts/releases/list/store/mutations_spec.js index d2577891495a98562388685dcc2dd2742c8555fe..d756c69d53b7c6a0c3296e4cd8644ea97e150e78 100644 --- a/spec/javascripts/releases/list/store/mutations_spec.js +++ b/spec/javascripts/releases/list/store/mutations_spec.js @@ -1,13 +1,16 @@ import state from '~/releases/list/store/state'; import mutations from '~/releases/list/store/mutations'; import * as types from '~/releases/list/store/mutation_types'; -import { releases } from '../../mock_data'; +import { parseIntPagination } from '~/lib/utils/common_utils'; +import { pageInfoHeadersWithoutPagination, releases } from '../../mock_data'; describe('Releases Store Mutations', () => { let stateCopy; + let pageInfo; beforeEach(() => { stateCopy = state(); + pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination); }); describe('REQUEST_RELEASES', () => { @@ -20,7 +23,7 @@ describe('Releases Store Mutations', () => { describe('RECEIVE_RELEASES_SUCCESS', () => { beforeEach(() => { - mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, releases); + mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { pageInfo, data: releases }); }); it('sets is loading to false', () => { @@ -34,6 +37,10 @@ describe('Releases Store Mutations', () => { it('sets data', () => { expect(stateCopy.releases).toEqual(releases); }); + + it('sets pageInfo', () => { + expect(stateCopy.pageInfo).toEqual(pageInfo); + }); }); describe('RECEIVE_RELEASES_ERROR', () => { @@ -42,6 +49,7 @@ describe('Releases Store Mutations', () => { expect(stateCopy.isLoading).toEqual(false); expect(stateCopy.releases).toEqual([]); + expect(stateCopy.pageInfo).toEqual({}); }); }); }); diff --git a/spec/javascripts/releases/mock_data.js b/spec/javascripts/releases/mock_data.js index 7197eb7bca8bcb64c655d3d18d0bf427664808ef..72875dff1720833b74a0987d05f85d3eae3a74ed 100644 --- a/spec/javascripts/releases/mock_data.js +++ b/spec/javascripts/releases/mock_data.js @@ -1,3 +1,21 @@ +export const pageInfoHeadersWithoutPagination = { + 'X-NEXT-PAGE': '', + 'X-PAGE': '1', + 'X-PER-PAGE': '20', + 'X-PREV-PAGE': '', + 'X-TOTAL': '19', + 'X-TOTAL-PAGES': '1', +}; + +export const pageInfoHeadersWithPagination = { + 'X-NEXT-PAGE': '2', + 'X-PAGE': '1', + 'X-PER-PAGE': '20', + 'X-PREV-PAGE': '', + 'X-TOTAL': '21', + 'X-TOTAL-PAGES': '2', +}; + export const release = { name: 'Bionic Beaver', tag_name: '18.04',