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>
import { GlBadge, GlTab, GlTabs } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility';
import environmentAppQuery from '../graphql/queries/environment_app.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 EnableReviewAppModal from './enable_review_app_modal.vue';
......@@ -11,6 +13,7 @@ export default {
EnvironmentFolder,
EnableReviewAppModal,
GlBadge,
GlPagination,
GlTab,
GlTabs,
},
......@@ -20,6 +23,7 @@ export default {
variables() {
return {
scope: this.scope,
page: this.page ?? 1,
};
},
pollInterval() {
......@@ -29,6 +33,9 @@ export default {
interval: {
query: pollIntervalQuery,
},
pageInfo: {
query: pageInfoQuery,
},
},
inject: ['newEnvironmentPath', 'canCreateEnvironment'],
i18n: {
......@@ -36,11 +43,21 @@ export default {
reviewAppButtonLabel: s__('Environments|Enable review app'),
available: __('Available'),
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',
data() {
const scope = new URLSearchParams(window.location.search).get('scope') || 'available';
return { interval: undefined, scope, isReviewAppModalVisible: false };
const { page = '1', scope = 'available' } = queryToObject(window.location.search);
return {
interval: undefined,
isReviewAppModalVisible: false,
page: parseInt(page, 10),
scope,
};
},
computed: {
canSetupReviewApp() {
......@@ -82,6 +99,19 @@ export default {
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: {
showReviewAppModal() {
......@@ -89,12 +119,30 @@ export default {
},
setScope(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.$nextTick(() => {
if (this.interval) {
this.$apollo.queries.environmentApp.startPolling(this.interval);
} else {
this.$apollo.queries.environmentApp.refetch({ scope });
this.$apollo.queries.environmentApp.refetch({ scope: this.scope, page: this.page });
}
});
},
......@@ -139,5 +187,19 @@ export default {
class="gl-mb-3"
: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>
</template>
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import environmentApp from './queries/environment_app.query.graphql';
import pageInfoQuery from './queries/page_info.query.graphql';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
......@@ -19,6 +20,19 @@ export const apolloProvider = (endpoint) => {
stoppedCount: 0,
},
});
cache.writeQuery({
query: pageInfoQuery,
data: {
pageInfo: {
total: 0,
perPage: 20,
nextPage: 0,
previousPage: 0,
__typename: 'LocalPageInfo',
},
},
});
return new VueApollo({
defaultClient,
});
......
query getEnvironmentApp($scope: String) {
environmentApp(scope: $scope) @client {
query getEnvironmentApp($page: Int, $scope: String) {
environmentApp(page: $page, scope: $scope) @client {
availableCount
stoppedCount
environments
......
query getPageInfo {
pageInfo @client {
total
perPage
nextPage
previousPage
}
}
import axios from '~/lib/utils/axios_utils';
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 environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql';
import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
import pageInfoQuery from './queries/page_info.query.graphql';
const buildErrors = (errors = []) => ({
errors,
......@@ -21,9 +27,11 @@ const mapEnvironment = (env) => ({
export const resolvers = (endpoint) => ({
Query: {
environmentApp(_context, { scope }, { cache }) {
return axios.get(endpoint, { params: { nested: true, scope } }).then((res) => {
const interval = res.headers['poll-interval'];
environmentApp(_context, { page, scope }, { cache }) {
return axios.get(endpoint, { params: { nested: true, page, scope } }).then((res) => {
const headers = normalizeHeaders(res.headers);
const interval = headers['POLL-INTERVAL'];
const pageInfo = { ...parseIntPagination(headers), __typename: 'LocalPageInfo' };
if (interval) {
cache.writeQuery({ query: pollIntervalQuery, data: { interval: parseFloat(interval) } });
......@@ -31,6 +39,11 @@ export const resolvers = (endpoint) => ({
cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } });
}
cache.writeQuery({
query: pageInfoQuery,
data: { pageInfo },
});
return {
availableCount: res.data.available_count,
environments: res.data.environments.map(mapNestedEnvironment),
......
......@@ -55,10 +55,18 @@ type LocalErrors {
errors: [String!]!
}
type LocalPageInfo {
total: Int!
perPage: Int!
nextPage: Int!
previousPage: Int!
}
extend type Query {
environmentApp: LocalEnvironmentApp
environmentApp(page: Int, scope: String): LocalEnvironmentApp
folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder
environmentToDelete: LocalEnvironment
pageInfo: LocalPageInfo
environmentToRollback: LocalEnvironment
isLastDeployment: Boolean
}
......
......@@ -16352,6 +16352,9 @@ msgstr ""
msgid "Go to next page"
msgstr ""
msgid "Go to page %{page}"
msgstr ""
msgid "Go to parent"
msgstr ""
......
......@@ -5,6 +5,7 @@ import environmentToRollback from '~/environments/graphql/queries/environment_to
import environmentToDelete from '~/environments/graphql/queries/environment_to_delete.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
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 {
environmentsApp,
......@@ -37,9 +38,11 @@ describe('~/frontend/environments/graphql/resolvers', () => {
it('should fetch environments and map them to frontend data', async () => {
const cache = { writeQuery: jest.fn() };
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(cache.writeQuery).toHaveBeenCalledWith({
query: pollIntervalQuery,
......@@ -49,14 +52,70 @@ describe('~/frontend/environments/graphql/resolvers', () => {
it('should set the poll interval when there is one', async () => {
const cache = { writeQuery: jest.fn() };
const scope = 'stopped';
const interval = 3000;
mock
.onGet(ENDPOINT, { params: { nested: true, scope } })
.reply(200, environmentsApp, { 'poll-interval': 3000 });
.onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } })
.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({
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 VueApollo from 'vue-apollo';
import { GlPagination } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
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 EnvironmentsFolder from '~/environments/components/new_environment_folder.vue';
import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data';
......@@ -14,12 +16,14 @@ describe('~/environments/components/new_environments_app.vue', () => {
let wrapper;
let environmentAppMock;
let environmentFolderMock;
let paginationMock;
const createApolloProvider = () => {
const mockResolvers = {
Query: {
environmentApp: environmentAppMock,
folder: environmentFolderMock,
pageInfo: paginationMock,
},
};
......@@ -37,9 +41,23 @@ describe('~/environments/components/new_environments_app.vue', () => {
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);
environmentFolderMock.mockReturnValue(folder);
paginationMock.mockReturnValue(pageInfo);
const apolloProvider = createApolloProvider();
wrapper = createWrapper({ apolloProvider, provide });
......@@ -50,6 +68,7 @@ describe('~/environments/components/new_environments_app.vue', () => {
beforeEach(() => {
environmentAppMock = jest.fn();
environmentFolderMock = jest.fn();
paginationMock = jest.fn();
});
afterEach(() => {
......@@ -118,6 +137,7 @@ describe('~/environments/components/new_environments_app.vue', () => {
expect(button.exists()).toBe(false);
});
describe('tabs', () => {
it('should show tabs for available and stopped environmets', async () => {
await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp,
......@@ -133,13 +153,10 @@ describe('~/environments/components/new_environments_app.vue', () => {
});
it('should change the requested scope on tab change', async () => {
environmentAppMock.mockReturnValue(resolvedEnvironmentsApp);
environmentFolderMock.mockReturnValue(resolvedFolder);
const apolloProvider = createApolloProvider();
wrapper = createWrapper({ apolloProvider });
await waitForPromises();
await nextTick();
await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp,
folder: resolvedFolder,
});
const stopped = wrapper.findByRole('tab', {
name: `${__('Stopped')} ${resolvedEnvironmentsApp.stoppedCount}`,
});
......@@ -151,9 +168,104 @@ describe('~/environments/components/new_environments_app.vue', () => {
expect(environmentAppMock).toHaveBeenCalledWith(
expect.anything(),
{ scope: 'stopped' },
expect.objectContaining({ scope: 'stopped' }),
expect.anything(),
expect.anything(),
);
});
});
describe('pagination', () => {
it('should sync page from query params on load', async () => {
await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp,
folder: resolvedFolder,
});
expect(wrapper.findComponent(GlPagination).props('value')).toBe(2);
});
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'),
});
next.trigger('click');
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 }),
});
pageButton.trigger('click');
await nextTick();
await waitForPromises();
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