Commit 8e10dcb8 authored by Nathan Friend's avatar Nathan Friend

Add pagination to Apollo Client Releases page

This commit updates the Apollo Client version of the Releases index page
to include pagination controls.
parent 700e1ab5
...@@ -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,9 @@ export default { ...@@ -94,6 +107,9 @@ export default {
shouldRenderLoadingIndicator() { shouldRenderLoadingIndicator() {
return this.isLoading && !this.hasError; return this.isLoading && !this.hasError;
}, },
shouldRenderPagination() {
return !this.isLoading && !this.hasError;
},
}, },
created() { created() {
this.updateQueryParamsFromUrl(); this.updateQueryParamsFromUrl();
...@@ -108,6 +124,16 @@ export default { ...@@ -108,6 +124,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 +166,13 @@ export default { ...@@ -140,6 +166,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),
},
},
computed: {
showPagination() {
return this.pageInfo.hasPreviousPage || this.pageInfo.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-if="showPagination"
v-bind="pageInfo"
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);
expectPagination();
}); });
describe('when an error occurs while loading data', () => { describe('when an error occurs while loading data', () => {
...@@ -155,6 +173,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -155,6 +173,7 @@ 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', () => {
...@@ -167,12 +186,10 @@ describe('app_index_apollo_client.vue', () => { ...@@ -167,12 +186,10 @@ describe('app_index_apollo_client.vue', () => {
expectNoFlashMessage(); expectNoFlashMessage();
expectNewReleaseButton(); expectNewReleaseButton();
expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length); expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
expectPagination();
}); });
describe('URL parameters', () => { describe('URL parameters', () => {
const before = 'beforeCursor';
const after = 'afterCursor';
describe('when the URL contains no query parameters', () => { describe('when the URL contains no query parameters', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -241,4 +258,27 @@ describe('app_index_apollo_client.vue', () => { ...@@ -241,4 +258,27 @@ describe('app_index_apollo_client.vue', () => {
expect(findNewReleaseButton().attributes().href).toBe(newReleasePath); expect(findNewReleaseButton().attributes().href).toBe(newReleasePath);
}); });
}); });
describe('pagination', () => {
it('requeries the GraphQL endpoint when a pagination button is clicked', async () => {
mockQueryParams = { before };
createComponent();
await wrapper.vm.$nextTick();
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 { GlKeysetPagination } from '@gitlab/ui';
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 findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
const findPrevButton = () => wrapper.findByTestId('prevButton');
const findNextButton = () => wrapper.findByTestId('nextButton');
describe.each`
description | pageInfo | paginationRendered | prevEnabled | nextEnabled
${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${'N/A'} | ${'N/A'}
${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${true} | ${false} | ${true}
${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${true} | ${false}
${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true} | ${true}
`(
'component states',
({ description, pageInfo, paginationRendered, prevEnabled, nextEnabled }) => {
describe(description, () => {
beforeEach(() => {
createComponent(pageInfo);
});
it(`does ${paginationRendered ? '' : 'not '}render a GlKeysetPagination`, () => {
expect(findGlKeysetPagination().exists()).toBe(paginationRendered);
});
// The remaining tests don't apply if the GlKeysetPagination component is not rendered
if (!paginationRendered) {
return;
}
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