Commit 82d51739 authored by Nathan Friend's avatar Nathan Friend Committed by Nicolò Maria Mezzopera

Add GraphQL pagination to Releases page

This commit updates the Releases page to include pagination when in
"GraphQL mode".

Because this is the last step in the GraphQL feature, this MR also
enables the `graphql_releases_page` feature flag by default.
parent 83ccc18d
...@@ -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);
if ( let paginationParams;
rootState.featureFlags.graphqlReleaseData && if (!before && !after) {
rootState.featureFlags.graphqlReleasesPage && paginationParams = { first: PAGE_SIZE };
rootState.featureFlags.graphqlMilestoneStats } else if (before && !after) {
) { paginationParams = { last: PAGE_SIZE, before };
gqClient } else if (!before && after) {
.query({ paginationParams = { first: PAGE_SIZE, after };
query: allReleasesQuery,
variables: {
fullPath: state.projectPath,
},
})
.then(response => {
dispatch('receiveReleasesSuccess', convertGraphQLResponse(response));
})
.catch(() => dispatch('receiveReleasesError'));
} else { } else {
api throw new Error(
.releases(state.projectId, { page }) 'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.',
.then(response => dispatch('receiveReleasesSuccess', response)) );
.catch(() => dispatch('receiveReleasesError'));
} }
gqClient
.query({
query: allReleasesQuery,
variables: {
fullPath: state.projectPath,
...paginationParams,
},
})
.then(response => {
const { data, paginationInfo: graphQlPageInfo } = convertGraphQLResponse(response);
commit(types.RECEIVE_RELEASES_SUCCESS, {
data,
graphQlPageInfo,
});
})
.catch(() => dispatch('receiveReleasesError'));
}; };
export const receiveReleasesSuccess = ({ commit }, { data, headers }) => { /**
const pageInfo = parseIntPagination(normalizeHeaders(headers)); * Gets a paginated list of releases from the REST endpoint
const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true }); */
commit(types.RECEIVE_RELEASES_SUCCESS, { export const fetchReleasesRest = ({ dispatch, commit, state }, { page }) => {
data: camelCasedReleases, commit(types.REQUEST_RELEASES);
pageInfo,
}); api
.releases(state.projectId, { page })
.then(({ data, headers }) => {
const restPageInfo = parseIntPagination(normalizeHeaders(headers));
const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true });
commit(types.RECEIVE_RELEASES_SUCCESS, {
data: camelCasedReleases,
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', () => {
it('dispatches fetchReleasesGraphQl with before and after parameters', () => {
return testAction(
fetchReleases,
{ before, after, page },
mockedState,
[],
[
{
type: 'fetchReleasesGraphQl',
payload: { before, after },
},
],
);
});
}); });
}); });
describe('fetchReleases', () => { describe('when at least one of the GraphQL feature flags is disabled', () => {
describe('success', () => { beforeEach(() => {
it('dispatches requestReleases and receiveReleasesSuccess', done => { mockedState.useGraphQLEndpoint = false;
jest.spyOn(gqClient, 'query').mockImplementation(({ query, variables }) => { });
expect(query).toBe(allReleasesQuery);
expect(variables).toEqual({ describe('fetchReleases', () => {
fullPath: projectPath, it('dispatches fetchReleasesRest with a page parameter', () => {
return testAction(
fetchReleases,
{ before, after, page },
mockedState,
[],
[
{
type: 'fetchReleasesRest',
payload: { page },
},
],
);
});
});
});
describe('fetchReleasesGraphQl', () => {
describe('GraphQL query variables', () => {
let vuexParams;
beforeEach(() => {
jest.spyOn(gqClient, 'query');
vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState };
});
describe('when neither a before nor an after parameter is provided', () => {
beforeEach(() => {
fetchReleasesGraphQl(vuexParams, { before: undefined, after: undefined });
});
it('makes a GraphQl query with a first variable', () => {
expect(gqClient.query).toHaveBeenCalledWith({
query: allReleasesQuery,
variables: { fullPath: projectPath, first: PAGE_SIZE },
}); });
return Promise.resolve(graphqlReleasesResponse);
}); });
});
testAction( describe('when only a before parameter is provided', () => {
fetchReleases, 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: convertGraphQLResponse(graphqlReleasesResponse), type: types.RECEIVE_RELEASES_SUCCESS,
type: 'receiveReleasesSuccess', payload: {
data: convertedResponse.data,
graphQlPageInfo: convertedResponse.paginationInfo,
},
}, },
], ],
done, [],
); );
}); });
}); });
describe('error', () => { describe('when the request fails', () => {
it('dispatches requestReleases and receiveReleasesError', done => { beforeEach(() => {
jest.spyOn(gqClient, 'query').mockRejectedValue(); jest.spyOn(gqClient, 'query').mockRejectedValue(new Error('Something went wrong!'));
});
testAction( it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => {
fetchReleases, return testAction(
fetchReleasesGraphQl,
{}, {},
mockedState, mockedState,
[],
[ [
{ {
type: 'requestReleases', type: types.REQUEST_RELEASES,
}, },
],
[
{ {
type: 'receiveReleasesError', type: 'receiveReleasesError',
}, },
], ],
done,
); );
}); });
}); });
});
describe('fetchReleasesRest', () => {
describe('REST query parameters', () => {
let vuexParams;
describe('when the graphqlReleaseData feature flag is disabled', () => {
beforeEach(() => { beforeEach(() => {
mockedState.featureFlags.graphqlReleasesPage = false; jest
}); .spyOn(api, 'releases')
.mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
describe('success', () => { vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState };
it('dispatches requestReleases and receiveReleasesSuccess', done => { });
jest.spyOn(api, 'releases').mockImplementation((id, options) => {
expect(id).toBe(projectId);
expect(options.page).toBe('1');
return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
});
testAction( describe('when a page parameter is provided', () => {
fetchReleases, beforeEach(() => {
{}, fetchReleasesRest(vuexParams, { page: 2 });
mockedState,
[],
[
{
type: 'requestReleases',
},
{
payload: { data: releases, headers: pageInfoHeadersWithoutPagination },
type: 'receiveReleasesSuccess',
},
],
done,
);
}); });
it('dispatches requestReleases and receiveReleasesSuccess on page two', done => { it('makes a REST query with a page query parameter', () => {
jest.spyOn(api, 'releases').mockImplementation((_, options) => { expect(api.releases).toHaveBeenCalledWith(projectId, { page });
expect(options.page).toBe('2');
return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
});
testAction(
fetchReleases,
{ page: '2' },
mockedState,
[],
[
{
type: 'requestReleases',
},
{
payload: { data: releases, headers: pageInfoHeadersWithoutPagination },
type: 'receiveReleasesSuccess',
},
],
done,
);
}); });
}); });
});
describe('error', () => { describe('when the request is successful', () => {
it('dispatches requestReleases and receiveReleasesError', done => { beforeEach(() => {
jest.spyOn(api, 'releases').mockReturnValue(Promise.reject()); jest
.spyOn(api, 'releases')
.mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
});
testAction( it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => {
fetchReleases, 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('receiveReleasesSuccess', () => { describe('when the request fails', () => {
it('should commit RECEIVE_RELEASES_SUCCESS mutation', done => { beforeEach(() => {
testAction( jest.spyOn(api, 'releases').mockRejectedValue(new Error('Something went wrong!'));
receiveReleasesSuccess, });
{ data: releases, headers: pageInfoHeadersWithoutPagination },
mockedState, it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => {
[{ type: types.RECEIVE_RELEASES_SUCCESS, payload: { pageInfo, data: releases } }], return testAction(
[], fetchReleasesRest,
done, {},
); mockedState,
[
{
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