Commit f66fc7ed authored by Nathan Friend's avatar Nathan Friend Committed by Natalia Tepluhina

Progressively load releases on the Releases page

parent 6a474c07
......@@ -33,7 +33,34 @@ export default {
},
},
apollo: {
graphqlResponse: {
/**
* The same query as `fullGraphqlResponse`, except that it limits its
* results to a single item. This causes this request to complete much more
* quickly than `fullGraphqlResponse`, which allows the page to show
* meaningful content to the user much earlier.
*/
singleGraphqlResponse: {
query: allReleasesQuery,
// This trick only works when paginating _forward_.
// When paginating backwards, limiting the query to a single item loads
// the _last_ item in the page, which is not useful for our purposes.
skip() {
return !this.includeSingleQuery;
},
variables() {
return {
...this.queryVariables,
first: 1,
};
},
update(data) {
return { data };
},
error() {
this.singleRequestError = true;
},
},
fullGraphqlResponse: {
query: allReleasesQuery,
variables() {
return this.queryVariables;
......@@ -42,7 +69,7 @@ export default {
return { data };
},
error(error) {
this.hasError = true;
this.fullRequestError = true;
createFlash({
message: this.$options.i18n.errorMessage,
......@@ -54,7 +81,8 @@ export default {
},
data() {
return {
hasError: false,
singleRequestError: false,
fullRequestError: false,
cursors: {
before: getParameterByName('before'),
after: getParameterByName('after'),
......@@ -83,42 +111,66 @@ export default {
sort: this.sort,
};
},
isLoading() {
return this.$apollo.queries.graphqlResponse.loading;
/**
* @returns {Boolean} Whether or not to request/include
* the results of the single-item query
*/
includeSingleQuery() {
return Boolean(!this.cursors.before || this.cursors.after);
},
isSingleRequestLoading() {
return this.$apollo.queries.singleGraphqlResponse.loading;
},
isFullRequestLoading() {
return this.$apollo.queries.fullGraphqlResponse.loading;
},
/**
* @returns {Boolean} `true` if the `singleGraphqlResponse`
* query has finished loading without errors
*/
isSingleRequestLoaded() {
return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project);
},
/**
* @returns {Boolean} `true` if the `fullGraphqlResponse`
* query has finished loading without errors
*/
isFullRequestLoaded() {
return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project);
},
releases() {
if (!this.graphqlResponse || this.hasError) {
return [];
if (this.isFullRequestLoaded) {
return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data;
}
if (this.isSingleRequestLoaded && this.includeSingleQuery) {
return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data;
}
return convertAllReleasesGraphQLResponse(this.graphqlResponse).data;
return [];
},
pageInfo() {
if (!this.graphqlResponse || this.hasError) {
if (!this.isFullRequestLoaded) {
return {
hasPreviousPage: false,
hasNextPage: false,
};
}
return this.graphqlResponse.data.project.releases.pageInfo;
return this.fullGraphqlResponse.data.project.releases.pageInfo;
},
shouldRenderEmptyState() {
return !this.releases.length && !this.hasError && !this.isLoading;
},
shouldRenderSuccessState() {
return this.releases.length && !this.isLoading && !this.hasError;
return this.isFullRequestLoaded && this.releases.length === 0;
},
shouldRenderLoadingIndicator() {
return this.isLoading && !this.hasError;
},
shouldRenderPagination() {
return (
!this.isLoading &&
!this.hasError &&
(this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage)
(this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) ||
(this.isFullRequestLoading && !this.fullRequestError)
);
},
shouldRenderPagination() {
return this.isFullRequestLoaded && !this.shouldRenderEmptyState;
},
},
created() {
this.updateQueryParamsFromUrl();
......@@ -130,7 +182,7 @@ export default {
},
methods: {
getReleaseKey(release, index) {
return [release.tagNamerstrs, release.name, index].join('|');
return [release.tagName, release.name, index].join('|');
},
updateQueryParamsFromUrl() {
this.cursors.before = getParameterByName('before');
......@@ -191,18 +243,16 @@ export default {
>
</div>
<release-skeleton-loader v-if="shouldRenderLoadingIndicator" />
<releases-empty-state v-if="shouldRenderEmptyState" />
<releases-empty-state v-else-if="shouldRenderEmptyState" />
<release-block
v-for="(release, index) in releases"
:key="getReleaseKey(release, index)"
:release="release"
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/>
<div v-else-if="shouldRenderSuccessState">
<release-block
v-for="(release, index) in releases"
:key="getReleaseKey(release, index)"
:release="release"
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/>
</div>
<release-skeleton-loader v-if="shouldRenderLoadingIndicator" />
<releases-pagination-apollo-client
v-if="shouldRenderPagination"
......
......@@ -14,7 +14,19 @@ export default () => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
defaultClient: createDefaultClient(
{},
{
// This page attempts to decrease the perceived loading time
// by sending two requests: one request for the first item only (which
// completes relatively quickly), and one for all the items (which is slower).
// By default, Apollo Client batches these requests together, which defeats
// the purpose of making separate requests. So we explicitly
// disable batching on this page.
batchMax: 1,
assumeImmutableResults: true,
},
),
});
return new Vue({
......
......@@ -100,6 +100,17 @@ RSpec.describe 'Releases (JavaScript fixtures)' do
link_type: :image)
end
let_it_be(:another_release) do
create(:release,
project: project,
tag: 'v1.2',
name: 'The second release',
author: admin,
description: 'An okay release :shrug:',
created_at: Time.zone.parse('2019-01-03'),
released_at: Time.zone.parse('2019-01-10'))
end
after(:all) do
remove_repository(project)
end
......
......@@ -3,6 +3,58 @@
exports[`releases/util.js convertAllReleasesGraphQLResponse matches snapshot 1`] = `
Object {
"data": Array [
Object {
"_links": Object {
"closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.2&scope=all&state=closed",
"closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=closed",
"editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.2/edit",
"mergedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=merged",
"openedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.2&scope=all&state=opened",
"openedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=opened",
"self": "http://localhost/releases-namespace/releases-project/-/releases/v1.2",
"selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.2",
},
"assets": Object {
"count": 4,
"links": Array [],
"sources": Array [
Object {
"format": "zip",
"url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.zip",
},
Object {
"format": "tar.gz",
"url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar.gz",
},
Object {
"format": "tar.bz2",
"url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar.bz2",
},
Object {
"format": "tar",
"url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar",
},
],
},
"author": Object {
"avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon",
"username": "administrator",
"webUrl": "http://localhost/administrator",
},
"commit": Object {
"shortId": "b83d6e39",
"title": "Merge branch 'branch-merged' into 'master'",
},
"commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0",
"descriptionHtml": "<p data-sourcepos=\\"1:1-1:23\\" dir=\\"auto\\">An okay release <gl-emoji title=\\"shrug\\" data-name=\\"shrug\\" data-unicode-version=\\"9.0\\">🤷</gl-emoji></p>",
"evidences": Array [],
"milestones": Array [],
"name": "The second release",
"releasedAt": "2019-01-10T00:00:00Z",
"tagName": "v1.2",
"tagPath": "/releases-namespace/releases-project/-/tags/v1.2",
"upcomingRelease": true,
},
Object {
"_links": Object {
"closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=closed",
......@@ -124,7 +176,7 @@ Object {
"endCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMSJ9",
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMSJ9",
"startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTktMDEtMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMiJ9",
},
}
`;
......
......@@ -37,12 +37,22 @@ describe('app_index_apollo_client.vue', () => {
const after = 'afterCursor';
let wrapper;
let allReleasesQueryResponse;
let allReleasesQueryMock;
const createComponent = (queryResponse = Promise.resolve(allReleasesQueryResponse)) => {
let allReleases;
let singleRelease;
let noReleases;
let queryMock;
const createComponent = ({
singleResponse = Promise.resolve(singleRelease),
fullResponse = Promise.resolve(allReleases),
} = {}) => {
const apolloProvider = createMockApollo([
[allReleasesQuery, allReleasesQueryMock.mockReturnValueOnce(queryResponse)],
[
allReleasesQuery,
queryMock.mockImplementation((vars) => {
return vars.first === 1 ? singleResponse : fullResponse;
}),
],
]);
wrapper = shallowMountExtended(ReleasesIndexApolloClientApp, {
......@@ -56,8 +66,19 @@ describe('app_index_apollo_client.vue', () => {
beforeEach(() => {
mockQueryParams = {};
allReleasesQueryResponse = cloneDeep(originalAllReleasesQueryResponse);
allReleasesQueryMock = jest.fn();
allReleases = cloneDeep(originalAllReleasesQueryResponse);
singleRelease = cloneDeep(originalAllReleasesQueryResponse);
singleRelease.data.project.releases.nodes.splice(
1,
singleRelease.data.project.releases.nodes.length,
);
noReleases = cloneDeep(originalAllReleasesQueryResponse);
noReleases.data.project.releases.nodes = [];
queryMock = jest.fn();
});
afterEach(() => {
......@@ -73,148 +94,88 @@ describe('app_index_apollo_client.vue', () => {
const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient);
const findSort = () => wrapper.findComponent(ReleasesSortApolloClient);
// Expectations
const expectLoadingIndicator = () => {
it('renders a loading indicator', () => {
expect(findLoadingIndicator().exists()).toBe(true);
});
};
const expectNoLoadingIndicator = () => {
it('does not render a loading indicator', () => {
expect(findLoadingIndicator().exists()).toBe(false);
});
};
const expectEmptyState = () => {
it('renders the empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
};
const expectNoEmptyState = () => {
it('does not render the empty state', () => {
expect(findEmptyState().exists()).toBe(false);
});
};
const expectFlashMessage = (message = ReleasesIndexApolloClientApp.i18n.errorMessage) => {
it(`shows a flash message that reads "${message}"`, () => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message,
captureError: true,
error: expect.any(Error),
});
});
};
const expectNewReleaseButton = () => {
it('renders the "New Release" button', () => {
expect(findNewReleaseButton().exists()).toBe(true);
});
};
const expectNoFlashMessage = () => {
it(`does not show a flash message`, () => {
expect(createFlash).not.toHaveBeenCalled();
});
};
const expectReleases = (count) => {
it(`renders ${count} release(s)`, () => {
expect(findAllReleaseBlocks()).toHaveLength(count);
});
};
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);
});
};
const expectSort = () => {
it('renders the sort controls', () => {
expect(findSort().exists()).toBe(true);
});
};
// Tests
describe('when the component is loading data', () => {
beforeEach(() => {
createComponent(new Promise(() => {}));
});
expectLoadingIndicator();
expectNoEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(0);
expectNoPagination();
expectSort();
});
describe('component states', () => {
// These need to be defined as functions, since `singleRelease` and
// `allReleases` are generated in a `beforeEach`, and therefore
// aren't available at test definition time.
const getInProgressResponse = () => new Promise(() => {});
const getErrorResponse = () => Promise.reject(new Error('Oops!'));
const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease);
const getFullRequestLoadedResponse = () => Promise.resolve(allReleases);
const getLoadedEmptyResponse = () => Promise.resolve(noReleases);
const toDescription = (bool) => (bool ? 'does' : 'does not');
describe('when the data has successfully loaded, but there are no releases', () => {
beforeEach(() => {
allReleasesQueryResponse.data.project.releases.nodes = [];
createComponent(Promise.resolve(allReleasesQueryResponse));
});
describe.each`
description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination
${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false}
${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false}
${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false}
${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false}
${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
`(
'$description',
({
singleResponseFn,
fullResponseFn,
loadingIndicator,
emptyState,
flashMessage,
releaseCount,
pagination,
}) => {
beforeEach(() => {
createComponent({
singleResponse: singleResponseFn(),
fullResponse: fullResponseFn(),
});
});
expectNoLoadingIndicator();
expectEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(0);
expectNoPagination();
expectSort();
});
it(`${toDescription(loadingIndicator)} render a loading indicator`, () => {
expect(findLoadingIndicator().exists()).toBe(loadingIndicator);
});
describe('when an error occurs while loading data', () => {
beforeEach(() => {
createComponent(Promise.reject(new Error('Oops!')));
});
it(`${toDescription(emptyState)} render an empty state`, () => {
expect(findEmptyState().exists()).toBe(emptyState);
});
expectNoLoadingIndicator();
expectNoEmptyState();
expectFlashMessage();
expectNewReleaseButton();
expectReleases(0);
expectNoPagination();
expectSort();
});
it(`${toDescription(flashMessage)} show a flash message`, () => {
if (flashMessage) {
expect(createFlash).toHaveBeenCalledWith({
message: ReleasesIndexApolloClientApp.i18n.errorMessage,
captureError: true,
error: expect.any(Error),
});
} else {
expect(createFlash).not.toHaveBeenCalled();
}
});
describe('when the data has successfully loaded with a single page of results', () => {
beforeEach(() => {
createComponent();
});
it(`renders ${releaseCount} release(s)`, () => {
expect(findAllReleaseBlocks()).toHaveLength(releaseCount);
});
expectNoLoadingIndicator();
expectNoEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
expectNoPagination();
});
it(`${toDescription(pagination)} render the pagination controls`, () => {
expect(findPagination().exists()).toBe(pagination);
});
describe('when the data has successfully loaded with multiple pages of results', () => {
beforeEach(() => {
allReleasesQueryResponse.data.project.releases.pageInfo.hasNextPage = true;
createComponent(Promise.resolve(allReleasesQueryResponse));
});
it('does render the "New release" button', () => {
expect(findNewReleaseButton().exists()).toBe(true);
});
expectNoLoadingIndicator();
expectNoEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
expectPagination();
expectSort();
it('does render the sort controls', () => {
expect(findSort().exists()).toBe(true);
});
},
);
});
describe('URL parameters', () => {
......@@ -224,7 +185,15 @@ describe('app_index_apollo_client.vue', () => {
});
it('makes a request with the correct GraphQL query parameters', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
expect(queryMock).toHaveBeenCalledTimes(2);
expect(queryMock).toHaveBeenCalledWith({
first: 1,
fullPath: projectPath,
sort: DEFAULT_SORT,
});
expect(queryMock).toHaveBeenCalledWith({
first: PAGE_SIZE,
fullPath: projectPath,
sort: DEFAULT_SORT,
......@@ -239,7 +208,9 @@ describe('app_index_apollo_client.vue', () => {
});
it('makes a request with the correct GraphQL query parameters', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
expect(queryMock).toHaveBeenCalledTimes(1);
expect(queryMock).toHaveBeenCalledWith({
before,
last: PAGE_SIZE,
fullPath: projectPath,
......@@ -255,7 +226,16 @@ describe('app_index_apollo_client.vue', () => {
});
it('makes a request with the correct GraphQL query parameters', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
expect(queryMock).toHaveBeenCalledTimes(2);
expect(queryMock).toHaveBeenCalledWith({
after,
first: 1,
fullPath: projectPath,
sort: DEFAULT_SORT,
});
expect(queryMock).toHaveBeenCalledWith({
after,
first: PAGE_SIZE,
fullPath: projectPath,
......@@ -271,7 +251,16 @@ describe('app_index_apollo_client.vue', () => {
});
it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
expect(queryMock).toHaveBeenCalledTimes(2);
expect(queryMock).toHaveBeenCalledWith({
after,
first: 1,
fullPath: projectPath,
sort: DEFAULT_SORT,
});
expect(queryMock).toHaveBeenCalledWith({
after,
first: PAGE_SIZE,
fullPath: projectPath,
......@@ -292,27 +281,23 @@ describe('app_index_apollo_client.vue', () => {
});
describe('pagination', () => {
beforeEach(async () => {
beforeEach(() => {
mockQueryParams = { before };
allReleasesQueryResponse.data.project.releases.pageInfo.hasNextPage = true;
createComponent(Promise.resolve(allReleasesQueryResponse));
await wrapper.vm.$nextTick();
createComponent();
});
it('requeries the GraphQL endpoint when a pagination button is clicked', async () => {
expect(allReleasesQueryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
mockQueryParams = { after };
findPagination().vm.$emit('next', after);
await wrapper.vm.$nextTick();
expect(allReleasesQueryMock.mock.calls).toEqual([
expect(queryMock.mock.calls).toEqual([
[expect.objectContaining({ before })],
[expect.objectContaining({ after })],
[expect.objectContaining({ after })],
]);
});
});
......@@ -323,7 +308,8 @@ describe('app_index_apollo_client.vue', () => {
});
it(`sorts by ${DEFAULT_SORT} by default`, () => {
expect(allReleasesQueryMock.mock.calls).toEqual([
expect(queryMock.mock.calls).toEqual([
[expect.objectContaining({ sort: DEFAULT_SORT })],
[expect.objectContaining({ sort: DEFAULT_SORT })],
]);
});
......@@ -333,8 +319,10 @@ describe('app_index_apollo_client.vue', () => {
await wrapper.vm.$nextTick();
expect(allReleasesQueryMock.mock.calls).toEqual([
expect(queryMock.mock.calls).toEqual([
[expect.objectContaining({ sort: DEFAULT_SORT })],
[expect.objectContaining({ sort: DEFAULT_SORT })],
[expect.objectContaining({ sort: CREATED_ASC })],
[expect.objectContaining({ sort: CREATED_ASC })],
]);
......@@ -347,7 +335,8 @@ describe('app_index_apollo_client.vue', () => {
await wrapper.vm.$nextTick();
expect(allReleasesQueryMock.mock.calls).toEqual([
expect(queryMock.mock.calls).toEqual([
[expect.objectContaining({ sort: DEFAULT_SORT })],
[expect.objectContaining({ sort: DEFAULT_SORT })],
]);
......@@ -381,11 +370,13 @@ describe('app_index_apollo_client.vue', () => {
});
it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => {
const firstRequestVariables = allReleasesQueryMock.mock.calls[0][0];
const secondRequestVariables = allReleasesQueryMock.mock.calls[1][0];
const firstRequestVariables = queryMock.mock.calls[0][0];
// Might be request #2 or #3, depending on the pagination direction
const mostRecentRequestVariables =
queryMock.mock.calls[queryMock.mock.calls.length - 1][0];
expect(firstRequestVariables[paramName]).toBe(paramInitialValue);
expect(secondRequestVariables[paramName]).toBeUndefined();
expect(mostRecentRequestVariables[paramName]).toBeUndefined();
});
it(`updates the URL to not include the "${paramName}" URL query parameter`, () => {
......
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