Commit 33f60e23 authored by Fabio Huser's avatar Fabio Huser Committed by Clement Ho

Project Releases pagination implementation

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