Commit de3430f0 authored by Clement Ho's avatar Clement Ho

Merge branch 'feat/ui-releases-pagination' into 'master'

Project Releases pagination implementation

Closes #14955

See merge request gitlab-org/gitlab!19912
parents c3abd803 33f60e23
......@@ -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) {
......
<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>
......
......@@ -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);
......
......@@ -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;
},
/**
......
......@@ -2,4 +2,5 @@ export default () => ({
isLoading: false,
hasError: false,
releases: [],
pageInfo: {},
});
---
title: Implement pagination for project releases page
merge_request: 19912
author: Fabio Huser
type: added
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);
......
......@@ -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,
);
......
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({});
});
});
});
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',
......
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