Commit 13a9df76 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch 'afontaine/new-environments-pagination' into 'master'

Add pagination to new environments app

See merge request gitlab-org/gitlab!76493
parents 4b4c4f27 90758d39
<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
}
......
......@@ -16406,6 +16406,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,42 +137,135 @@ describe('~/environments/components/new_environments_app.vue', () => {
expect(button.exists()).toBe(false);
});
it('should show tabs for available and stopped environmets', async () => {
await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp,
folder: resolvedFolder,
describe('tabs', () => {
it('should show tabs for available and stopped environmets', async () => {
await createWrapperWithMocked({
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'));
expect(available.text()).toContain(resolvedEnvironmentsApp.availableCount);
expect(stopped.text()).toContain(__('Stopped'));
expect(stopped.text()).toContain(resolvedEnvironmentsApp.stoppedCount);
await nextTick();
await waitForPromises();
expect(environmentAppMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ scope: 'stopped' }),
expect.anything(),
expect.anything(),
);
});
});
it('should change the requested scope on tab change', async () => {
environmentAppMock.mockReturnValue(resolvedEnvironmentsApp);
environmentFolderMock.mockReturnValue(resolvedFolder);
const apolloProvider = createApolloProvider();
wrapper = createWrapper({ apolloProvider });
describe('pagination', () => {
it('should sync page from query params on load', async () => {
await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp,
folder: resolvedFolder,
});
await waitForPromises();
await nextTick();
const stopped = wrapper.findByRole('tab', {
name: `${__('Stopped')} ${resolvedEnvironmentsApp.stoppedCount}`,
expect(wrapper.findComponent(GlPagination).props('value')).toBe(2);
});
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();
await waitForPromises();
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 }),
});
expect(environmentAppMock).toHaveBeenCalledWith(
expect.anything(),
{ scope: 'stopped' },
expect.anything(),
expect.anything(),
);
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