Commit 9ddb5b5d authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch 'nfriend-add-pagination-to-apollo-client-releases-page' into 'master'

Add pagination controls to new Releases page

See merge request gitlab-org/gitlab!62234
parents 0c17e1c5 6a5b7b00
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { getParameterByName } from '~/lib/utils/common_utils'; import { getParameterByName } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { PAGE_SIZE } from '~/releases/constants'; import { PAGE_SIZE } from '~/releases/constants';
import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
...@@ -9,6 +10,7 @@ import { convertAllReleasesGraphQLResponse } from '~/releases/util'; ...@@ -9,6 +10,7 @@ import { convertAllReleasesGraphQLResponse } from '~/releases/util';
import ReleaseBlock from './release_block.vue'; import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
import ReleasesEmptyState from './releases_empty_state.vue'; import ReleasesEmptyState from './releases_empty_state.vue';
import ReleasesPaginationApolloClient from './releases_pagination_apollo_client.vue';
export default { export default {
name: 'ReleasesIndexApolloClientApp', name: 'ReleasesIndexApolloClientApp',
...@@ -17,6 +19,7 @@ export default { ...@@ -17,6 +19,7 @@ export default {
ReleaseBlock, ReleaseBlock,
ReleaseSkeletonLoader, ReleaseSkeletonLoader,
ReleasesEmptyState, ReleasesEmptyState,
ReleasesPaginationApolloClient,
}, },
inject: { inject: {
projectPath: { projectPath: {
...@@ -85,6 +88,16 @@ export default { ...@@ -85,6 +88,16 @@ export default {
return convertAllReleasesGraphQLResponse(this.graphqlResponse).data; return convertAllReleasesGraphQLResponse(this.graphqlResponse).data;
}, },
pageInfo() {
if (!this.graphqlResponse || this.hasError) {
return {
hasPreviousPage: false,
hasNextPage: false,
};
}
return this.graphqlResponse.data.project.releases.pageInfo;
},
shouldRenderEmptyState() { shouldRenderEmptyState() {
return !this.releases.length && !this.hasError && !this.isLoading; return !this.releases.length && !this.hasError && !this.isLoading;
}, },
...@@ -94,6 +107,13 @@ export default { ...@@ -94,6 +107,13 @@ export default {
shouldRenderLoadingIndicator() { shouldRenderLoadingIndicator() {
return this.isLoading && !this.hasError; return this.isLoading && !this.hasError;
}, },
shouldRenderPagination() {
return (
!this.isLoading &&
!this.hasError &&
(this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage)
);
},
}, },
created() { created() {
this.updateQueryParamsFromUrl(); this.updateQueryParamsFromUrl();
...@@ -108,6 +128,16 @@ export default { ...@@ -108,6 +128,16 @@ export default {
this.cursors.before = getParameterByName('before'); this.cursors.before = getParameterByName('before');
this.cursors.after = getParameterByName('after'); this.cursors.after = getParameterByName('after');
}, },
onPaginationButtonPress() {
this.updateQueryParamsFromUrl();
// In some cases, Apollo Client is able to pull its results from the cache instead of making
// a new network request. In these cases, the page's content gets swapped out immediately without
// changing the page's scroll, leaving the user looking at the bottom of the new page.
// To make the experience consistent, regardless of how the data is sourced, we manually
// scroll to the top of the page every time a pagination button is pressed.
scrollUp();
},
}, },
i18n: { i18n: {
newRelease: __('New release'), newRelease: __('New release'),
...@@ -140,6 +170,13 @@ export default { ...@@ -140,6 +170,13 @@ export default {
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/> />
</div> </div>
<releases-pagination-apollo-client
v-if="shouldRenderPagination"
:page-info="pageInfo"
@prev="onPaginationButtonPress"
@next="onPaginationButtonPress"
/>
</div> </div>
</template> </template>
<style> <style>
......
<script>
import { GlKeysetPagination } from '@gitlab/ui';
import { isBoolean } from 'lodash';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
export default {
name: 'ReleasesPaginationApolloClient',
components: { GlKeysetPagination },
props: {
pageInfo: {
type: Object,
required: true,
validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage),
},
},
methods: {
onPrev(before) {
historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
},
onNext(after) {
historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
},
},
};
</script>
<template>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
v-bind="pageInfo"
:prev-text="__('Prev')"
:next-text="__('Next')"
v-on="$listeners"
@prev="onPrev($event)"
@next="onNext($event)"
/>
</div>
</template>
...@@ -8,6 +8,7 @@ import ReleasesIndexApolloClientApp from '~/releases/components/app_index_apollo ...@@ -8,6 +8,7 @@ import ReleasesIndexApolloClientApp from '~/releases/components/app_index_apollo
import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseBlock from '~/releases/components/release_block.vue';
import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue'; import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue';
import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue';
import { PAGE_SIZE } from '~/releases/constants'; import { PAGE_SIZE } from '~/releases/constants';
import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
...@@ -29,6 +30,8 @@ describe('app_index_apollo_client.vue', () => { ...@@ -29,6 +30,8 @@ describe('app_index_apollo_client.vue', () => {
); );
const projectPath = 'project/path'; const projectPath = 'project/path';
const newReleasePath = 'path/to/new/release/page'; const newReleasePath = 'path/to/new/release/page';
const before = 'beforeCursor';
const after = 'afterCursor';
let wrapper; let wrapper;
let allReleasesQueryResponse; let allReleasesQueryResponse;
...@@ -64,6 +67,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -64,6 +67,7 @@ describe('app_index_apollo_client.vue', () => {
const findNewReleaseButton = () => const findNewReleaseButton = () =>
wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease); wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease);
const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock); const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock);
const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient);
// Expectations // Expectations
const expectLoadingIndicator = () => { const expectLoadingIndicator = () => {
...@@ -119,6 +123,18 @@ describe('app_index_apollo_client.vue', () => { ...@@ -119,6 +123,18 @@ describe('app_index_apollo_client.vue', () => {
}); });
}; };
const expectPagination = () => {
it('renders the pagination buttons', () => {
expect(findPagination().exists()).toBe(true);
});
};
const expectNoPagination = () => {
it('does not render the pagination buttons', () => {
expect(findPagination().exists()).toBe(false);
});
};
// Tests // Tests
describe('when the component is loading data', () => { describe('when the component is loading data', () => {
beforeEach(() => { beforeEach(() => {
...@@ -130,6 +146,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -130,6 +146,7 @@ describe('app_index_apollo_client.vue', () => {
expectNoFlashMessage(); expectNoFlashMessage();
expectNewReleaseButton(); expectNewReleaseButton();
expectReleases(0); expectReleases(0);
expectNoPagination();
}); });
describe('when the data has successfully loaded, but there are no releases', () => { describe('when the data has successfully loaded, but there are no releases', () => {
...@@ -143,6 +160,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -143,6 +160,7 @@ describe('app_index_apollo_client.vue', () => {
expectNoFlashMessage(); expectNoFlashMessage();
expectNewReleaseButton(); expectNewReleaseButton();
expectReleases(0); expectReleases(0);
expectNoPagination();
}); });
describe('when an error occurs while loading data', () => { describe('when an error occurs while loading data', () => {
...@@ -155,9 +173,10 @@ describe('app_index_apollo_client.vue', () => { ...@@ -155,9 +173,10 @@ describe('app_index_apollo_client.vue', () => {
expectFlashMessage(); expectFlashMessage();
expectNewReleaseButton(); expectNewReleaseButton();
expectReleases(0); expectReleases(0);
expectNoPagination();
}); });
describe('when the data has successfully loaded', () => { describe('when the data has successfully loaded with a single page of results', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
}); });
...@@ -167,12 +186,24 @@ describe('app_index_apollo_client.vue', () => { ...@@ -167,12 +186,24 @@ describe('app_index_apollo_client.vue', () => {
expectNoFlashMessage(); expectNoFlashMessage();
expectNewReleaseButton(); expectNewReleaseButton();
expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length); expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
expectNoPagination();
}); });
describe('URL parameters', () => { describe('when the data has successfully loaded with multiple pages of results', () => {
const before = 'beforeCursor'; beforeEach(() => {
const after = 'afterCursor'; allReleasesQueryResponse.data.project.releases.pageInfo.hasNextPage = true;
createComponent(Promise.resolve(allReleasesQueryResponse));
});
expectNoLoadingIndicator();
expectNoEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
expectPagination();
});
describe('URL parameters', () => {
describe('when the URL contains no query parameters', () => { describe('when the URL contains no query parameters', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -241,4 +272,30 @@ describe('app_index_apollo_client.vue', () => { ...@@ -241,4 +272,30 @@ describe('app_index_apollo_client.vue', () => {
expect(findNewReleaseButton().attributes().href).toBe(newReleasePath); expect(findNewReleaseButton().attributes().href).toBe(newReleasePath);
}); });
}); });
describe('pagination', () => {
beforeEach(async () => {
mockQueryParams = { before };
allReleasesQueryResponse.data.project.releases.pageInfo.hasNextPage = true;
createComponent(Promise.resolve(allReleasesQueryResponse));
await wrapper.vm.$nextTick();
});
it('requeries the GraphQL endpoint when a pagination button is clicked', async () => {
expect(allReleasesQueryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
mockQueryParams = { after };
findPagination().vm.$emit('next', after);
await wrapper.vm.$nextTick();
expect(allReleasesQueryMock.mock.calls).toEqual([
[expect.objectContaining({ before })],
[expect.objectContaining({ after })],
]);
});
});
}); });
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { historyPushState } from '~/lib/utils/common_utils';
import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue';
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
historyPushState: jest.fn(),
}));
describe('releases_pagination_apollo_client.vue', () => {
const startCursor = 'startCursor';
const endCursor = 'endCursor';
let wrapper;
let onPrev;
let onNext;
const createComponent = (pageInfo) => {
onPrev = jest.fn();
onNext = jest.fn();
wrapper = mountExtended(ReleasesPaginationApolloClient, {
propsData: {
pageInfo,
},
listeners: {
prev: onPrev,
next: onNext,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const singlePageInfo = {
hasPreviousPage: false,
hasNextPage: false,
startCursor,
endCursor,
};
const onlyNextPageInfo = {
hasPreviousPage: false,
hasNextPage: true,
startCursor,
endCursor,
};
const onlyPrevPageInfo = {
hasPreviousPage: true,
hasNextPage: false,
startCursor,
endCursor,
};
const prevAndNextPageInfo = {
hasPreviousPage: true,
hasNextPage: true,
startCursor,
endCursor,
};
const findPrevButton = () => wrapper.findByTestId('prevButton');
const findNextButton = () => wrapper.findByTestId('nextButton');
describe.each`
description | pageInfo | prevEnabled | nextEnabled
${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false}
${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true}
${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false}
${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true}
`('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => {
describe(description, () => {
beforeEach(() => {
createComponent(pageInfo);
});
it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => {
expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled');
});
it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => {
expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled');
});
});
});
describe('button behavior', () => {
beforeEach(() => {
createComponent(prevAndNextPageInfo);
});
describe('next button behavior', () => {
beforeEach(() => {
findNextButton().trigger('click');
});
it('emits an "next" event with the "after" cursor', () => {
expect(onNext.mock.calls).toEqual([[endCursor]]);
});
it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([
[expect.stringContaining(`?after=${endCursor}`)],
]);
});
});
describe('prev button behavior', () => {
beforeEach(() => {
findPrevButton().trigger('click');
});
it('emits an "prev" event with the "before" cursor', () => {
expect(onPrev.mock.calls).toEqual([[startCursor]]);
});
it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([
[expect.stringContaining(`?before=${startCursor}`)],
]);
});
});
});
});
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