Commit dfdfb82d authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch 'xanf-add-pagination-support-to-import-projects' into 'master'

Add pagination support to import_projects application

See merge request gitlab-org/gitlab!39598
parents 0894c467 76ed471b
...@@ -9,6 +9,7 @@ export default { ...@@ -9,6 +9,7 @@ export default {
GlSprintf, GlSprintf,
GlLink, GlLink,
}, },
inheritAttrs: false,
props: { props: {
providerTitle: { providerTitle: {
type: String, type: String,
...@@ -28,7 +29,7 @@ export default { ...@@ -28,7 +29,7 @@ export default {
}; };
</script> </script>
<template> <template>
<import-projects-table :provider-title="providerTitle"> <import-projects-table :provider-title="providerTitle" v-bind="$attrs">
<template #actions> <template #actions>
<slot name="actions"></slot> <slot name="actions"></slot>
</template> </template>
......
...@@ -3,9 +3,11 @@ import { throttle } from 'lodash'; ...@@ -3,9 +3,11 @@ import { throttle } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import ImportedProjectTableRow from './imported_project_table_row.vue'; import ImportedProjectTableRow from './imported_project_table_row.vue';
import ProviderRepoTableRow from './provider_repo_table_row.vue'; import ProviderRepoTableRow from './provider_repo_table_row.vue';
import IncompatibleRepoTableRow from './incompatible_repo_table_row.vue'; import IncompatibleRepoTableRow from './incompatible_repo_table_row.vue';
import PageQueryParamSync from './page_query_param_sync.vue';
import { isProjectImportable } from '../utils'; import { isProjectImportable } from '../utils';
const reposFetchThrottleDelay = 1000; const reposFetchThrottleDelay = 1000;
...@@ -16,8 +18,10 @@ export default { ...@@ -16,8 +18,10 @@ export default {
ImportedProjectTableRow, ImportedProjectTableRow,
ProviderRepoTableRow, ProviderRepoTableRow,
IncompatibleRepoTableRow, IncompatibleRepoTableRow,
PageQueryParamSync,
GlLoadingIcon, GlLoadingIcon,
GlButton, GlButton,
PaginationLinks,
}, },
props: { props: {
providerTitle: { providerTitle: {
...@@ -29,10 +33,15 @@ export default { ...@@ -29,10 +33,15 @@ export default {
required: false, required: false,
default: true, default: true,
}, },
paginatable: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace']), ...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace', 'pageInfo']),
...mapGetters([ ...mapGetters([
'isLoading', 'isLoading',
'isImportingAnyRepo', 'isImportingAnyRepo',
...@@ -90,6 +99,7 @@ export default { ...@@ -90,6 +99,7 @@ export default {
'clearJobsEtagPoll', 'clearJobsEtagPoll',
'setFilter', 'setFilter',
'importAll', 'importAll',
'setPage',
]), ]),
handleFilterInput({ target }) { handleFilterInput({ target }) {
...@@ -107,69 +117,82 @@ export default { ...@@ -107,69 +117,82 @@ export default {
<template> <template>
<div> <div>
<page-query-param-sync :page="pageInfo.page" @popstate="setPage" />
<p class="light text-nowrap mt-2"> <p class="light text-nowrap mt-2">
{{ s__('ImportProjects|Select the projects you want to import') }} {{ s__('ImportProjects|Select the projects you want to import') }}
</p> </p>
<template v-if="hasIncompatibleRepos"> <template v-if="hasIncompatibleRepos">
<slot name="incompatible-repos-warning"></slot> <slot name="incompatible-repos-warning"></slot>
</template> </template>
<div v-if="!isLoading" class="d-flex justify-content-between align-items-end flex-wrap mb-3">
<gl-button
variant="success"
:loading="isImportingAnyRepo"
:disabled="!hasImportableRepos"
type="button"
@click="importAll"
>{{ importAllButtonText }}</gl-button
>
<slot name="actions"></slot>
<form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent>
<input
:value="filter"
data-qa-selector="githubish_import_filter_field"
class="form-control"
name="filter"
:placeholder="__('Filter your projects by name')"
autofocus
size="40"
@input="handleFilterInput($event)"
@keyup.enter="throttledFetchRepos"
/>
</form>
</div>
<gl-loading-icon <gl-loading-icon
v-if="isLoading" v-if="isLoading"
class="js-loading-button-icon import-projects-loading-icon" class="js-loading-button-icon import-projects-loading-icon"
size="md" size="md"
/> />
<div v-else-if="repositories.length" class="table-responsive"> <template v-if="!isLoading">
<table class="table import-table"> <div class="d-flex justify-content-between align-items-end flex-wrap mb-3">
<thead> <gl-button
<th class="import-jobs-from-col">{{ fromHeaderText }}</th> variant="success"
<th class="import-jobs-to-col">{{ __('To GitLab') }}</th> :loading="isImportingAnyRepo"
<th class="import-jobs-status-col">{{ __('Status') }}</th> :disabled="!hasImportableRepos"
<th class="import-jobs-cta-col"></th> type="button"
</thead> @click="importAll"
<tbody> >{{ importAllButtonText }}</gl-button
<template v-for="repo in repositories"> >
<incompatible-repo-table-row <slot name="actions"></slot>
v-if="repo.importSource.incompatible" <form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent>
:key="repo.importSource.id" <input
:repo="repo" :value="filter"
/> data-qa-selector="githubish_import_filter_field"
<provider-repo-table-row class="form-control"
v-else-if="isProjectImportable(repo)" name="filter"
:key="repo.importSource.id" :placeholder="__('Filter your projects by name')"
:repo="repo" autofocus
:available-namespaces="availableNamespaces" size="40"
/> @input="handleFilterInput($event)"
<imported-project-table-row v-else :key="repo.importSource.id" :project="repo" /> @keyup.enter="throttledFetchRepos"
</template> />
</tbody> </form>
</table> </div>
</div> <div v-if="repositories.length" class="table-responsive">
<div v-else class="text-center"> <table class="table import-table">
<strong>{{ emptyStateText }}</strong> <thead>
</div> <th class="import-jobs-from-col">{{ fromHeaderText }}</th>
<th class="import-jobs-to-col">{{ __('To GitLab') }}</th>
<th class="import-jobs-status-col">{{ __('Status') }}</th>
<th class="import-jobs-cta-col"></th>
</thead>
<tbody>
<template v-for="repo in repositories">
<incompatible-repo-table-row
v-if="repo.importSource.incompatible"
:key="repo.importSource.id"
:repo="repo"
/>
<provider-repo-table-row
v-else-if="isProjectImportable(repo)"
:key="repo.importSource.id"
:repo="repo"
:available-namespaces="availableNamespaces"
/>
<imported-project-table-row v-else :key="repo.importSource.id" :project="repo" />
</template>
</tbody>
</table>
</div>
<div v-else class="text-center">
<strong>{{ emptyStateText }}</strong>
</div>
<pagination-links
v-if="paginatable"
align="center"
class="gl-mt-3"
:page-info="pageInfo"
:prev-page="pageInfo.page - 1"
:next-page="repositories.length && pageInfo.page + 1"
:change="setPage"
/>
</template>
</div> </div>
</template> </template>
<script>
import { queryToObject, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
export default {
props: {
page: {
type: Number,
required: true,
},
},
watch: {
page(newPage) {
updateHistory({
url: setUrlParams({
page: newPage === 1 ? null : newPage,
}),
});
},
},
created() {
window.addEventListener('popstate', this.updatePage);
},
beforeDestroy() {
window.removeEventListener('popstate', this.updatePage);
},
methods: {
updatePage() {
const page = parseInt(queryToObject(window.location.search).page, 10) || 1;
this.$emit('popstate', page);
},
},
render: () => null,
};
</script>
...@@ -2,6 +2,7 @@ import Vue from 'vue'; ...@@ -2,6 +2,7 @@ import Vue from 'vue';
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
import ImportProjectsTable from './components/import_projects_table.vue'; import ImportProjectsTable from './components/import_projects_table.vue';
import { parseBoolean } from '../lib/utils/common_utils'; import { parseBoolean } from '../lib/utils/common_utils';
import { queryToObject } from '../lib/utils/url_utility';
import createStore from './store'; import createStore from './store';
Vue.use(Translate); Vue.use(Translate);
...@@ -16,14 +17,21 @@ export function initStoreFromElement(element) { ...@@ -16,14 +17,21 @@ export function initStoreFromElement(element) {
jobsPath, jobsPath,
importPath, importPath,
namespacesPath, namespacesPath,
paginatable,
} = element.dataset; } = element.dataset;
const params = queryToObject(document.location.search);
const page = parseInt(params.page ?? 1, 10);
return createStore({ return createStore({
initialState: { initialState: {
defaultTargetNamespace: gon.current_username, defaultTargetNamespace: gon.current_username,
ciCdOnly: parseBoolean(ciCdOnly), ciCdOnly: parseBoolean(ciCdOnly),
canSelectNamespace: parseBoolean(canSelectNamespace), canSelectNamespace: parseBoolean(canSelectNamespace),
provider, provider,
pageInfo: {
page,
},
}, },
endpoints: { endpoints: {
reposPath, reposPath,
...@@ -31,6 +39,7 @@ export function initStoreFromElement(element) { ...@@ -31,6 +39,7 @@ export function initStoreFromElement(element) {
importPath, importPath,
namespacesPath, namespacesPath,
}, },
hasPagination: parseBoolean(paginatable),
}); });
} }
...@@ -38,6 +47,7 @@ export function initPropsFromElement(element) { ...@@ -38,6 +47,7 @@ export function initPropsFromElement(element) {
return { return {
providerTitle: element.dataset.providerTitle, providerTitle: element.dataset.providerTitle,
filterable: parseBoolean(element.dataset.filterable), filterable: parseBoolean(element.dataset.filterable),
paginatable: parseBoolean(element.dataset.paginatable),
}; };
} }
......
import Visibility from 'visibilityjs'; import Visibility from 'visibilityjs';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { isProjectImportable } from '../utils'; import { isProjectImportable } from '../utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import {
convertObjectPropsToCamelCase,
normalizeHeaders,
parseIntPagination,
} from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll'; import Poll from '~/lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl, objectToQuery } from '~/lib/utils/url_utility';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
...@@ -12,7 +16,13 @@ let eTagPoll; ...@@ -12,7 +16,13 @@ let eTagPoll;
const hasRedirectInError = e => e?.response?.data?.error?.redirect; const hasRedirectInError = e => e?.response?.data?.error?.redirect;
const redirectToUrlInError = e => visitUrl(e.response.data.error.redirect); const redirectToUrlInError = e => visitUrl(e.response.data.error.redirect);
const pathWithFilter = ({ path, filter = '' }) => (filter ? `${path}?filter=${filter}` : path); const pathWithParams = ({ path, ...params }) => {
const filteredParams = Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== ''),
);
const queryString = objectToQuery(filteredParams);
return queryString ? `${path}?${queryString}` : path;
};
const isRequired = () => { const isRequired = () => {
// eslint-disable-next-line @gitlab/require-i18n-strings // eslint-disable-next-line @gitlab/require-i18n-strings
...@@ -44,17 +54,33 @@ const importAll = ({ state, dispatch }) => { ...@@ -44,17 +54,33 @@ const importAll = ({ state, dispatch }) => {
); );
}; };
const fetchReposFactory = (reposPath = isRequired()) => ({ state, dispatch, commit }) => { const fetchReposFactory = ({ reposPath = isRequired(), hasPagination }) => ({
state,
dispatch,
commit,
}) => {
dispatch('stopJobsPolling'); dispatch('stopJobsPolling');
commit(types.REQUEST_REPOS); commit(types.REQUEST_REPOS);
const { provider, filter } = state; const { provider, filter } = state;
return axios return axios
.get(pathWithFilter({ path: reposPath, filter })) .get(
.then(({ data }) => pathWithParams({
commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), path: reposPath,
filter,
page: hasPagination ? state.pageInfo.page.toString() : '',
}),
) )
.then(({ data, headers }) => {
const normalizedHeaders = normalizeHeaders(headers);
if ('X-PAGE' in normalizedHeaders) {
commit(types.SET_PAGE_INFO, parseIntPagination(normalizedHeaders));
}
commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true }));
})
.then(() => dispatch('fetchJobs')) .then(() => dispatch('fetchJobs'))
.catch(e => { .catch(e => {
if (hasRedirectInError(e)) { if (hasRedirectInError(e)) {
...@@ -85,12 +111,12 @@ const fetchImportFactory = (importPath = isRequired()) => ({ state, commit, gett ...@@ -85,12 +111,12 @@ const fetchImportFactory = (importPath = isRequired()) => ({ state, commit, gett
new_name: newName, new_name: newName,
target_namespace: targetNamespace, target_namespace: targetNamespace,
}) })
.then(({ data }) => .then(({ data }) => {
commit(types.RECEIVE_IMPORT_SUCCESS, { commit(types.RECEIVE_IMPORT_SUCCESS, {
importedProject: convertObjectPropsToCamelCase(data, { deep: true }), importedProject: convertObjectPropsToCamelCase(data, { deep: true }),
repoId, repoId,
}), });
) })
.catch(e => { .catch(e => {
const serverErrorMessage = e?.response?.data?.errors; const serverErrorMessage = e?.response?.data?.errors;
const flashMessage = serverErrorMessage const flashMessage = serverErrorMessage
...@@ -119,7 +145,7 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d ...@@ -119,7 +145,7 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d
eTagPoll = new Poll({ eTagPoll = new Poll({
resource: { resource: {
fetchJobs: () => axios.get(pathWithFilter({ path: jobsPath, filter })), fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, filter })),
}, },
method: 'fetchJobs', method: 'fetchJobs',
successCallback: ({ data }) => successCallback: ({ data }) =>
...@@ -161,14 +187,24 @@ const fetchNamespacesFactory = (namespacesPath = isRequired()) => ({ commit }) = ...@@ -161,14 +187,24 @@ const fetchNamespacesFactory = (namespacesPath = isRequired()) => ({ commit }) =
}); });
}; };
export default ({ endpoints = isRequired() }) => ({ const setPage = ({ state, commit, dispatch }, page) => {
if (page === state.pageInfo.page) {
return null;
}
commit(types.SET_PAGE, page);
return dispatch('fetchRepos');
};
export default ({ endpoints = isRequired(), hasPagination }) => ({
clearJobsEtagPoll, clearJobsEtagPoll,
stopJobsPolling, stopJobsPolling,
restartJobsPolling, restartJobsPolling,
setFilter, setFilter,
setImportTarget, setImportTarget,
importAll, importAll,
fetchRepos: fetchReposFactory(endpoints.reposPath), setPage,
fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath, hasPagination }),
fetchImport: fetchImportFactory(endpoints.importPath), fetchImport: fetchImportFactory(endpoints.importPath),
fetchJobs: fetchJobsFactory(endpoints.jobsPath), fetchJobs: fetchJobsFactory(endpoints.jobsPath),
fetchNamespaces: fetchNamespacesFactory(endpoints.namespacesPath), fetchNamespaces: fetchNamespacesFactory(endpoints.namespacesPath),
......
...@@ -7,10 +7,10 @@ import mutations from './mutations'; ...@@ -7,10 +7,10 @@ import mutations from './mutations';
Vue.use(Vuex); Vue.use(Vuex);
export default ({ initialState, endpoints }) => export default ({ initialState, endpoints, hasPagination }) =>
new Vuex.Store({ new Vuex.Store({
state: { ...state(), ...initialState }, state: { ...state(), ...initialState },
actions: actionsFactory({ endpoints }), actions: actionsFactory({ endpoints, hasPagination }),
mutations, mutations,
getters, getters,
}); });
...@@ -15,3 +15,7 @@ export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS'; ...@@ -15,3 +15,7 @@ export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS';
export const SET_FILTER = 'SET_FILTER'; export const SET_FILTER = 'SET_FILTER';
export const SET_IMPORT_TARGET = 'SET_IMPORT_TARGET'; export const SET_IMPORT_TARGET = 'SET_IMPORT_TARGET';
export const SET_PAGE = 'SET_PAGE';
export const SET_PAGE_INFO = 'SET_PAGE_INFO';
...@@ -104,4 +104,12 @@ export default { ...@@ -104,4 +104,12 @@ export default {
Vue.set(state.customImportTargets, repoId, importTarget); Vue.set(state.customImportTargets, repoId, importTarget);
} }
}, },
[types.SET_PAGE_INFO](state, pageInfo) {
state.pageInfo = pageInfo;
},
[types.SET_PAGE](state, page) {
state.pageInfo.page = page;
},
}; };
...@@ -7,4 +7,7 @@ export default () => ({ ...@@ -7,4 +7,7 @@ export default () => ({
isLoadingNamespaces: false, isLoadingNamespaces: false,
ciCdOnly: false, ciCdOnly: false,
filter: '', filter: '',
pageInfo: {
page: 1,
},
}); });
...@@ -7,13 +7,13 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -7,13 +7,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (!mountElement) return undefined; if (!mountElement) return undefined;
const store = initStoreFromElement(mountElement); const store = initStoreFromElement(mountElement);
const props = initPropsFromElement(mountElement); const attrs = initPropsFromElement(mountElement);
return new Vue({ return new Vue({
el: mountElement, el: mountElement,
store, store,
render(createElement) { render(createElement) {
return createElement(BitbucketStatusTable, { props }); return createElement(BitbucketStatusTable, { attrs });
}, },
}); });
}); });
...@@ -7,11 +7,8 @@ export default { ...@@ -7,11 +7,8 @@ export default {
BitbucketStatusTable, BitbucketStatusTable,
GlButton, GlButton,
}, },
inheritAttrs: false,
props: { props: {
providerTitle: {
type: String,
required: true,
},
reconfigurePath: { reconfigurePath: {
type: String, type: String,
required: true, required: true,
...@@ -20,7 +17,7 @@ export default { ...@@ -20,7 +17,7 @@ export default {
}; };
</script> </script>
<template> <template>
<bitbucket-status-table :provider-title="providerTitle"> <bitbucket-status-table v-bind="$attrs">
<template #actions> <template #actions>
<gl-button variant="info" class="gl-ml-3" data-method="post" :href="reconfigurePath">{{ <gl-button variant="info" class="gl-ml-3" data-method="post" :href="reconfigurePath">{{
__('Reconfigure') __('Reconfigure')
......
...@@ -7,14 +7,16 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -7,14 +7,16 @@ document.addEventListener('DOMContentLoaded', () => {
if (!mountElement) return undefined; if (!mountElement) return undefined;
const store = initStoreFromElement(mountElement); const store = initStoreFromElement(mountElement);
const props = initPropsFromElement(mountElement); const attrs = initPropsFromElement(mountElement);
const { reconfigurePath } = mountElement.dataset; const { reconfigurePath } = mountElement.dataset;
return new Vue({ return new Vue({
el: mountElement, el: mountElement,
store, store,
render(createElement) { render(createElement) {
return createElement(BitbucketServerStatusTable, { props: { ...props, reconfigurePath } }); return createElement(BitbucketServerStatusTable, {
attrs: { ...attrs, reconfigurePath },
});
}, },
}); });
}); });
- provider = local_assigns.fetch(:provider) - provider = local_assigns.fetch(:provider)
- extra_data = local_assigns.fetch(:extra_data, {}) - extra_data = local_assigns.fetch(:extra_data, {})
- filterable = local_assigns.fetch(:filterable, true) - filterable = local_assigns.fetch(:filterable, true)
- paginatable = local_assigns.fetch(:paginatable, false)
- provider_title = Gitlab::ImportSources.title(provider) - provider_title = Gitlab::ImportSources.title(provider)
#import-projects-mount-element{ data: { provider: provider, provider_title: provider_title, #import-projects-mount-element{ data: { provider: provider, provider_title: provider_title,
...@@ -10,4 +11,5 @@ ...@@ -10,4 +11,5 @@
repos_path: url_for([:status, :import, provider, format: :json]), repos_path: url_for([:status, :import, provider, format: :json]),
jobs_path: url_for([:realtime_changes, :import, provider, format: :json]), jobs_path: url_for([:realtime_changes, :import, provider, format: :json]),
import_path: url_for([:import, provider, format: :json]), import_path: url_for([:import, provider, format: :json]),
filterable: filterable.to_s }.merge(extra_data) } filterable: filterable.to_s,
paginatable: paginatable.to_s }.merge(extra_data) }
...@@ -5,4 +5,4 @@ ...@@ -5,4 +5,4 @@
%i.fa.fa-bitbucket-square %i.fa.fa-bitbucket-square
= _('Import projects from Bitbucket Server') = _('Import projects from Bitbucket Server')
= render 'import/githubish_status', provider: 'bitbucket_server', extra_data: { reconfigure_path: configure_import_bitbucket_server_path } = render 'import/githubish_status', provider: 'bitbucket_server', paginatable: true, extra_data: { reconfigure_path: configure_import_bitbucket_server_path }
---
title: Fix pagination for bitbucket server importer
merge_request: 39598
author:
type: fixed
...@@ -9,6 +9,7 @@ import ImportProjectsTable from '~/import_projects/components/import_projects_ta ...@@ -9,6 +9,7 @@ import ImportProjectsTable from '~/import_projects/components/import_projects_ta
import ImportedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue'; import ImportedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue';
import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue';
import IncompatibleRepoTableRow from '~/import_projects/components/incompatible_repo_table_row.vue'; import IncompatibleRepoTableRow from '~/import_projects/components/incompatible_repo_table_row.vue';
import PageQueryParamSync from '~/import_projects/components/page_query_param_sync.vue';
describe('ImportProjectsTable', () => { describe('ImportProjectsTable', () => {
let wrapper; let wrapper;
...@@ -26,11 +27,14 @@ describe('ImportProjectsTable', () => { ...@@ -26,11 +27,14 @@ describe('ImportProjectsTable', () => {
.at(0); .at(0);
const importAllFn = jest.fn(); const importAllFn = jest.fn();
const setPageFn = jest.fn();
function createComponent({ function createComponent({
state: initialState, state: initialState,
getters: customGetters, getters: customGetters,
slots, slots,
filterable, filterable,
paginatable,
} = {}) { } = {}) {
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -49,6 +53,7 @@ describe('ImportProjectsTable', () => { ...@@ -49,6 +53,7 @@ describe('ImportProjectsTable', () => {
stopJobsPolling: jest.fn(), stopJobsPolling: jest.fn(),
clearJobsEtagPoll: jest.fn(), clearJobsEtagPoll: jest.fn(),
setFilter: jest.fn(), setFilter: jest.fn(),
setPage: setPageFn,
}, },
}); });
...@@ -58,6 +63,7 @@ describe('ImportProjectsTable', () => { ...@@ -58,6 +63,7 @@ describe('ImportProjectsTable', () => {
propsData: { propsData: {
providerTitle, providerTitle,
filterable, filterable,
paginatable,
}, },
slots, slots,
}); });
...@@ -167,6 +173,37 @@ describe('ImportProjectsTable', () => { ...@@ -167,6 +173,37 @@ describe('ImportProjectsTable', () => {
expect(findFilterField().exists()).toBe(false); expect(findFilterField().exists()).toBe(false);
}); });
describe('when paginatable is set to true', () => {
const pageInfo = { page: 1 };
beforeEach(() => {
createComponent({
state: {
namespaces: [{ fullPath: 'path' }],
pageInfo,
repositories: [
{ importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE },
],
},
paginatable: true,
});
});
it('passes current page to page-query-param-sync component', () => {
expect(wrapper.find(PageQueryParamSync).props().page).toBe(pageInfo.page);
});
it('dispatches setPage when page-query-param-sync emits popstate', () => {
const NEW_PAGE = 2;
wrapper.find(PageQueryParamSync).vm.$emit('popstate', NEW_PAGE);
const { calls } = setPageFn.mock;
expect(calls).toHaveLength(1);
expect(calls[0][1]).toBe(NEW_PAGE);
});
});
it.each` it.each`
hasIncompatibleRepos | shouldRenderSlot | action hasIncompatibleRepos | shouldRenderSlot | action
${false} | ${false} | ${'does not render'} ${false} | ${false} | ${'does not render'}
......
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
import PageQueryParamSync from '~/import_projects/components/page_query_param_sync.vue';
describe('PageQueryParamSync', () => {
let originalPushState;
let originalAddEventListener;
let originalRemoveEventListener;
const pushStateMock = jest.fn();
const addEventListenerMock = jest.fn();
const removeEventListenerMock = jest.fn();
beforeAll(() => {
window.location.search = '';
originalPushState = window.pushState;
window.history.pushState = pushStateMock;
originalAddEventListener = window.addEventListener;
window.addEventListener = addEventListenerMock;
originalRemoveEventListener = window.removeEventListener;
window.removeEventListener = removeEventListenerMock;
});
afterAll(() => {
window.history.pushState = originalPushState;
window.addEventListener = originalAddEventListener;
window.removeEventListener = originalRemoveEventListener;
});
let wrapper;
beforeEach(() => {
wrapper = shallowMount(PageQueryParamSync, {
propsData: { page: 3 },
});
});
afterEach(() => {
wrapper.destroy();
});
it('calls push state with page number when page is updated and differs from 1', async () => {
wrapper.setProps({ page: 2 });
await nextTick();
const { calls } = pushStateMock.mock;
expect(calls).toHaveLength(1);
expect(calls[0][2]).toBe(`${TEST_HOST}/?page=2`);
});
it('calls push state without page number when page is updated and is 1', async () => {
wrapper.setProps({ page: 1 });
await nextTick();
const { calls } = pushStateMock.mock;
expect(calls).toHaveLength(1);
expect(calls[0][2]).toBe(`${TEST_HOST}/`);
});
it('subscribes to popstate event on create', () => {
expect(addEventListenerMock).toHaveBeenCalledWith('popstate', expect.any(Function));
});
it('unsubscribes from popstate event when destroyed', () => {
const [, fn] = addEventListenerMock.mock.calls[0];
wrapper.destroy();
expect(removeEventListenerMock).toHaveBeenCalledWith('popstate', fn);
});
it('emits popstate event when popstate is triggered', async () => {
const [, fn] = addEventListenerMock.mock.calls[0];
delete window.location;
window.location = new URL(`${TEST_HOST}/?page=5`);
fn();
expect(wrapper.emitted().popstate[0]).toStrictEqual([5]);
});
});
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
REQUEST_NAMESPACES, REQUEST_NAMESPACES,
RECEIVE_NAMESPACES_SUCCESS, RECEIVE_NAMESPACES_SUCCESS,
RECEIVE_NAMESPACES_ERROR, RECEIVE_NAMESPACES_ERROR,
SET_PAGE,
} from '~/import_projects/store/mutation_types'; } from '~/import_projects/store/mutation_types';
import actionsFactory from '~/import_projects/store/actions'; import actionsFactory from '~/import_projects/store/actions';
import { getImportTarget } from '~/import_projects/store/getters'; import { getImportTarget } from '~/import_projects/store/getters';
...@@ -24,6 +25,12 @@ import { STATUSES } from '~/import_projects/constants'; ...@@ -24,6 +25,12 @@ import { STATUSES } from '~/import_projects/constants';
jest.mock('~/flash'); jest.mock('~/flash');
const MOCK_ENDPOINT = `${TEST_HOST}/endpoint.json`; const MOCK_ENDPOINT = `${TEST_HOST}/endpoint.json`;
const endpoints = {
reposPath: MOCK_ENDPOINT,
importPath: MOCK_ENDPOINT,
jobsPath: MOCK_ENDPOINT,
namespacesPath: MOCK_ENDPOINT,
};
const { const {
clearJobsEtagPoll, clearJobsEtagPoll,
...@@ -33,13 +40,9 @@ const { ...@@ -33,13 +40,9 @@ const {
fetchImport, fetchImport,
fetchJobs, fetchJobs,
fetchNamespaces, fetchNamespaces,
setPage,
} = actionsFactory({ } = actionsFactory({
endpoints: { endpoints,
reposPath: MOCK_ENDPOINT,
importPath: MOCK_ENDPOINT,
jobsPath: MOCK_ENDPOINT,
namespacesPath: MOCK_ENDPOINT,
},
}); });
describe('import_projects store actions', () => { describe('import_projects store actions', () => {
...@@ -110,18 +113,39 @@ describe('import_projects store actions', () => { ...@@ -110,18 +113,39 @@ describe('import_projects store actions', () => {
); );
}); });
describe('when filtered', () => { describe('when pagination is enabled', () => {
beforeEach(() => { it('includes page in url query params', async () => {
localState.filter = 'filter'; const { fetchRepos: fetchReposWithPagination } = actionsFactory({
endpoints,
hasPagination: true,
});
let requestedUrl;
mock.onGet().reply(config => {
requestedUrl = config.url;
return [200, payload];
});
await testAction(
fetchReposWithPagination,
null,
localState,
expect.any(Array),
expect.any(Array),
);
expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localState.pageInfo.page}`);
}); });
});
describe('when filtered', () => {
it('fetches repos with filter applied', () => { it('fetches repos with filter applied', () => {
mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload); mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload);
return testAction( return testAction(
fetchRepos, fetchRepos,
null, null,
localState, { ...localState, filter: 'filter' },
[ [
{ type: REQUEST_REPOS }, { type: REQUEST_REPOS },
{ {
...@@ -323,5 +347,21 @@ describe('import_projects store actions', () => { ...@@ -323,5 +347,21 @@ describe('import_projects store actions', () => {
], ],
); );
}); });
describe('setPage', () => {
it('dispatches fetchRepos and commits setPage when page number differs from current one', async () => {
await testAction(
setPage,
2,
{ ...localState, pageInfo: { page: 1 } },
[{ type: SET_PAGE, payload: 2 }],
[{ type: 'fetchRepos' }],
);
});
it('does not perform any action if page equals to current one', async () => {
await testAction(setPage, 2, { ...localState, pageInfo: { page: 2 } }, [], []);
});
});
}); });
}); });
...@@ -279,4 +279,25 @@ describe('import_projects store mutations', () => { ...@@ -279,4 +279,25 @@ describe('import_projects store mutations', () => {
expect(state.customImportTargets[SOURCE_PROJECT.id]).toBeUndefined(); expect(state.customImportTargets[SOURCE_PROJECT.id]).toBeUndefined();
}); });
}); });
describe(`${types.SET_PAGE_INFO}`, () => {
it('sets passed page info', () => {
state = {};
const pageInfo = { page: 1, total: 10 };
mutations[types.SET_PAGE_INFO](state, pageInfo);
expect(state.pageInfo).toBe(pageInfo);
});
});
describe(`${types.SET_PAGE}`, () => {
it('sets page number', () => {
const NEW_PAGE = 4;
state = { pageInfo: { page: 5 } };
mutations[types.SET_PAGE](state, NEW_PAGE);
expect(state.pageInfo.page).toBe(NEW_PAGE);
});
});
}); });
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