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> <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
} }
......
...@@ -16406,6 +16406,9 @@ msgstr "" ...@@ -16406,6 +16406,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,6 +137,7 @@ describe('~/environments/components/new_environments_app.vue', () => { ...@@ -118,6 +137,7 @@ describe('~/environments/components/new_environments_app.vue', () => {
expect(button.exists()).toBe(false); expect(button.exists()).toBe(false);
}); });
describe('tabs', () => {
it('should show tabs for available and stopped environmets', async () => { it('should show tabs for available and stopped environmets', async () => {
await createWrapperWithMocked({ await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp, environmentsApp: resolvedEnvironmentsApp,
...@@ -133,13 +153,10 @@ describe('~/environments/components/new_environments_app.vue', () => { ...@@ -133,13 +153,10 @@ describe('~/environments/components/new_environments_app.vue', () => {
}); });
it('should change the requested scope on tab change', async () => { it('should change the requested scope on tab change', async () => {
environmentAppMock.mockReturnValue(resolvedEnvironmentsApp); await createWrapperWithMocked({
environmentFolderMock.mockReturnValue(resolvedFolder); environmentsApp: resolvedEnvironmentsApp,
const apolloProvider = createApolloProvider(); folder: resolvedFolder,
wrapper = createWrapper({ apolloProvider }); });
await waitForPromises();
await nextTick();
const stopped = wrapper.findByRole('tab', { const stopped = wrapper.findByRole('tab', {
name: `${__('Stopped')} ${resolvedEnvironmentsApp.stoppedCount}`, name: `${__('Stopped')} ${resolvedEnvironmentsApp.stoppedCount}`,
}); });
...@@ -151,9 +168,104 @@ describe('~/environments/components/new_environments_app.vue', () => { ...@@ -151,9 +168,104 @@ describe('~/environments/components/new_environments_app.vue', () => {
expect(environmentAppMock).toHaveBeenCalledWith( expect(environmentAppMock).toHaveBeenCalledWith(
expect.anything(), 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(),
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