Commit 90758d39 authored by Andrew Fontaine's avatar Andrew Fontaine

Add pagination to new environments app

The old page paginates based on the headers returned by the API, and so
we do the same here.

We put the page info header into the GraphQL cache, and fetch it out to
pass it to the pagination component from GitLab UI.

Also the pagination must be wired up to the query parameters to support
deep linking.

I wonder if GlPagination should just have a prop for syncing the query
parameters, like GlTabs does.
parent 0fd270ff
<script> <script>
import { GlBadge, GlTab, GlTabs } from '@gitlab/ui'; import { GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui';
import { __, s__ } from '~/locale'; import { s__, __, sprintf } from '~/locale';
import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility';
import environmentAppQuery from '../graphql/queries/environment_app.query.graphql'; import environmentAppQuery from '../graphql/queries/environment_app.query.graphql';
import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql'; import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql';
import pageInfoQuery from '../graphql/queries/page_info.query.graphql';
import EnvironmentFolder from './new_environment_folder.vue'; import EnvironmentFolder from './new_environment_folder.vue';
import EnableReviewAppModal from './enable_review_app_modal.vue'; import EnableReviewAppModal from './enable_review_app_modal.vue';
...@@ -11,6 +13,7 @@ export default { ...@@ -11,6 +13,7 @@ export default {
EnvironmentFolder, EnvironmentFolder,
EnableReviewAppModal, EnableReviewAppModal,
GlBadge, GlBadge,
GlPagination,
GlTab, GlTab,
GlTabs, GlTabs,
}, },
...@@ -20,6 +23,7 @@ export default { ...@@ -20,6 +23,7 @@ export default {
variables() { variables() {
return { return {
scope: this.scope, scope: this.scope,
page: this.page ?? 1,
}; };
}, },
pollInterval() { pollInterval() {
...@@ -29,6 +33,9 @@ export default { ...@@ -29,6 +33,9 @@ export default {
interval: { interval: {
query: pollIntervalQuery, query: pollIntervalQuery,
}, },
pageInfo: {
query: pageInfoQuery,
},
}, },
inject: ['newEnvironmentPath', 'canCreateEnvironment'], inject: ['newEnvironmentPath', 'canCreateEnvironment'],
i18n: { i18n: {
...@@ -36,11 +43,21 @@ export default { ...@@ -36,11 +43,21 @@ export default {
reviewAppButtonLabel: s__('Environments|Enable review app'), reviewAppButtonLabel: s__('Environments|Enable review app'),
available: __('Available'), available: __('Available'),
stopped: __('Stopped'), stopped: __('Stopped'),
prevPage: __('Go to previous page'),
nextPage: __('Go to next page'),
next: __('Next'),
prev: __('Prev'),
goto: (page) => sprintf(__('Go to page %{page}'), { page }),
}, },
modalId: 'enable-review-app-info', modalId: 'enable-review-app-info',
data() { data() {
const scope = new URLSearchParams(window.location.search).get('scope') || 'available'; const { page = '1', scope = 'available' } = queryToObject(window.location.search);
return { interval: undefined, scope, isReviewAppModalVisible: false }; return {
interval: undefined,
isReviewAppModalVisible: false,
page: parseInt(page, 10),
scope,
};
}, },
computed: { computed: {
canSetupReviewApp() { canSetupReviewApp() {
...@@ -82,6 +99,19 @@ export default { ...@@ -82,6 +99,19 @@ export default {
stoppedCount() { stoppedCount() {
return this.environmentApp?.stoppedCount; return this.environmentApp?.stoppedCount;
}, },
totalItems() {
return this.pageInfo?.total;
},
itemsPerPage() {
return this.pageInfo?.perPage;
},
},
mounted() {
window.addEventListener('popstate', this.syncPageFromQueryParams);
},
destroyed() {
window.removeEventListener('popstate', this.syncPageFromQueryParams);
this.$apollo.queries.environmentApp.stopPolling();
}, },
methods: { methods: {
showReviewAppModal() { showReviewAppModal() {
...@@ -89,12 +119,30 @@ export default { ...@@ -89,12 +119,30 @@ export default {
}, },
setScope(scope) { setScope(scope) {
this.scope = scope; this.scope = scope;
this.resetPolling();
},
movePage(direction) {
this.moveToPage(this.pageInfo[`${direction}Page`]);
},
moveToPage(page) {
this.page = page;
updateHistory({
url: setUrlParams({ page: this.page }),
title: document.title,
});
this.resetPolling();
},
syncPageFromQueryParams() {
const { page = '1' } = queryToObject(window.location.search);
this.page = parseInt(page, 10);
},
resetPolling() {
this.$apollo.queries.environmentApp.stopPolling(); this.$apollo.queries.environmentApp.stopPolling();
this.$nextTick(() => { this.$nextTick(() => {
if (this.interval) { if (this.interval) {
this.$apollo.queries.environmentApp.startPolling(this.interval); this.$apollo.queries.environmentApp.startPolling(this.interval);
} else { } else {
this.$apollo.queries.environmentApp.refetch({ scope }); this.$apollo.queries.environmentApp.refetch({ scope: this.scope, page: this.page });
} }
}); });
}, },
...@@ -139,5 +187,19 @@ export default { ...@@ -139,5 +187,19 @@ export default {
class="gl-mb-3" class="gl-mb-3"
:nested-environment="folder" :nested-environment="folder"
/> />
<gl-pagination
align="center"
:total-items="totalItems"
:per-page="itemsPerPage"
:value="page"
:next="$options.i18n.next"
:prev="$options.i18n.prev"
:label-previous-page="$options.prevPage"
:label-next-page="$options.nextPage"
:label-page="$options.goto"
@next="movePage('next')"
@previous="movePage('previous')"
@input="moveToPage"
/>
</div> </div>
</template> </template>
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import environmentApp from './queries/environment_app.query.graphql'; import environmentApp from './queries/environment_app.query.graphql';
import pageInfoQuery from './queries/page_info.query.graphql';
import { resolvers } from './resolvers'; import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql'; import typeDefs from './typedefs.graphql';
...@@ -19,6 +20,19 @@ export const apolloProvider = (endpoint) => { ...@@ -19,6 +20,19 @@ export const apolloProvider = (endpoint) => {
stoppedCount: 0, stoppedCount: 0,
}, },
}); });
cache.writeQuery({
query: pageInfoQuery,
data: {
pageInfo: {
total: 0,
perPage: 20,
nextPage: 0,
previousPage: 0,
__typename: 'LocalPageInfo',
},
},
});
return new VueApollo({ return new VueApollo({
defaultClient, defaultClient,
}); });
......
query getEnvironmentApp($scope: String) { query getEnvironmentApp($page: Int, $scope: String) {
environmentApp(scope: $scope) @client { environmentApp(page: $page, scope: $scope) @client {
availableCount availableCount
stoppedCount stoppedCount
environments environments
......
query getPageInfo {
pageInfo @client {
total
perPage
nextPage
previousPage
}
}
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import {
convertObjectPropsToCamelCase,
parseIntPagination,
normalizeHeaders,
} from '~/lib/utils/common_utils';
import pollIntervalQuery from './queries/poll_interval.query.graphql'; import pollIntervalQuery from './queries/poll_interval.query.graphql';
import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql';
import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
import pageInfoQuery from './queries/page_info.query.graphql';
const buildErrors = (errors = []) => ({ const buildErrors = (errors = []) => ({
errors, errors,
...@@ -21,9 +27,11 @@ const mapEnvironment = (env) => ({ ...@@ -21,9 +27,11 @@ const mapEnvironment = (env) => ({
export const resolvers = (endpoint) => ({ export const resolvers = (endpoint) => ({
Query: { Query: {
environmentApp(_context, { scope }, { cache }) { environmentApp(_context, { page, scope }, { cache }) {
return axios.get(endpoint, { params: { nested: true, scope } }).then((res) => { return axios.get(endpoint, { params: { nested: true, page, scope } }).then((res) => {
const interval = res.headers['poll-interval']; const headers = normalizeHeaders(res.headers);
const interval = headers['POLL-INTERVAL'];
const pageInfo = { ...parseIntPagination(headers), __typename: 'LocalPageInfo' };
if (interval) { if (interval) {
cache.writeQuery({ query: pollIntervalQuery, data: { interval: parseFloat(interval) } }); cache.writeQuery({ query: pollIntervalQuery, data: { interval: parseFloat(interval) } });
...@@ -31,6 +39,11 @@ export const resolvers = (endpoint) => ({ ...@@ -31,6 +39,11 @@ export const resolvers = (endpoint) => ({
cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } }); cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } });
} }
cache.writeQuery({
query: pageInfoQuery,
data: { pageInfo },
});
return { return {
availableCount: res.data.available_count, availableCount: res.data.available_count,
environments: res.data.environments.map(mapNestedEnvironment), environments: res.data.environments.map(mapNestedEnvironment),
......
...@@ -55,10 +55,18 @@ type LocalErrors { ...@@ -55,10 +55,18 @@ type LocalErrors {
errors: [String!]! errors: [String!]!
} }
type LocalPageInfo {
total: Int!
perPage: Int!
nextPage: Int!
previousPage: Int!
}
extend type Query { extend type Query {
environmentApp: LocalEnvironmentApp environmentApp(page: Int, scope: String): LocalEnvironmentApp
folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder
environmentToDelete: LocalEnvironment environmentToDelete: LocalEnvironment
pageInfo: LocalPageInfo
environmentToRollback: LocalEnvironment environmentToRollback: LocalEnvironment
isLastDeployment: Boolean isLastDeployment: Boolean
} }
......
...@@ -16352,6 +16352,9 @@ msgstr "" ...@@ -16352,6 +16352,9 @@ msgstr ""
msgid "Go to next page" msgid "Go to next page"
msgstr "" msgstr ""
msgid "Go to page %{page}"
msgstr ""
msgid "Go to parent" msgid "Go to parent"
msgstr "" msgstr ""
......
...@@ -5,6 +5,7 @@ import environmentToRollback from '~/environments/graphql/queries/environment_to ...@@ -5,6 +5,7 @@ import environmentToRollback from '~/environments/graphql/queries/environment_to
import environmentToDelete from '~/environments/graphql/queries/environment_to_delete.query.graphql'; import environmentToDelete from '~/environments/graphql/queries/environment_to_delete.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import pollIntervalQuery from '~/environments/graphql/queries/poll_interval.query.graphql'; import pollIntervalQuery from '~/environments/graphql/queries/poll_interval.query.graphql';
import pageInfoQuery from '~/environments/graphql/queries/page_info.query.graphql';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { import {
environmentsApp, environmentsApp,
...@@ -37,9 +38,11 @@ describe('~/frontend/environments/graphql/resolvers', () => { ...@@ -37,9 +38,11 @@ describe('~/frontend/environments/graphql/resolvers', () => {
it('should fetch environments and map them to frontend data', async () => { it('should fetch environments and map them to frontend data', async () => {
const cache = { writeQuery: jest.fn() }; const cache = { writeQuery: jest.fn() };
const scope = 'available'; const scope = 'available';
mock.onGet(ENDPOINT, { params: { nested: true, scope } }).reply(200, environmentsApp, {}); mock
.onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } })
.reply(200, environmentsApp, {});
const app = await mockResolvers.Query.environmentApp(null, { scope }, { cache }); const app = await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache });
expect(app).toEqual(resolvedEnvironmentsApp); expect(app).toEqual(resolvedEnvironmentsApp);
expect(cache.writeQuery).toHaveBeenCalledWith({ expect(cache.writeQuery).toHaveBeenCalledWith({
query: pollIntervalQuery, query: pollIntervalQuery,
...@@ -49,14 +52,70 @@ describe('~/frontend/environments/graphql/resolvers', () => { ...@@ -49,14 +52,70 @@ describe('~/frontend/environments/graphql/resolvers', () => {
it('should set the poll interval when there is one', async () => { it('should set the poll interval when there is one', async () => {
const cache = { writeQuery: jest.fn() }; const cache = { writeQuery: jest.fn() };
const scope = 'stopped'; const scope = 'stopped';
const interval = 3000;
mock mock
.onGet(ENDPOINT, { params: { nested: true, scope } }) .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } })
.reply(200, environmentsApp, { 'poll-interval': 3000 }); .reply(200, environmentsApp, {
'poll-interval': interval,
});
await mockResolvers.Query.environmentApp(null, { scope }, { cache }); await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache });
expect(cache.writeQuery).toHaveBeenCalledWith({ expect(cache.writeQuery).toHaveBeenCalledWith({
query: pollIntervalQuery, query: pollIntervalQuery,
data: { interval: 3000 }, data: { interval },
});
});
it('should set page info if there is any', async () => {
const cache = { writeQuery: jest.fn() };
const scope = 'stopped';
mock
.onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } })
.reply(200, environmentsApp, {
'x-next-page': '2',
'x-page': '1',
'X-Per-Page': '2',
'X-Prev-Page': '',
'X-TOTAL': '37',
'X-Total-Pages': '5',
});
await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache });
expect(cache.writeQuery).toHaveBeenCalledWith({
query: pageInfoQuery,
data: {
pageInfo: {
total: 37,
perPage: 2,
previousPage: NaN,
totalPages: 5,
nextPage: 2,
page: 1,
__typename: 'LocalPageInfo',
},
},
});
});
it('should not set page info if there is none', async () => {
const cache = { writeQuery: jest.fn() };
const scope = 'stopped';
mock
.onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } })
.reply(200, environmentsApp, {});
await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache });
expect(cache.writeQuery).toHaveBeenCalledWith({
query: pageInfoQuery,
data: {
pageInfo: {
__typename: 'LocalPageInfo',
nextPage: NaN,
page: NaN,
perPage: NaN,
previousPage: NaN,
total: NaN,
totalPages: NaN,
},
},
}); });
}); });
}); });
......
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { GlPagination } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { __, s__ } from '~/locale'; import setWindowLocation from 'helpers/set_window_location_helper';
import { sprintf, __, s__ } from '~/locale';
import EnvironmentsApp from '~/environments/components/new_environments_app.vue'; import EnvironmentsApp from '~/environments/components/new_environments_app.vue';
import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue'; import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue';
import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data'; import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data';
...@@ -14,12 +16,14 @@ describe('~/environments/components/new_environments_app.vue', () => { ...@@ -14,12 +16,14 @@ describe('~/environments/components/new_environments_app.vue', () => {
let wrapper; let wrapper;
let environmentAppMock; let environmentAppMock;
let environmentFolderMock; let environmentFolderMock;
let paginationMock;
const createApolloProvider = () => { const createApolloProvider = () => {
const mockResolvers = { const mockResolvers = {
Query: { Query: {
environmentApp: environmentAppMock, environmentApp: environmentAppMock,
folder: environmentFolderMock, folder: environmentFolderMock,
pageInfo: paginationMock,
}, },
}; };
...@@ -37,9 +41,23 @@ describe('~/environments/components/new_environments_app.vue', () => { ...@@ -37,9 +41,23 @@ describe('~/environments/components/new_environments_app.vue', () => {
apolloProvider, apolloProvider,
}); });
const createWrapperWithMocked = async ({ provide = {}, environmentsApp, folder }) => { const createWrapperWithMocked = async ({
provide = {},
environmentsApp,
folder,
pageInfo = {
total: 20,
perPage: 5,
nextPage: 3,
page: 2,
previousPage: 1,
__typename: 'LocalPageInfo',
},
}) => {
setWindowLocation('?scope=available&page=2');
environmentAppMock.mockReturnValue(environmentsApp); environmentAppMock.mockReturnValue(environmentsApp);
environmentFolderMock.mockReturnValue(folder); environmentFolderMock.mockReturnValue(folder);
paginationMock.mockReturnValue(pageInfo);
const apolloProvider = createApolloProvider(); const apolloProvider = createApolloProvider();
wrapper = createWrapper({ apolloProvider, provide }); wrapper = createWrapper({ apolloProvider, provide });
...@@ -50,6 +68,7 @@ describe('~/environments/components/new_environments_app.vue', () => { ...@@ -50,6 +68,7 @@ describe('~/environments/components/new_environments_app.vue', () => {
beforeEach(() => { beforeEach(() => {
environmentAppMock = jest.fn(); environmentAppMock = jest.fn();
environmentFolderMock = jest.fn(); environmentFolderMock = jest.fn();
paginationMock = jest.fn();
}); });
afterEach(() => { afterEach(() => {
...@@ -118,42 +137,135 @@ describe('~/environments/components/new_environments_app.vue', () => { ...@@ -118,42 +137,135 @@ describe('~/environments/components/new_environments_app.vue', () => {
expect(button.exists()).toBe(false); expect(button.exists()).toBe(false);
}); });
it('should show tabs for available and stopped environmets', async () => { describe('tabs', () => {
await createWrapperWithMocked({ it('should show tabs for available and stopped environmets', async () => {
environmentsApp: resolvedEnvironmentsApp, await createWrapperWithMocked({
folder: resolvedFolder, environmentsApp: resolvedEnvironmentsApp,
folder: resolvedFolder,
});
const [available, stopped] = wrapper.findAllByRole('tab').wrappers;
expect(available.text()).toContain(__('Available'));
expect(available.text()).toContain(resolvedEnvironmentsApp.availableCount);
expect(stopped.text()).toContain(__('Stopped'));
expect(stopped.text()).toContain(resolvedEnvironmentsApp.stoppedCount);
}); });
const [available, stopped] = wrapper.findAllByRole('tab').wrappers; it('should change the requested scope on tab change', async () => {
await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp,
folder: resolvedFolder,
});
const stopped = wrapper.findByRole('tab', {
name: `${__('Stopped')} ${resolvedEnvironmentsApp.stoppedCount}`,
});
stopped.trigger('click');
expect(available.text()).toContain(__('Available')); await nextTick();
expect(available.text()).toContain(resolvedEnvironmentsApp.availableCount); await waitForPromises();
expect(stopped.text()).toContain(__('Stopped'));
expect(stopped.text()).toContain(resolvedEnvironmentsApp.stoppedCount); expect(environmentAppMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ scope: 'stopped' }),
expect.anything(),
expect.anything(),
);
});
}); });
it('should change the requested scope on tab change', async () => { describe('pagination', () => {
environmentAppMock.mockReturnValue(resolvedEnvironmentsApp); it('should sync page from query params on load', async () => {
environmentFolderMock.mockReturnValue(resolvedFolder); await createWrapperWithMocked({
const apolloProvider = createApolloProvider(); environmentsApp: resolvedEnvironmentsApp,
wrapper = createWrapper({ apolloProvider }); folder: resolvedFolder,
});
await waitForPromises(); expect(wrapper.findComponent(GlPagination).props('value')).toBe(2);
await nextTick();
const stopped = wrapper.findByRole('tab', {
name: `${__('Stopped')} ${resolvedEnvironmentsApp.stoppedCount}`,
}); });
stopped.trigger('click'); it('should change the requested page on next page click', async () => {
await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp,
folder: resolvedFolder,
});
const next = wrapper.findByRole('link', {
name: __('Go to next page'),
});
await nextTick(); next.trigger('click');
await waitForPromises();
await nextTick();
await waitForPromises();
expect(environmentAppMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ page: 3 }),
expect.anything(),
expect.anything(),
);
});
it('should change the requested page on previous page click', async () => {
await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp,
folder: resolvedFolder,
});
const prev = wrapper.findByRole('link', {
name: __('Go to previous page'),
});
prev.trigger('click');
await nextTick();
await waitForPromises();
expect(environmentAppMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ page: 1 }),
expect.anything(),
expect.anything(),
);
});
it('should change the requested page on specific page click', async () => {
await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp,
folder: resolvedFolder,
});
const page = 1;
const pageButton = wrapper.findByRole('link', {
name: sprintf(__('Go to page %{page}'), { page }),
});
expect(environmentAppMock).toHaveBeenCalledWith( pageButton.trigger('click');
expect.anything(),
{ scope: 'stopped' }, await nextTick();
expect.anything(), await waitForPromises();
expect.anything(),
); expect(environmentAppMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ page }),
expect.anything(),
expect.anything(),
);
});
it('should sync the query params to the new page', async () => {
await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp,
folder: resolvedFolder,
});
const next = wrapper.findByRole('link', {
name: __('Go to next page'),
});
next.trigger('click');
await nextTick();
expect(window.location.search).toBe('?scope=available&page=3');
});
}); });
}); });
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