Commit f1e0a004 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch 'nfriend-add-graphql-releases-page-pagination' into 'master'

Step 4/4: Add pagination to GraphQL release page

See merge request gitlab-org/gitlab!41441
parents 06c6774a 82d51739
...@@ -6,14 +6,10 @@ import { ...@@ -6,14 +6,10 @@ import {
GlLink, GlLink,
GlButton, GlButton,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { import { getParameterByName } from '~/lib/utils/common_utils';
getParameterByName,
historyPushState,
buildUrlWithCurrentLocation,
} from '~/lib/utils/common_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import ReleaseBlock from './release_block.vue'; import ReleaseBlock from './release_block.vue';
import ReleasesPagination from './releases_pagination.vue';
export default { export default {
name: 'ReleasesApp', name: 'ReleasesApp',
...@@ -21,7 +17,7 @@ export default { ...@@ -21,7 +17,7 @@ export default {
GlSkeletonLoading, GlSkeletonLoading,
GlEmptyState, GlEmptyState,
ReleaseBlock, ReleaseBlock,
TablePagination, ReleasesPagination,
GlLink, GlLink,
GlButton, GlButton,
}, },
...@@ -33,7 +29,6 @@ export default { ...@@ -33,7 +29,6 @@ export default {
'isLoading', 'isLoading',
'releases', 'releases',
'hasError', 'hasError',
'pageInfo',
]), ]),
shouldRenderEmptyState() { shouldRenderEmptyState() {
return !this.releases.length && !this.hasError && !this.isLoading; return !this.releases.length && !this.hasError && !this.isLoading;
...@@ -48,15 +43,23 @@ export default { ...@@ -48,15 +43,23 @@ export default {
}, },
}, },
created() { created() {
this.fetchReleases({ this.fetchReleases();
page: getParameterByName('page'),
}); window.addEventListener('popstate', this.fetchReleases);
}, },
methods: { methods: {
...mapActions('list', ['fetchReleases']), ...mapActions('list', {
onChangePage(page) { fetchReleasesStoreAction: 'fetchReleases',
historyPushState(buildUrlWithCurrentLocation(`?page=${page}`)); }),
this.fetchReleases({ page }); fetchReleases() {
this.fetchReleasesStoreAction({
// these two parameters are only used in "GraphQL mode"
before: getParameterByName('before'),
after: getParameterByName('after'),
// this parameter is only used when in "REST mode"
page: getParameterByName('page'),
});
}, },
}, },
}; };
...@@ -105,7 +108,7 @@ export default { ...@@ -105,7 +108,7 @@ export default {
/> />
</div> </div>
<table-pagination v-if="!isLoading" :change="onChangePage" :page-info="pageInfo" /> <releases-pagination v-if="!isLoading" />
</div> </div>
</template> </template>
<style> <style>
......
...@@ -13,14 +13,14 @@ export default { ...@@ -13,14 +13,14 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions('list', ['fetchReleasesGraphQl']), ...mapActions('list', ['fetchReleases']),
onPrev(before) { onPrev(before) {
historyPushState(buildUrlWithCurrentLocation(`?before=${before}`)); historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
this.fetchReleasesGraphQl({ before }); this.fetchReleases({ before });
}, },
onNext(after) { onNext(after) {
historyPushState(buildUrlWithCurrentLocation(`?after=${after}`)); historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
this.fetchReleasesGraphQl({ after }); this.fetchReleases({ after });
}, },
}, },
}; };
......
...@@ -7,18 +7,18 @@ export default { ...@@ -7,18 +7,18 @@ export default {
name: 'ReleasesPaginationRest', name: 'ReleasesPaginationRest',
components: { TablePagination }, components: { TablePagination },
computed: { computed: {
...mapState('list', ['pageInfo']), ...mapState('list', ['restPageInfo']),
}, },
methods: { methods: {
...mapActions('list', ['fetchReleasesRest']), ...mapActions('list', ['fetchReleases']),
onChangePage(page) { onChangePage(page) {
historyPushState(buildUrlWithCurrentLocation(`?page=${page}`)); historyPushState(buildUrlWithCurrentLocation(`?page=${page}`));
this.fetchReleasesRest({ page }); this.fetchReleases({ page });
}, },
}, },
}; };
</script> </script>
<template> <template>
<table-pagination :change="onChangePage" :page-info="pageInfo" /> <table-pagination :change="onChangePage" :page-info="restPageInfo" />
</template> </template>
...@@ -10,3 +10,5 @@ export const ASSET_LINK_TYPE = Object.freeze({ ...@@ -10,3 +10,5 @@ export const ASSET_LINK_TYPE = Object.freeze({
}); });
export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER; export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER;
export const PAGE_SIZE = 20;
query allReleases($fullPath: ID!) { query allReleases($fullPath: ID!, $first: Int, $last: Int, $before: String, $after: String) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
releases(first: 20) { releases(first: $first, last: $last, before: $before, after: $after) {
count
nodes { nodes {
name name
tagName tagName
...@@ -64,6 +63,12 @@ query allReleases($fullPath: ID!) { ...@@ -64,6 +63,12 @@ query allReleases($fullPath: ID!) {
} }
} }
} }
pageInfo {
startCursor
hasPreviousPage
hasNextPage
endCursor
}
} }
} }
} }
/**
* @returns {Boolean} `true` if all the feature flags
* required to enable the GraphQL endpoint are enabled
*/
export const useGraphQLEndpoint = rootState => {
return Boolean(
rootState.featureFlags.graphqlReleaseData &&
rootState.featureFlags.graphqlReleasesPage &&
rootState.featureFlags.graphqlMilestoneStats,
);
};
import Vuex from 'vuex'; import Vuex from 'vuex';
import * as getters from './getters';
export default ({ modules, featureFlags }) => export default ({ modules, featureFlags }) =>
new Vuex.Store({ new Vuex.Store({
modules, modules,
state: { featureFlags }, state: { featureFlags },
getters,
}); });
...@@ -9,54 +9,89 @@ import { ...@@ -9,54 +9,89 @@ import {
} from '~/lib/utils/common_utils'; } from '~/lib/utils/common_utils';
import allReleasesQuery from '~/releases/queries/all_releases.query.graphql'; import allReleasesQuery from '~/releases/queries/all_releases.query.graphql';
import { gqClient, convertGraphQLResponse } from '../../../util'; import { gqClient, convertGraphQLResponse } from '../../../util';
import { PAGE_SIZE } from '../../../constants';
/** /**
* Commits a mutation to update the state while the main endpoint is being requested. * Gets a paginated list of releases from the server
*
* @param {Object} vuexParams
* @param {Object} actionParams
* @param {Number} [actionParams.page] The page number of results to fetch
* (this parameter is only used when fetching results from the REST API)
* @param {String} [actionParams.before] A GraphQL cursor. If provided,
* the items returned will proceed the provided cursor (this parameter is only
* used when fetching results from the GraphQL API).
* @param {String} [actionParams.after] A GraphQL cursor. If provided,
* the items returned will follow the provided cursor (this parameter is only
* used when fetching results from the GraphQL API).
*/ */
export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES); export const fetchReleases = ({ dispatch, rootGetters }, { page = 1, before, after }) => {
if (rootGetters.useGraphQLEndpoint) {
dispatch('fetchReleasesGraphQl', { before, after });
} else {
dispatch('fetchReleasesRest', { page });
}
};
/** /**
* Fetches the main endpoint. * Gets a paginated list of releases from the GraphQL endpoint
* Will dispatch requestNamespace action before starting the request.
* Will dispatch receiveNamespaceSuccess if the request is successful
* Will dispatch receiveNamesapceError if the request returns an error
*
* @param {String} projectId
*/ */
export const fetchReleases = ({ dispatch, rootState, state }, { page = '1' }) => { export const fetchReleasesGraphQl = (
dispatch('requestReleases'); { dispatch, commit, state },
{ before = null, after = null },
) => {
commit(types.REQUEST_RELEASES);
let paginationParams;
if (!before && !after) {
paginationParams = { first: PAGE_SIZE };
} else if (before && !after) {
paginationParams = { last: PAGE_SIZE, before };
} else if (!before && after) {
paginationParams = { first: PAGE_SIZE, after };
} else {
throw new Error(
'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.',
);
}
if (
rootState.featureFlags.graphqlReleaseData &&
rootState.featureFlags.graphqlReleasesPage &&
rootState.featureFlags.graphqlMilestoneStats
) {
gqClient gqClient
.query({ .query({
query: allReleasesQuery, query: allReleasesQuery,
variables: { variables: {
fullPath: state.projectPath, fullPath: state.projectPath,
...paginationParams,
}, },
}) })
.then(response => { .then(response => {
dispatch('receiveReleasesSuccess', convertGraphQLResponse(response)); const { data, paginationInfo: graphQlPageInfo } = convertGraphQLResponse(response);
commit(types.RECEIVE_RELEASES_SUCCESS, {
data,
graphQlPageInfo,
});
}) })
.catch(() => dispatch('receiveReleasesError')); .catch(() => dispatch('receiveReleasesError'));
} else {
api
.releases(state.projectId, { page })
.then(response => dispatch('receiveReleasesSuccess', response))
.catch(() => dispatch('receiveReleasesError'));
}
}; };
export const receiveReleasesSuccess = ({ commit }, { data, headers }) => { /**
const pageInfo = parseIntPagination(normalizeHeaders(headers)); * Gets a paginated list of releases from the REST endpoint
*/
export const fetchReleasesRest = ({ dispatch, commit, state }, { page }) => {
commit(types.REQUEST_RELEASES);
api
.releases(state.projectId, { page })
.then(({ data, headers }) => {
const restPageInfo = parseIntPagination(normalizeHeaders(headers));
const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true }); const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true });
commit(types.RECEIVE_RELEASES_SUCCESS, { commit(types.RECEIVE_RELEASES_SUCCESS, {
data: camelCasedReleases, data: camelCasedReleases,
pageInfo, restPageInfo,
}); });
})
.catch(() => dispatch('receiveReleasesError'));
}; };
export const receiveReleasesError = ({ commit }) => { export const receiveReleasesError = ({ commit }) => {
......
...@@ -17,11 +17,12 @@ export default { ...@@ -17,11 +17,12 @@ export default {
* @param {Object} state * @param {Object} state
* @param {Object} resp * @param {Object} resp
*/ */
[types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) { [types.RECEIVE_RELEASES_SUCCESS](state, { data, restPageInfo, graphQlPageInfo }) {
state.hasError = false; state.hasError = false;
state.isLoading = false; state.isLoading = false;
state.releases = data; state.releases = data;
state.pageInfo = pageInfo; state.restPageInfo = restPageInfo;
state.graphQlPageInfo = graphQlPageInfo;
}, },
/** /**
...@@ -35,5 +36,7 @@ export default { ...@@ -35,5 +36,7 @@ export default {
state.isLoading = false; state.isLoading = false;
state.releases = []; state.releases = [];
state.hasError = true; state.hasError = true;
state.restPageInfo = {};
state.graphQlPageInfo = {};
}, },
}; };
...@@ -14,5 +14,6 @@ export default ({ ...@@ -14,5 +14,6 @@ export default ({
isLoading: false, isLoading: false,
hasError: false, hasError: false,
releases: [], releases: [],
pageInfo: {}, restPageInfo: {},
graphQlPageInfo: {},
}); });
...@@ -126,5 +126,9 @@ export const convertGraphQLResponse = response => { ...@@ -126,5 +126,9 @@ export const convertGraphQLResponse = response => {
...convertMilestones(r), ...convertMilestones(r),
})); }));
return { data: releases }; const paginationInfo = {
...response.data.project.releases.pageInfo,
};
return { data: releases, paginationInfo };
}; };
...@@ -13,7 +13,7 @@ class Projects::ReleasesController < Projects::ApplicationController ...@@ -13,7 +13,7 @@ class Projects::ReleasesController < Projects::ApplicationController
push_frontend_feature_flag(:release_asset_link_type, project, default_enabled: true) push_frontend_feature_flag(:release_asset_link_type, project, default_enabled: true)
push_frontend_feature_flag(:graphql_release_data, project, default_enabled: true) push_frontend_feature_flag(:graphql_release_data, project, default_enabled: true)
push_frontend_feature_flag(:graphql_milestone_stats, project, default_enabled: true) push_frontend_feature_flag(:graphql_milestone_stats, project, default_enabled: true)
push_frontend_feature_flag(:graphql_releases_page, project, default_enabled: false) push_frontend_feature_flag(:graphql_releases_page, project, default_enabled: true)
end end
before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_create_release!, only: :new before_action :authorize_create_release!, only: :new
......
...@@ -109,5 +109,11 @@ Object { ...@@ -109,5 +109,11 @@ Object {
"upcomingRelease": false, "upcomingRelease": false,
}, },
], ],
"paginationInfo": Object {
"endCursor": "eyJpZCI6IjMiLCJyZWxlYXNlZF9hdCI6IjIwMjAtMDctMDkgMjA6MTE6MzMuODA0OTYxMDAwIFVUQyJ9",
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "eyJpZCI6IjQ0IiwicmVsZWFzZWRfYXQiOiIyMDMwLTAzLTE1IDA4OjAwOjAwLjAwMDAwMDAwMCBVVEMifQ",
},
} }
`; `;
...@@ -13,7 +13,14 @@ import { ...@@ -13,7 +13,14 @@ import {
releases, releases,
} from '../mock_data'; } from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import ReleasesPagination from '~/releases/components/releases_pagination.vue';
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
getParameterByName: jest.fn().mockImplementation(paramName => {
return `${paramName}_param_value`;
}),
}));
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -22,7 +29,7 @@ describe('Releases App ', () => { ...@@ -22,7 +29,7 @@ describe('Releases App ', () => {
let wrapper; let wrapper;
let fetchReleaseSpy; let fetchReleaseSpy;
const releasesPagination = rge(21).map(index => ({ const paginatedReleases = rge(21).map(index => ({
...convertObjectPropsToCamelCase(release, { deep: true }), ...convertObjectPropsToCamelCase(release, { deep: true }),
tagName: `${index}.00`, tagName: `${index}.00`,
})); }));
...@@ -70,9 +77,13 @@ describe('Releases App ', () => { ...@@ -70,9 +77,13 @@ describe('Releases App ', () => {
createComponent(); createComponent();
}); });
it('calls fetchRelease with the page parameter', () => { it('calls fetchRelease with the page, before, and after parameters', () => {
expect(fetchReleaseSpy).toHaveBeenCalledTimes(1); expect(fetchReleaseSpy).toHaveBeenCalledTimes(1);
expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), { page: null }); expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), {
page: 'page_param_value',
before: 'before_param_value',
after: 'after_param_value',
});
}); });
}); });
...@@ -91,7 +102,7 @@ describe('Releases App ', () => { ...@@ -91,7 +102,7 @@ describe('Releases App ', () => {
expect(wrapper.contains('.js-loading')).toBe(true); expect(wrapper.contains('.js-loading')).toBe(true);
expect(wrapper.contains('.js-empty-state')).toBe(false); expect(wrapper.contains('.js-empty-state')).toBe(false);
expect(wrapper.contains('.js-success-state')).toBe(false); expect(wrapper.contains('.js-success-state')).toBe(false);
expect(wrapper.contains(TablePagination)).toBe(false); expect(wrapper.contains(ReleasesPagination)).toBe(false);
}); });
}); });
...@@ -108,7 +119,7 @@ describe('Releases App ', () => { ...@@ -108,7 +119,7 @@ describe('Releases App ', () => {
expect(wrapper.contains('.js-loading')).toBe(false); expect(wrapper.contains('.js-loading')).toBe(false);
expect(wrapper.contains('.js-empty-state')).toBe(false); expect(wrapper.contains('.js-empty-state')).toBe(false);
expect(wrapper.contains('.js-success-state')).toBe(true); expect(wrapper.contains('.js-success-state')).toBe(true);
expect(wrapper.contains(TablePagination)).toBe(true); expect(wrapper.contains(ReleasesPagination)).toBe(true);
}); });
}); });
...@@ -116,7 +127,7 @@ describe('Releases App ', () => { ...@@ -116,7 +127,7 @@ describe('Releases App ', () => {
beforeEach(() => { beforeEach(() => {
jest jest
.spyOn(api, 'releases') .spyOn(api, 'releases')
.mockResolvedValue({ data: releasesPagination, headers: pageInfoHeadersWithPagination }); .mockResolvedValue({ data: paginatedReleases, headers: pageInfoHeadersWithPagination });
createComponent(); createComponent();
}); });
...@@ -125,7 +136,7 @@ describe('Releases App ', () => { ...@@ -125,7 +136,7 @@ describe('Releases App ', () => {
expect(wrapper.contains('.js-loading')).toBe(false); expect(wrapper.contains('.js-loading')).toBe(false);
expect(wrapper.contains('.js-empty-state')).toBe(false); expect(wrapper.contains('.js-empty-state')).toBe(false);
expect(wrapper.contains('.js-success-state')).toBe(true); expect(wrapper.contains('.js-success-state')).toBe(true);
expect(wrapper.contains(TablePagination)).toBe(true); expect(wrapper.contains(ReleasesPagination)).toBe(true);
}); });
}); });
...@@ -154,7 +165,7 @@ describe('Releases App ', () => { ...@@ -154,7 +165,7 @@ describe('Releases App ', () => {
const newReleasePath = 'path/to/new/release'; const newReleasePath = 'path/to/new/release';
beforeEach(() => { beforeEach(() => {
createComponent({ ...defaultInitialState, newReleasePath }); createComponent({ newReleasePath });
}); });
it('renders the "New release" button', () => { it('renders the "New release" button', () => {
...@@ -174,4 +185,27 @@ describe('Releases App ', () => { ...@@ -174,4 +185,27 @@ describe('Releases App ', () => {
}); });
}); });
}); });
describe('when the back button is pressed', () => {
beforeEach(() => {
jest
.spyOn(api, 'releases')
.mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
createComponent();
fetchReleaseSpy.mockClear();
window.dispatchEvent(new PopStateEvent('popstate'));
});
it('calls fetchRelease with the page parameter', () => {
expect(fetchReleaseSpy).toHaveBeenCalledTimes(1);
expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), {
page: 'page_param_value',
before: 'before_param_value',
after: 'after_param_value',
});
});
});
}); });
...@@ -29,7 +29,7 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => { ...@@ -29,7 +29,7 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => {
listModule.state.graphQlPageInfo = pageInfo; listModule.state.graphQlPageInfo = pageInfo;
listModule.actions.fetchReleasesGraphQl = jest.fn(); listModule.actions.fetchReleases = jest.fn();
wrapper = mount(ReleasesPaginationGraphql, { wrapper = mount(ReleasesPaginationGraphql, {
store: createStore({ store: createStore({
...@@ -141,8 +141,8 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => { ...@@ -141,8 +141,8 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => {
findNextButton().trigger('click'); findNextButton().trigger('click');
}); });
it('calls fetchReleasesGraphQl with the correct after cursor', () => { it('calls fetchReleases with the correct after cursor', () => {
expect(listModule.actions.fetchReleasesGraphQl.mock.calls).toEqual([ expect(listModule.actions.fetchReleases.mock.calls).toEqual([
[expect.anything(), { after: cursors.endCursor }], [expect.anything(), { after: cursors.endCursor }],
]); ]);
}); });
...@@ -159,8 +159,8 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => { ...@@ -159,8 +159,8 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => {
findPrevButton().trigger('click'); findPrevButton().trigger('click');
}); });
it('calls fetchReleasesGraphQl with the correct before cursor', () => { it('calls fetchReleases with the correct before cursor', () => {
expect(listModule.actions.fetchReleasesGraphQl.mock.calls).toEqual([ expect(listModule.actions.fetchReleases.mock.calls).toEqual([
[expect.anything(), { before: cursors.startCursor }], [expect.anything(), { before: cursors.startCursor }],
]); ]);
}); });
......
...@@ -20,9 +20,9 @@ describe('~/releases/components/releases_pagination_rest.vue', () => { ...@@ -20,9 +20,9 @@ describe('~/releases/components/releases_pagination_rest.vue', () => {
const createComponent = pageInfo => { const createComponent = pageInfo => {
listModule = createListModule({ projectId }); listModule = createListModule({ projectId });
listModule.state.pageInfo = pageInfo; listModule.state.restPageInfo = pageInfo;
listModule.actions.fetchReleasesRest = jest.fn(); listModule.actions.fetchReleases = jest.fn();
wrapper = mount(ReleasesPaginationRest, { wrapper = mount(ReleasesPaginationRest, {
store: createStore({ store: createStore({
...@@ -57,8 +57,8 @@ describe('~/releases/components/releases_pagination_rest.vue', () => { ...@@ -57,8 +57,8 @@ describe('~/releases/components/releases_pagination_rest.vue', () => {
findGlPagination().vm.$emit('input', newPage); findGlPagination().vm.$emit('input', newPage);
}); });
it('calls fetchReleasesRest with the correct page', () => { it('calls fetchReleases with the correct page', () => {
expect(listModule.actions.fetchReleasesRest.mock.calls).toEqual([ expect(listModule.actions.fetchReleases.mock.calls).toEqual([
[expect.anything(), { page: newPage }], [expect.anything(), { page: newPage }],
]); ]);
}); });
......
...@@ -346,6 +346,14 @@ export const graphqlReleasesResponse = { ...@@ -346,6 +346,14 @@ export const graphqlReleasesResponse = {
}, },
}, },
], ],
pageInfo: {
startCursor:
'eyJpZCI6IjQ0IiwicmVsZWFzZWRfYXQiOiIyMDMwLTAzLTE1IDA4OjAwOjAwLjAwMDAwMDAwMCBVVEMifQ',
hasPreviousPage: false,
hasNextPage: true,
endCursor:
'eyJpZCI6IjMiLCJyZWxlYXNlZF9hdCI6IjIwMjAtMDctMDkgMjA6MTE6MzMuODA0OTYxMDAwIFVUQyJ9',
},
}, },
}, },
}, },
......
import * as getters from '~/releases/stores/getters';
describe('~/releases/stores/getters.js', () => {
it.each`
graphqlReleaseData | graphqlReleasesPage | graphqlMilestoneStats | result
${false} | ${false} | ${false} | ${false}
${false} | ${false} | ${true} | ${false}
${false} | ${true} | ${false} | ${false}
${false} | ${true} | ${true} | ${false}
${true} | ${false} | ${false} | ${false}
${true} | ${false} | ${true} | ${false}
${true} | ${true} | ${false} | ${false}
${true} | ${true} | ${true} | ${true}
`(
'returns $result with feature flag values graphqlReleaseData=$graphqlReleaseData, graphqlReleasesPage=$graphqlReleasesPage, and graphqlMilestoneStats=$graphqlMilestoneStats',
({ result: expectedResult, ...featureFlags }) => {
const actualResult = getters.useGraphQLEndpoint({ featureFlags });
expect(actualResult).toBe(expectedResult);
},
);
});
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import { import {
requestReleases,
fetchReleases, fetchReleases,
receiveReleasesSuccess, fetchReleasesGraphQl,
fetchReleasesRest,
receiveReleasesError, receiveReleasesError,
} from '~/releases/stores/modules/list/actions'; } from '~/releases/stores/modules/list/actions';
import createState from '~/releases/stores/modules/list/state'; import createState from '~/releases/stores/modules/list/state';
import * as types from '~/releases/stores/modules/list/mutation_types'; import * as types from '~/releases/stores/modules/list/mutation_types';
import api from '~/api'; import api from '~/api';
import { gqClient, convertGraphQLResponse } from '~/releases/util'; import { gqClient, convertGraphQLResponse } from '~/releases/util';
import { parseIntPagination, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import {
normalizeHeaders,
parseIntPagination,
convertObjectPropsToCamelCase,
} from '~/lib/utils/common_utils';
import { import {
pageInfoHeadersWithoutPagination, pageInfoHeadersWithoutPagination,
releases as originalReleases, releases as originalReleases,
graphqlReleasesResponse as originalGraphqlReleasesResponse, graphqlReleasesResponse as originalGraphqlReleasesResponse,
} from '../../../mock_data'; } from '../../../mock_data';
import allReleasesQuery from '~/releases/queries/all_releases.query.graphql'; import allReleasesQuery from '~/releases/queries/all_releases.query.graphql';
import { PAGE_SIZE } from '~/releases/constants';
describe('Releases State actions', () => { describe('Releases State actions', () => {
let mockedState; let mockedState;
let pageInfo;
let releases; let releases;
let graphqlReleasesResponse; let graphqlReleasesResponse;
const projectPath = 'root/test-project'; const projectPath = 'root/test-project';
const projectId = 19; const projectId = 19;
const before = 'testBeforeCursor';
const after = 'testAfterCursor';
const page = 2;
beforeEach(() => { beforeEach(() => {
mockedState = { mockedState = {
...@@ -33,178 +40,261 @@ describe('Releases State actions', () => { ...@@ -33,178 +40,261 @@ describe('Releases State actions', () => {
projectId, projectId,
projectPath, projectPath,
}), }),
featureFlags: {
graphqlReleaseData: true,
graphqlReleasesPage: true,
graphqlMilestoneStats: true,
},
}; };
pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
releases = convertObjectPropsToCamelCase(originalReleases, { deep: true }); releases = convertObjectPropsToCamelCase(originalReleases, { deep: true });
graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse); graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse);
}); });
describe('requestReleases', () => { describe('when all the necessary GraphQL feature flags are enabled', () => {
it('should commit REQUEST_RELEASES mutation', done => { beforeEach(() => {
testAction(requestReleases, null, mockedState, [{ type: types.REQUEST_RELEASES }], [], done); mockedState.useGraphQLEndpoint = true;
});
}); });
describe('fetchReleases', () => { describe('fetchReleases', () => {
describe('success', () => { it('dispatches fetchReleasesGraphQl with before and after parameters', () => {
it('dispatches requestReleases and receiveReleasesSuccess', done => { return testAction(
jest.spyOn(gqClient, 'query').mockImplementation(({ query, variables }) => {
expect(query).toBe(allReleasesQuery);
expect(variables).toEqual({
fullPath: projectPath,
});
return Promise.resolve(graphqlReleasesResponse);
});
testAction(
fetchReleases, fetchReleases,
{}, { before, after, page },
mockedState, mockedState,
[], [],
[ [
{ {
type: 'requestReleases', type: 'fetchReleasesGraphQl',
}, payload: { before, after },
{
payload: convertGraphQLResponse(graphqlReleasesResponse),
type: 'receiveReleasesSuccess',
}, },
], ],
done,
); );
}); });
}); });
});
describe('error', () => { describe('when at least one of the GraphQL feature flags is disabled', () => {
it('dispatches requestReleases and receiveReleasesError', done => { beforeEach(() => {
jest.spyOn(gqClient, 'query').mockRejectedValue(); mockedState.useGraphQLEndpoint = false;
});
testAction( describe('fetchReleases', () => {
it('dispatches fetchReleasesRest with a page parameter', () => {
return testAction(
fetchReleases, fetchReleases,
{}, { before, after, page },
mockedState, mockedState,
[], [],
[ [
{ {
type: 'requestReleases', type: 'fetchReleasesRest',
}, payload: { page },
{
type: 'receiveReleasesError',
}, },
], ],
done,
); );
}); });
}); });
});
describe('fetchReleasesGraphQl', () => {
describe('GraphQL query variables', () => {
let vuexParams;
describe('when the graphqlReleaseData feature flag is disabled', () => {
beforeEach(() => { beforeEach(() => {
mockedState.featureFlags.graphqlReleasesPage = false; jest.spyOn(gqClient, 'query');
vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState };
}); });
describe('success', () => { describe('when neither a before nor an after parameter is provided', () => {
it('dispatches requestReleases and receiveReleasesSuccess', done => { beforeEach(() => {
jest.spyOn(api, 'releases').mockImplementation((id, options) => { fetchReleasesGraphQl(vuexParams, { before: undefined, after: undefined });
expect(id).toBe(projectId);
expect(options.page).toBe('1');
return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
}); });
testAction( it('makes a GraphQl query with a first variable', () => {
fetchReleases, expect(gqClient.query).toHaveBeenCalledWith({
query: allReleasesQuery,
variables: { fullPath: projectPath, first: PAGE_SIZE },
});
});
});
describe('when only a before parameter is provided', () => {
beforeEach(() => {
fetchReleasesGraphQl(vuexParams, { before, after: undefined });
});
it('makes a GraphQl query with last and before variables', () => {
expect(gqClient.query).toHaveBeenCalledWith({
query: allReleasesQuery,
variables: { fullPath: projectPath, last: PAGE_SIZE, before },
});
});
});
describe('when only an after parameter is provided', () => {
beforeEach(() => {
fetchReleasesGraphQl(vuexParams, { before: undefined, after });
});
it('makes a GraphQl query with first and after variables', () => {
expect(gqClient.query).toHaveBeenCalledWith({
query: allReleasesQuery,
variables: { fullPath: projectPath, first: PAGE_SIZE, after },
});
});
});
describe('when both before and after parameters are provided', () => {
it('throws an error', () => {
const callFetchReleasesGraphQl = () => {
fetchReleasesGraphQl(vuexParams, { before, after });
};
expect(callFetchReleasesGraphQl).toThrowError(
'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.',
);
});
});
});
describe('when the request is successful', () => {
beforeEach(() => {
jest.spyOn(gqClient, 'query').mockResolvedValue(graphqlReleasesResponse);
});
it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => {
const convertedResponse = convertGraphQLResponse(graphqlReleasesResponse);
return testAction(
fetchReleasesGraphQl,
{}, {},
mockedState, mockedState,
[],
[ [
{ {
type: 'requestReleases', type: types.REQUEST_RELEASES,
}, },
{ {
payload: { data: releases, headers: pageInfoHeadersWithoutPagination }, type: types.RECEIVE_RELEASES_SUCCESS,
type: 'receiveReleasesSuccess', payload: {
data: convertedResponse.data,
graphQlPageInfo: convertedResponse.paginationInfo,
},
}, },
], ],
done, [],
); );
}); });
});
it('dispatches requestReleases and receiveReleasesSuccess on page two', done => { describe('when the request fails', () => {
jest.spyOn(api, 'releases').mockImplementation((_, options) => { beforeEach(() => {
expect(options.page).toBe('2'); jest.spyOn(gqClient, 'query').mockRejectedValue(new Error('Something went wrong!'));
return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
}); });
testAction( it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => {
fetchReleases, return testAction(
{ page: '2' }, fetchReleasesGraphQl,
{},
mockedState, mockedState,
[],
[ [
{ {
type: 'requestReleases', type: types.REQUEST_RELEASES,
}, },
],
[
{ {
payload: { data: releases, headers: pageInfoHeadersWithoutPagination }, type: 'receiveReleasesError',
type: 'receiveReleasesSuccess',
}, },
], ],
done,
); );
}); });
}); });
});
describe('error', () => { describe('fetchReleasesRest', () => {
it('dispatches requestReleases and receiveReleasesError', done => { describe('REST query parameters', () => {
jest.spyOn(api, 'releases').mockReturnValue(Promise.reject()); let vuexParams;
testAction( beforeEach(() => {
fetchReleases, jest
.spyOn(api, 'releases')
.mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState };
});
describe('when a page parameter is provided', () => {
beforeEach(() => {
fetchReleasesRest(vuexParams, { page: 2 });
});
it('makes a REST query with a page query parameter', () => {
expect(api.releases).toHaveBeenCalledWith(projectId, { page });
});
});
});
describe('when the request is successful', () => {
beforeEach(() => {
jest
.spyOn(api, 'releases')
.mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
});
it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => {
return testAction(
fetchReleasesRest,
{}, {},
mockedState, mockedState,
[],
[ [
{ {
type: 'requestReleases', type: types.REQUEST_RELEASES,
}, },
{ {
type: 'receiveReleasesError', type: types.RECEIVE_RELEASES_SUCCESS,
payload: {
data: convertObjectPropsToCamelCase(releases, { deep: true }),
restPageInfo: parseIntPagination(
normalizeHeaders(pageInfoHeadersWithoutPagination),
),
},
}, },
], ],
done, [],
); );
}); });
}); });
});
describe('when the request fails', () => {
beforeEach(() => {
jest.spyOn(api, 'releases').mockRejectedValue(new Error('Something went wrong!'));
}); });
describe('receiveReleasesSuccess', () => { it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => {
it('should commit RECEIVE_RELEASES_SUCCESS mutation', done => { return testAction(
testAction( fetchReleasesRest,
receiveReleasesSuccess, {},
{ data: releases, headers: pageInfoHeadersWithoutPagination },
mockedState, mockedState,
[{ type: types.RECEIVE_RELEASES_SUCCESS, payload: { pageInfo, data: releases } }], [
[], {
done, type: types.REQUEST_RELEASES,
},
],
[
{
type: 'receiveReleasesError',
},
],
); );
}); });
}); });
});
describe('receiveReleasesError', () => { describe('receiveReleasesError', () => {
it('should commit RECEIVE_RELEASES_ERROR mutation', done => { it('should commit RECEIVE_RELEASES_ERROR mutation', () => {
testAction( return testAction(
receiveReleasesError, receiveReleasesError,
null, null,
mockedState, mockedState,
[{ type: types.RECEIVE_RELEASES_ERROR }], [{ type: types.RECEIVE_RELEASES_ERROR }],
[], [],
done,
); );
}); });
}); });
......
...@@ -2,15 +2,22 @@ import createState from '~/releases/stores/modules/list/state'; ...@@ -2,15 +2,22 @@ import createState from '~/releases/stores/modules/list/state';
import mutations from '~/releases/stores/modules/list/mutations'; import mutations from '~/releases/stores/modules/list/mutations';
import * as types from '~/releases/stores/modules/list/mutation_types'; import * as types from '~/releases/stores/modules/list/mutation_types';
import { parseIntPagination } from '~/lib/utils/common_utils'; import { parseIntPagination } from '~/lib/utils/common_utils';
import { pageInfoHeadersWithoutPagination, releases } from '../../../mock_data'; import {
pageInfoHeadersWithoutPagination,
releases,
graphqlReleasesResponse,
} from '../../../mock_data';
import { convertGraphQLResponse } from '~/releases/util';
describe('Releases Store Mutations', () => { describe('Releases Store Mutations', () => {
let stateCopy; let stateCopy;
let pageInfo; let restPageInfo;
let graphQlPageInfo;
beforeEach(() => { beforeEach(() => {
stateCopy = createState({}); stateCopy = createState({});
pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination); restPageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
graphQlPageInfo = convertGraphQLResponse(graphqlReleasesResponse).paginationInfo;
}); });
describe('REQUEST_RELEASES', () => { describe('REQUEST_RELEASES', () => {
...@@ -23,7 +30,11 @@ describe('Releases Store Mutations', () => { ...@@ -23,7 +30,11 @@ describe('Releases Store Mutations', () => {
describe('RECEIVE_RELEASES_SUCCESS', () => { describe('RECEIVE_RELEASES_SUCCESS', () => {
beforeEach(() => { beforeEach(() => {
mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { pageInfo, data: releases }); mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, {
restPageInfo,
graphQlPageInfo,
data: releases,
});
}); });
it('sets is loading to false', () => { it('sets is loading to false', () => {
...@@ -38,18 +49,29 @@ describe('Releases Store Mutations', () => { ...@@ -38,18 +49,29 @@ describe('Releases Store Mutations', () => {
expect(stateCopy.releases).toEqual(releases); expect(stateCopy.releases).toEqual(releases);
}); });
it('sets pageInfo', () => { it('sets restPageInfo', () => {
expect(stateCopy.pageInfo).toEqual(pageInfo); expect(stateCopy.restPageInfo).toEqual(restPageInfo);
});
it('sets graphQlPageInfo', () => {
expect(stateCopy.graphQlPageInfo).toEqual(graphQlPageInfo);
}); });
}); });
describe('RECEIVE_RELEASES_ERROR', () => { describe('RECEIVE_RELEASES_ERROR', () => {
it('resets data', () => { it('resets data', () => {
mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, {
restPageInfo,
graphQlPageInfo,
data: releases,
});
mutations[types.RECEIVE_RELEASES_ERROR](stateCopy); mutations[types.RECEIVE_RELEASES_ERROR](stateCopy);
expect(stateCopy.isLoading).toEqual(false); expect(stateCopy.isLoading).toEqual(false);
expect(stateCopy.releases).toEqual([]); expect(stateCopy.releases).toEqual([]);
expect(stateCopy.pageInfo).toEqual({}); expect(stateCopy.restPageInfo).toEqual({});
expect(stateCopy.graphQlPageInfo).toEqual({});
}); });
}); });
}); });
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