Commit a6e3e394 authored by Illya Klymov's avatar Illya Klymov Committed by Miguel Rincon

Introduce infinite scroll pagination to importers

* Remove existing undeterminate pagination
* Use intersection observer for loading more pages
parent 38ef3273
<script> <script>
import { throttle } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { GlButton, GlLoadingIcon, GlModal } from '@gitlab/ui'; import { GlButton, GlLoadingIcon, GlIntersectionObserver, GlModal } from '@gitlab/ui';
import { n__, __, sprintf } from '~/locale'; import { n__, __, sprintf } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import ProviderRepoTableRow from './provider_repo_table_row.vue'; import ProviderRepoTableRow from './provider_repo_table_row.vue';
import PageQueryParamSync from './page_query_param_sync.vue';
const reposFetchThrottleDelay = 1000;
export default { export default {
name: 'ImportProjectsTable', name: 'ImportProjectsTable',
components: { components: {
ProviderRepoTableRow, ProviderRepoTableRow,
PageQueryParamSync,
GlLoadingIcon, GlLoadingIcon,
GlButton, GlButton,
GlModal, GlModal,
PaginationLinks, GlIntersectionObserver,
}, },
props: { props: {
providerTitle: { providerTitle: {
...@@ -37,7 +31,7 @@ export default { ...@@ -37,7 +31,7 @@ export default {
}, },
computed: { computed: {
...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace', 'pageInfo']), ...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace']),
...mapGetters([ ...mapGetters([
'isLoading', 'isLoading',
'isImportingAnyRepo', 'isImportingAnyRepo',
...@@ -46,6 +40,10 @@ export default { ...@@ -46,6 +40,10 @@ export default {
'importAllCount', 'importAllCount',
]), ]),
pagePaginationStateKey() {
return `${this.filter}-${this.repositories.length}`;
},
availableNamespaces() { availableNamespaces() {
const serializedNamespaces = this.namespaces.map(({ fullPath }) => ({ const serializedNamespaces = this.namespaces.map(({ fullPath }) => ({
id: fullPath, id: fullPath,
...@@ -84,7 +82,11 @@ export default { ...@@ -84,7 +82,11 @@ export default {
mounted() { mounted() {
this.fetchNamespaces(); this.fetchNamespaces();
this.fetchRepos(); this.fetchJobs();
if (!this.paginatable) {
this.fetchRepos();
}
}, },
beforeDestroy() { beforeDestroy() {
...@@ -95,111 +97,95 @@ export default { ...@@ -95,111 +97,95 @@ export default {
methods: { methods: {
...mapActions([ ...mapActions([
'fetchRepos', 'fetchRepos',
'fetchJobs',
'fetchNamespaces', 'fetchNamespaces',
'stopJobsPolling', 'stopJobsPolling',
'clearJobsEtagPoll', 'clearJobsEtagPoll',
'setFilter', 'setFilter',
'importAll', 'importAll',
'setPage',
]), ]),
handleFilterInput({ target }) {
this.setFilter(target.value);
},
throttledFetchRepos: throttle(function fetch() {
this.fetchRepos();
}, reposFetchThrottleDelay),
}, },
}; };
</script> </script>
<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 repositories you want to import') }} {{ s__('ImportProjects|Select the repositories 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 class="d-flex justify-content-between align-items-end flex-wrap mb-3">
<gl-button
variant="success"
:loading="isImportingAnyRepo"
:disabled="!hasImportableRepos"
type="button"
@click="$refs.importAllModal.show()"
>{{ importAllButtonText }}</gl-button
>
<gl-modal
ref="importAllModal"
modal-id="import-all-modal"
:title="s__('ImportProjects|Import repositories')"
:ok-title="__('Import')"
@ok="importAll"
>
{{
n__(
'Are you sure you want to import %d repository?',
'Are you sure you want to import %d repositories?',
importAllCount,
)
}}
</gl-modal>
<slot name="actions"></slot>
<form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent>
<input
data-qa-selector="githubish_import_filter_field"
class="form-control"
name="filter"
:placeholder="__('Filter your repositories by name')"
autofocus
size="40"
@keyup.enter="setFilter($event.target.value)"
/>
</form>
</div>
<div v-if="repositories.length" class="table-responsive">
<table class="table import-table">
<thead>
<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">
<provider-repo-table-row
:key="repo.importSource.providerLink"
:repo="repo"
:available-namespaces="availableNamespaces"
/>
</template>
</tbody>
</table>
</div>
<gl-intersection-observer
v-if="paginatable"
:key="pagePaginationStateKey"
@appear="fetchRepos"
/>
<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"
/> />
<template v-if="!isLoading">
<div class="d-flex justify-content-between align-items-end flex-wrap mb-3">
<gl-button
variant="success"
:loading="isImportingAnyRepo"
:disabled="!hasImportableRepos"
type="button"
@click="$refs.importAllModal.show()"
>{{ importAllButtonText }}</gl-button
>
<gl-modal
ref="importAllModal"
modal-id="import-all-modal"
:title="s__('ImportProjects|Import repositories')"
:ok-title="__('Import')"
@ok="importAll"
>
{{
n__(
'Are you sure you want to import %d repository?',
'Are you sure you want to import %d repositories?',
importAllCount,
)
}}
</gl-modal>
<slot name="actions"></slot> <div v-if="!isLoading && repositories.length === 0" class="text-center">
<form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent> <strong>{{ emptyStateText }}</strong>
<input </div>
:value="filter"
data-qa-selector="githubish_import_filter_field"
class="form-control"
name="filter"
:placeholder="__('Filter your repositories by name')"
autofocus
size="40"
@input="handleFilterInput($event)"
@keyup.enter="throttledFetchRepos"
/>
</form>
</div>
<div v-if="repositories.length" class="table-responsive">
<table class="table import-table">
<thead>
<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">
<provider-repo-table-row
:key="repo.importSource.providerLink"
:repo="repo"
:available-namespaces="availableNamespaces"
/>
</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>
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 { import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
convertObjectPropsToCamelCase,
normalizeHeaders,
parseIntPagination,
} from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll'; import Poll from '~/lib/utils/poll';
import { visitUrl, objectToQuery } from '~/lib/utils/url_utility'; import { visitUrl, objectToQuery } from '~/lib/utils/url_utility';
import { deprecatedCreateFlash as createFlash } from '~/flash'; import { deprecatedCreateFlash as createFlash } from '~/flash';
...@@ -54,12 +50,9 @@ const importAll = ({ state, dispatch }) => { ...@@ -54,12 +50,9 @@ const importAll = ({ state, dispatch }) => {
); );
}; };
const fetchReposFactory = ({ reposPath = isRequired(), hasPagination }) => ({ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) => {
state, const nextPage = state.pageInfo.page + 1;
dispatch, commit(types.SET_PAGE, nextPage);
commit,
}) => {
dispatch('stopJobsPolling');
commit(types.REQUEST_REPOS); commit(types.REQUEST_REPOS);
const { provider, filter } = state; const { provider, filter } = state;
...@@ -68,21 +61,16 @@ const fetchReposFactory = ({ reposPath = isRequired(), hasPagination }) => ({ ...@@ -68,21 +61,16 @@ const fetchReposFactory = ({ reposPath = isRequired(), hasPagination }) => ({
.get( .get(
pathWithParams({ pathWithParams({
path: reposPath, path: reposPath,
filter, filter: filter ?? '',
page: hasPagination ? state.pageInfo.page.toString() : '', page: nextPage === 1 ? '' : nextPage.toString(),
}), }),
) )
.then(({ data, headers }) => { .then(({ data }) => {
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 })); commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true }));
}) })
.then(() => dispatch('fetchJobs'))
.catch(e => { .catch(e => {
commit(types.SET_PAGE, nextPage - 1);
if (hasRedirectInError(e)) { if (hasRedirectInError(e)) {
redirectToUrlInError(e); redirectToUrlInError(e);
} else { } else {
...@@ -136,8 +124,6 @@ const fetchImportFactory = (importPath = isRequired()) => ({ state, commit, gett ...@@ -136,8 +124,6 @@ const fetchImportFactory = (importPath = isRequired()) => ({ state, commit, gett
}; };
export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, dispatch }) => { export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, dispatch }) => {
const { filter } = state;
if (eTagPoll) { if (eTagPoll) {
stopJobsPolling(); stopJobsPolling();
clearJobsEtagPoll(); clearJobsEtagPoll();
...@@ -145,7 +131,7 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d ...@@ -145,7 +131,7 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d
eTagPoll = new Poll({ eTagPoll = new Poll({
resource: { resource: {
fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, filter })), fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, filter: state.filter })),
}, },
method: 'fetchJobs', method: 'fetchJobs',
successCallback: ({ data }) => successCallback: ({ data }) =>
...@@ -157,7 +143,6 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d ...@@ -157,7 +143,6 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d
createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed')); createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed'));
} }
}, },
data: { filter },
}); });
if (!Visibility.hidden()) { if (!Visibility.hidden()) {
...@@ -196,7 +181,7 @@ const setPage = ({ state, commit, dispatch }, page) => { ...@@ -196,7 +181,7 @@ const setPage = ({ state, commit, dispatch }, page) => {
return dispatch('fetchRepos'); return dispatch('fetchRepos');
}; };
export default ({ endpoints = isRequired(), hasPagination }) => ({ export default ({ endpoints = isRequired() }) => ({
clearJobsEtagPoll, clearJobsEtagPoll,
stopJobsPolling, stopJobsPolling,
restartJobsPolling, restartJobsPolling,
...@@ -204,7 +189,7 @@ export default ({ endpoints = isRequired(), hasPagination }) => ({ ...@@ -204,7 +189,7 @@ export default ({ endpoints = isRequired(), hasPagination }) => ({
setImportTarget, setImportTarget,
importAll, importAll,
setPage, setPage,
fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath, hasPagination }), fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath }),
fetchImport: fetchImportFactory(endpoints.importPath), fetchImport: fetchImportFactory(endpoints.importPath),
fetchJobs: fetchJobsFactory(endpoints.jobsPath), fetchJobs: fetchJobsFactory(endpoints.jobsPath),
fetchNamespaces: fetchNamespacesFactory(endpoints.namespacesPath), fetchNamespaces: fetchNamespacesFactory(endpoints.namespacesPath),
......
...@@ -2,9 +2,41 @@ import Vue from 'vue'; ...@@ -2,9 +2,41 @@ import Vue from 'vue';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { STATUSES } from '../constants'; import { STATUSES } from '../constants';
const makeNewImportedProject = importedProject => ({
importSource: {
id: importedProject.id,
fullName: importedProject.importSource,
sanitizedName: importedProject.name,
providerLink: importedProject.providerLink,
},
importedProject,
});
const makeNewIncompatibleProject = project => ({
importSource: { ...project, incompatible: true },
importedProject: null,
});
const processLegacyEntries = ({ newRepositories, existingRepositories, factory }) => {
const newEntries = [];
newRepositories.forEach(project => {
const existingProject = existingRepositories.find(p => p.importSource.id === project.id);
const importedProjectShape = factory(project);
if (existingProject) {
Object.assign(existingProject, importedProjectShape);
} else {
newEntries.push(importedProjectShape);
}
});
return newEntries;
};
export default { export default {
[types.SET_FILTER](state, filter) { [types.SET_FILTER](state, filter) {
state.filter = filter; state.filter = filter;
state.repositories = [];
state.pageInfo.page = 0;
}, },
[types.REQUEST_REPOS](state) { [types.REQUEST_REPOS](state) {
...@@ -17,30 +49,41 @@ export default { ...@@ -17,30 +49,41 @@ export default {
if (!Array.isArray(repositories)) { if (!Array.isArray(repositories)) {
// Legacy code path, will be removed when all importers will be switched to new pagination format // Legacy code path, will be removed when all importers will be switched to new pagination format
// https://gitlab.com/gitlab-org/gitlab/-/issues/27370#note_379034091 // https://gitlab.com/gitlab-org/gitlab/-/issues/27370#note_379034091
const newImportedProjects = processLegacyEntries({
newRepositories: repositories.importedProjects,
existingRepositories: state.repositories,
factory: makeNewImportedProject,
});
const incompatibleRepos = repositories.incompatibleRepos ?? [];
const newIncompatibleProjects = processLegacyEntries({
newRepositories: incompatibleRepos,
existingRepositories: state.repositories,
factory: makeNewIncompatibleProject,
});
state.repositories = [ state.repositories = [
...repositories.importedProjects.map(importedProject => ({ ...newImportedProjects,
importSource: { ...state.repositories,
id: importedProject.id,
fullName: importedProject.importSource,
sanitizedName: importedProject.name,
providerLink: importedProject.providerLink,
},
importedProject,
})),
...repositories.providerRepos.map(project => ({ ...repositories.providerRepos.map(project => ({
importSource: project, importSource: project,
importedProject: null, importedProject: null,
})), })),
...(repositories.incompatibleRepos ?? []).map(project => ({ ...newIncompatibleProjects,
importSource: { ...project, incompatible: true },
importedProject: null,
})),
]; ];
if (incompatibleRepos.length === 0 && repositories.providerRepos.length === 0) {
state.pageInfo.page -= 1;
}
return; return;
} }
state.repositories = repositories; state.repositories = [...state.repositories, ...repositories];
if (repositories.length === 0) {
state.pageInfo.page -= 1;
}
}, },
[types.RECEIVE_REPOS_ERROR](state) { [types.RECEIVE_REPOS_ERROR](state) {
...@@ -100,10 +143,6 @@ export default { ...@@ -100,10 +143,6 @@ export default {
} }
}, },
[types.SET_PAGE_INFO](state, pageInfo) {
state.pageInfo = pageInfo;
},
[types.SET_PAGE](state, page) { [types.SET_PAGE](state, page) {
state.pageInfo.page = page; state.pageInfo.page = page;
}, },
......
...@@ -8,6 +8,6 @@ export default () => ({ ...@@ -8,6 +8,6 @@ export default () => ({
ciCdOnly: false, ciCdOnly: false,
filter: '', filter: '',
pageInfo: { pageInfo: {
page: 1, page: 0,
}, },
}); });
---
title: Introduce infinite scrolling to importers
merge_request: 41789
author:
type: changed
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlLoadingIcon, GlButton } from '@gitlab/ui'; import { GlLoadingIcon, GlButton, GlIntersectionObserver } from '@gitlab/ui';
import state from '~/import_projects/store/state'; import state from '~/import_projects/store/state';
import * as getters from '~/import_projects/store/getters'; import * as getters from '~/import_projects/store/getters';
import { STATUSES } from '~/import_projects/constants'; import { STATUSES } from '~/import_projects/constants';
import ImportProjectsTable from '~/import_projects/components/import_projects_table.vue'; import ImportProjectsTable from '~/import_projects/components/import_projects_table.vue';
import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue';
import PageQueryParamSync from '~/import_projects/components/page_query_param_sync.vue';
describe('ImportProjectsTable', () => { describe('ImportProjectsTable', () => {
let wrapper; let wrapper;
...@@ -35,6 +34,7 @@ describe('ImportProjectsTable', () => { ...@@ -35,6 +34,7 @@ describe('ImportProjectsTable', () => {
const importAllFn = jest.fn(); const importAllFn = jest.fn();
const importAllModalShowFn = jest.fn(); const importAllModalShowFn = jest.fn();
const setPageFn = jest.fn(); const setPageFn = jest.fn();
const fetchReposFn = jest.fn();
function createComponent({ function createComponent({
state: initialState, state: initialState,
...@@ -53,7 +53,7 @@ describe('ImportProjectsTable', () => { ...@@ -53,7 +53,7 @@ describe('ImportProjectsTable', () => {
...customGetters, ...customGetters,
}, },
actions: { actions: {
fetchRepos: jest.fn(), fetchRepos: fetchReposFn,
fetchJobs: jest.fn(), fetchJobs: jest.fn(),
fetchNamespaces: jest.fn(), fetchNamespaces: jest.fn(),
importAll: importAllFn, importAll: importAllFn,
...@@ -203,21 +203,29 @@ describe('ImportProjectsTable', () => { ...@@ -203,21 +203,29 @@ describe('ImportProjectsTable', () => {
}); });
}); });
it('passes current page to page-query-param-sync component', () => { it('does not call fetchRepos on mount', () => {
expect(wrapper.find(PageQueryParamSync).props().page).toBe(pageInfo.page); expect(fetchReposFn).not.toHaveBeenCalled();
}); });
it('dispatches setPage when page-query-param-sync emits popstate', () => { it('renders intersection observer component', () => {
const NEW_PAGE = 2; expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true);
wrapper.find(PageQueryParamSync).vm.$emit('popstate', NEW_PAGE); });
it('calls fetchRepos when intersection observer appears', async () => {
wrapper.find(GlIntersectionObserver).vm.$emit('appear');
const { calls } = setPageFn.mock; await nextTick();
expect(calls).toHaveLength(1); expect(fetchReposFn).toHaveBeenCalled();
expect(calls[0][1]).toBe(NEW_PAGE);
}); });
}); });
it('calls fetchRepos on mount', () => {
createComponent();
expect(fetchReposFn).toHaveBeenCalled();
});
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]);
});
});
...@@ -83,7 +83,7 @@ describe('import_projects store actions', () => { ...@@ -83,7 +83,7 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore()); afterEach(() => mock.restore());
it('dispatches stopJobsPolling actions and commits REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => { it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => {
mock.onGet(MOCK_ENDPOINT).reply(200, payload); mock.onGet(MOCK_ENDPOINT).reply(200, payload);
return testAction( return testAction(
...@@ -91,54 +91,65 @@ describe('import_projects store actions', () => { ...@@ -91,54 +91,65 @@ describe('import_projects store actions', () => {
null, null,
localState, localState,
[ [
{ type: SET_PAGE, payload: 1 },
{ type: REQUEST_REPOS }, { type: REQUEST_REPOS },
{ {
type: RECEIVE_REPOS_SUCCESS, type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }), payload: convertObjectPropsToCamelCase(payload, { deep: true }),
}, },
], ],
[{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }], [],
); );
}); });
it('dispatches stopJobsPolling action and commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => { it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_ERROR and SET_PAGE again mutations on an unsuccessful request', () => {
mock.onGet(MOCK_ENDPOINT).reply(500); mock.onGet(MOCK_ENDPOINT).reply(500);
return testAction( return testAction(
fetchRepos, fetchRepos,
null, null,
localState, localState,
[{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }], [
[{ type: 'stopJobsPolling' }], { type: SET_PAGE, payload: 1 },
{ type: REQUEST_REPOS },
{ type: SET_PAGE, payload: 0 },
{ type: RECEIVE_REPOS_ERROR },
],
[],
); );
}); });
describe('when pagination is enabled', () => { it('includes page in url query params', async () => {
it('includes page in url query params', async () => { let requestedUrl;
const { fetchRepos: fetchReposWithPagination } = actionsFactory({ mock.onGet().reply(config => {
endpoints, requestedUrl = config.url;
hasPagination: true, return [200, payload];
}); });
let requestedUrl; const localStateWithPage = { ...localState, pageInfo: { page: 2 } };
mock.onGet().reply(config => {
requestedUrl = config.url;
return [200, payload];
});
await testAction( await testAction(fetchRepos, null, localStateWithPage, expect.any(Array), expect.any(Array));
fetchReposWithPagination,
null,
localState,
expect.any(Array),
expect.any(Array),
);
expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localState.pageInfo.page}`); expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`);
});
}); });
describe('when filtered', () => { it('correctly updates current page on an unsuccessful request', () => {
mock.onGet(MOCK_ENDPOINT).reply(500);
const CURRENT_PAGE = 5;
return testAction(
fetchRepos,
null,
{ ...localState, pageInfo: { page: CURRENT_PAGE } },
expect.arrayContaining([
{ type: SET_PAGE, payload: CURRENT_PAGE + 1 },
{ type: SET_PAGE, payload: CURRENT_PAGE },
]),
[],
);
});
describe('when /home/xanf/projects/gdk/gitlab/spec/frontend/import_projects/store/actions_spec.jsfiltered', () => {
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);
...@@ -147,13 +158,14 @@ describe('import_projects store actions', () => { ...@@ -147,13 +158,14 @@ describe('import_projects store actions', () => {
null, null,
{ ...localState, filter: 'filter' }, { ...localState, filter: 'filter' },
[ [
{ type: SET_PAGE, payload: 1 },
{ type: REQUEST_REPOS }, { type: REQUEST_REPOS },
{ {
type: RECEIVE_REPOS_SUCCESS, type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }), payload: convertObjectPropsToCamelCase(payload, { deep: true }),
}, },
], ],
[{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }], [],
); );
}); });
}); });
......
import * as types from '~/import_projects/store/mutation_types'; import * as types from '~/import_projects/store/mutation_types';
import mutations from '~/import_projects/store/mutations'; import mutations from '~/import_projects/store/mutations';
import getInitialState from '~/import_projects/store/state';
import { STATUSES } from '~/import_projects/constants'; import { STATUSES } from '~/import_projects/constants';
describe('import_projects store mutations', () => { describe('import_projects store mutations', () => {
let state; let state;
const SOURCE_PROJECT = { const SOURCE_PROJECT = {
id: 1, id: 1,
full_name: 'full/name', full_name: 'full/name',
...@@ -19,13 +21,23 @@ describe('import_projects store mutations', () => { ...@@ -19,13 +21,23 @@ describe('import_projects store mutations', () => {
}; };
describe(`${types.SET_FILTER}`, () => { describe(`${types.SET_FILTER}`, () => {
it('overwrites current filter value', () => { const NEW_VALUE = 'new-value';
state = { filter: 'some-value' };
const NEW_VALUE = 'new-value';
beforeEach(() => {
state = {
filter: 'some-value',
repositories: ['some', ' repositories'],
pageInfo: { page: 1 },
};
mutations[types.SET_FILTER](state, NEW_VALUE); mutations[types.SET_FILTER](state, NEW_VALUE);
});
expect(state.filter).toBe(NEW_VALUE); it('removes current repositories list', () => {
expect(state.repositories.length).toBe(0);
});
it('resets current page to 0', () => {
expect(state.pageInfo.page).toBe(0);
}); });
}); });
...@@ -48,7 +60,7 @@ describe('import_projects store mutations', () => { ...@@ -48,7 +60,7 @@ describe('import_projects store mutations', () => {
}; };
it('recreates importSource from response', () => { it('recreates importSource from response', () => {
state = {}; state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response); mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
...@@ -62,7 +74,7 @@ describe('import_projects store mutations', () => { ...@@ -62,7 +74,7 @@ describe('import_projects store mutations', () => {
}); });
it('passes project to importProject', () => { it('passes project to importProject', () => {
state = {}; state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response); mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
...@@ -74,7 +86,8 @@ describe('import_projects store mutations', () => { ...@@ -74,7 +86,8 @@ describe('import_projects store mutations', () => {
describe('for importable projects', () => { describe('for importable projects', () => {
beforeEach(() => { beforeEach(() => {
state = {}; state = getInitialState();
const response = { const response = {
importedProjects: [], importedProjects: [],
providerRepos: [SOURCE_PROJECT], providerRepos: [SOURCE_PROJECT],
...@@ -95,7 +108,7 @@ describe('import_projects store mutations', () => { ...@@ -95,7 +108,7 @@ describe('import_projects store mutations', () => {
}; };
beforeEach(() => { beforeEach(() => {
state = {}; state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response); mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
}); });
...@@ -115,7 +128,8 @@ describe('import_projects store mutations', () => { ...@@ -115,7 +128,8 @@ describe('import_projects store mutations', () => {
importedProjects: [], importedProjects: [],
providerRepos: [], providerRepos: [],
}; };
state = {};
state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response); mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
...@@ -125,16 +139,18 @@ describe('import_projects store mutations', () => { ...@@ -125,16 +139,18 @@ describe('import_projects store mutations', () => {
it('passes response as it is', () => { it('passes response as it is', () => {
const response = []; const response = [];
state = {}; state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response); mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
expect(state.repositories).toBe(response); expect(state.repositories).toStrictEqual(response);
}); });
it('sets repos loading flag to false', () => { it('sets repos loading flag to false', () => {
const response = []; const response = [];
state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response); mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
expect(state.isLoadingRepos).toBe(false); expect(state.isLoadingRepos).toBe(false);
...@@ -143,7 +159,7 @@ describe('import_projects store mutations', () => { ...@@ -143,7 +159,7 @@ describe('import_projects store mutations', () => {
describe(`${types.RECEIVE_REPOS_ERROR}`, () => { describe(`${types.RECEIVE_REPOS_ERROR}`, () => {
it('sets repos loading flag to false', () => { it('sets repos loading flag to false', () => {
state = {}; state = getInitialState();
mutations[types.RECEIVE_REPOS_ERROR](state); mutations[types.RECEIVE_REPOS_ERROR](state);
...@@ -291,17 +307,6 @@ describe('import_projects store mutations', () => { ...@@ -291,17 +307,6 @@ describe('import_projects store mutations', () => {
}); });
}); });
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}`, () => { describe(`${types.SET_PAGE}`, () => {
it('sets page number', () => { it('sets page number', () => {
const NEW_PAGE = 4; const NEW_PAGE = 4;
......
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