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>
import { throttle } from 'lodash';
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 PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import ProviderRepoTableRow from './provider_repo_table_row.vue';
import PageQueryParamSync from './page_query_param_sync.vue';
const reposFetchThrottleDelay = 1000;
export default {
name: 'ImportProjectsTable',
components: {
ProviderRepoTableRow,
PageQueryParamSync,
GlLoadingIcon,
GlButton,
GlModal,
PaginationLinks,
GlIntersectionObserver,
},
props: {
providerTitle: {
......@@ -37,7 +31,7 @@ export default {
},
computed: {
...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace', 'pageInfo']),
...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace']),
...mapGetters([
'isLoading',
'isImportingAnyRepo',
......@@ -46,6 +40,10 @@ export default {
'importAllCount',
]),
pagePaginationStateKey() {
return `${this.filter}-${this.repositories.length}`;
},
availableNamespaces() {
const serializedNamespaces = this.namespaces.map(({ fullPath }) => ({
id: fullPath,
......@@ -84,7 +82,11 @@ export default {
mounted() {
this.fetchNamespaces();
this.fetchJobs();
if (!this.paginatable) {
this.fetchRepos();
}
},
beforeDestroy() {
......@@ -95,40 +97,25 @@ export default {
methods: {
...mapActions([
'fetchRepos',
'fetchJobs',
'fetchNamespaces',
'stopJobsPolling',
'clearJobsEtagPoll',
'setFilter',
'importAll',
'setPage',
]),
handleFilterInput({ target }) {
this.setFilter(target.value);
},
throttledFetchRepos: throttle(function fetch() {
this.fetchRepos();
}, reposFetchThrottleDelay),
},
};
</script>
<template>
<div>
<page-query-param-sync :page="pageInfo.page" @popstate="setPage" />
<p class="light text-nowrap mt-2">
{{ s__('ImportProjects|Select the repositories you want to import') }}
</p>
<template v-if="hasIncompatibleRepos">
<slot name="incompatible-repos-warning"></slot>
</template>
<gl-loading-icon
v-if="isLoading"
class="js-loading-button-icon import-projects-loading-icon"
size="md"
/>
<template v-if="!isLoading">
<div class="d-flex justify-content-between align-items-end flex-wrap mb-3">
<gl-button
variant="success"
......@@ -157,15 +144,13 @@ export default {
<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 repositories by name')"
autofocus
size="40"
@input="handleFilterInput($event)"
@keyup.enter="throttledFetchRepos"
@keyup.enter="setFilter($event.target.value)"
/>
</form>
</div>
......@@ -188,18 +173,19 @@ export default {
</tbody>
</table>
</div>
<div v-else class="text-center">
<strong>{{ emptyStateText }}</strong>
</div>
<pagination-links
<gl-intersection-observer
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"
:key="pagePaginationStateKey"
@appear="fetchRepos"
/>
</template>
<gl-loading-icon
v-if="isLoading"
class="js-loading-button-icon import-projects-loading-icon"
size="md"
/>
<div v-if="!isLoading && repositories.length === 0" class="text-center">
<strong>{{ emptyStateText }}</strong>
</div>
</div>
</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 * as types from './mutation_types';
import { isProjectImportable } from '../utils';
import {
convertObjectPropsToCamelCase,
normalizeHeaders,
parseIntPagination,
} from '~/lib/utils/common_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
import { visitUrl, objectToQuery } from '~/lib/utils/url_utility';
import { deprecatedCreateFlash as createFlash } from '~/flash';
......@@ -54,12 +50,9 @@ const importAll = ({ state, dispatch }) => {
);
};
const fetchReposFactory = ({ reposPath = isRequired(), hasPagination }) => ({
state,
dispatch,
commit,
}) => {
dispatch('stopJobsPolling');
const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) => {
const nextPage = state.pageInfo.page + 1;
commit(types.SET_PAGE, nextPage);
commit(types.REQUEST_REPOS);
const { provider, filter } = state;
......@@ -68,21 +61,16 @@ const fetchReposFactory = ({ reposPath = isRequired(), hasPagination }) => ({
.get(
pathWithParams({
path: reposPath,
filter,
page: hasPagination ? state.pageInfo.page.toString() : '',
filter: filter ?? '',
page: nextPage === 1 ? '' : nextPage.toString(),
}),
)
.then(({ data, headers }) => {
const normalizedHeaders = normalizeHeaders(headers);
if ('X-PAGE' in normalizedHeaders) {
commit(types.SET_PAGE_INFO, parseIntPagination(normalizedHeaders));
}
.then(({ data }) => {
commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true }));
})
.then(() => dispatch('fetchJobs'))
.catch(e => {
commit(types.SET_PAGE, nextPage - 1);
if (hasRedirectInError(e)) {
redirectToUrlInError(e);
} else {
......@@ -136,8 +124,6 @@ const fetchImportFactory = (importPath = isRequired()) => ({ state, commit, gett
};
export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, dispatch }) => {
const { filter } = state;
if (eTagPoll) {
stopJobsPolling();
clearJobsEtagPoll();
......@@ -145,7 +131,7 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d
eTagPoll = new Poll({
resource: {
fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, filter })),
fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, filter: state.filter })),
},
method: 'fetchJobs',
successCallback: ({ data }) =>
......@@ -157,7 +143,6 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d
createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed'));
}
},
data: { filter },
});
if (!Visibility.hidden()) {
......@@ -196,7 +181,7 @@ const setPage = ({ state, commit, dispatch }, page) => {
return dispatch('fetchRepos');
};
export default ({ endpoints = isRequired(), hasPagination }) => ({
export default ({ endpoints = isRequired() }) => ({
clearJobsEtagPoll,
stopJobsPolling,
restartJobsPolling,
......@@ -204,7 +189,7 @@ export default ({ endpoints = isRequired(), hasPagination }) => ({
setImportTarget,
importAll,
setPage,
fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath, hasPagination }),
fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath }),
fetchImport: fetchImportFactory(endpoints.importPath),
fetchJobs: fetchJobsFactory(endpoints.jobsPath),
fetchNamespaces: fetchNamespacesFactory(endpoints.namespacesPath),
......
......@@ -2,9 +2,41 @@ import Vue from 'vue';
import * as types from './mutation_types';
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 {
[types.SET_FILTER](state, filter) {
state.filter = filter;
state.repositories = [];
state.pageInfo.page = 0;
},
[types.REQUEST_REPOS](state) {
......@@ -17,30 +49,41 @@ export default {
if (!Array.isArray(repositories)) {
// 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
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 = [
...repositories.importedProjects.map(importedProject => ({
importSource: {
id: importedProject.id,
fullName: importedProject.importSource,
sanitizedName: importedProject.name,
providerLink: importedProject.providerLink,
},
importedProject,
})),
...newImportedProjects,
...state.repositories,
...repositories.providerRepos.map(project => ({
importSource: project,
importedProject: null,
})),
...(repositories.incompatibleRepos ?? []).map(project => ({
importSource: { ...project, incompatible: true },
importedProject: null,
})),
...newIncompatibleProjects,
];
if (incompatibleRepos.length === 0 && repositories.providerRepos.length === 0) {
state.pageInfo.page -= 1;
}
return;
}
state.repositories = repositories;
state.repositories = [...state.repositories, ...repositories];
if (repositories.length === 0) {
state.pageInfo.page -= 1;
}
},
[types.RECEIVE_REPOS_ERROR](state) {
......@@ -100,10 +143,6 @@ export default {
}
},
[types.SET_PAGE_INFO](state, pageInfo) {
state.pageInfo = pageInfo;
},
[types.SET_PAGE](state, page) {
state.pageInfo.page = page;
},
......
......@@ -8,6 +8,6 @@ export default () => ({
ciCdOnly: false,
filter: '',
pageInfo: {
page: 1,
page: 0,
},
});
---
title: Introduce infinite scrolling to importers
merge_request: 41789
author:
type: changed
import { nextTick } from 'vue';
import Vuex from 'vuex';
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 * as getters from '~/import_projects/store/getters';
import { STATUSES } from '~/import_projects/constants';
import ImportProjectsTable from '~/import_projects/components/import_projects_table.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', () => {
let wrapper;
......@@ -35,6 +34,7 @@ describe('ImportProjectsTable', () => {
const importAllFn = jest.fn();
const importAllModalShowFn = jest.fn();
const setPageFn = jest.fn();
const fetchReposFn = jest.fn();
function createComponent({
state: initialState,
......@@ -53,7 +53,7 @@ describe('ImportProjectsTable', () => {
...customGetters,
},
actions: {
fetchRepos: jest.fn(),
fetchRepos: fetchReposFn,
fetchJobs: jest.fn(),
fetchNamespaces: jest.fn(),
importAll: importAllFn,
......@@ -203,19 +203,27 @@ describe('ImportProjectsTable', () => {
});
});
it('passes current page to page-query-param-sync component', () => {
expect(wrapper.find(PageQueryParamSync).props().page).toBe(pageInfo.page);
it('does not call fetchRepos on mount', () => {
expect(fetchReposFn).not.toHaveBeenCalled();
});
it('dispatches setPage when page-query-param-sync emits popstate', () => {
const NEW_PAGE = 2;
wrapper.find(PageQueryParamSync).vm.$emit('popstate', NEW_PAGE);
it('renders intersection observer component', () => {
expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true);
});
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(calls[0][1]).toBe(NEW_PAGE);
expect(fetchReposFn).toHaveBeenCalled();
});
});
it('calls fetchRepos on mount', () => {
createComponent();
expect(fetchReposFn).toHaveBeenCalled();
});
it.each`
......
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', () => {
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);
return testAction(
......@@ -91,54 +91,65 @@ describe('import_projects store actions', () => {
null,
localState,
[
{ type: SET_PAGE, payload: 1 },
{ type: REQUEST_REPOS },
{
type: RECEIVE_REPOS_SUCCESS,
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);
return testAction(
fetchRepos,
null,
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 () => {
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),
);
const localStateWithPage = { ...localState, pageInfo: { page: 2 } };
expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localState.pageInfo.page}`);
await testAction(fetchRepos, null, localStateWithPage, expect.any(Array), expect.any(Array));
expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`);
});
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 filtered', () => {
describe('when /home/xanf/projects/gdk/gitlab/spec/frontend/import_projects/store/actions_spec.jsfiltered', () => {
it('fetches repos with filter applied', () => {
mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload);
......@@ -147,13 +158,14 @@ describe('import_projects store actions', () => {
null,
{ ...localState, filter: 'filter' },
[
{ type: SET_PAGE, payload: 1 },
{ type: REQUEST_REPOS },
{
type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
},
],
[{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }],
[],
);
});
});
......
import * as types from '~/import_projects/store/mutation_types';
import mutations from '~/import_projects/store/mutations';
import getInitialState from '~/import_projects/store/state';
import { STATUSES } from '~/import_projects/constants';
describe('import_projects store mutations', () => {
let state;
const SOURCE_PROJECT = {
id: 1,
full_name: 'full/name',
......@@ -19,13 +21,23 @@ describe('import_projects store mutations', () => {
};
describe(`${types.SET_FILTER}`, () => {
it('overwrites current filter 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);
});
it('removes current repositories list', () => {
expect(state.repositories.length).toBe(0);
});
expect(state.filter).toBe(NEW_VALUE);
it('resets current page to 0', () => {
expect(state.pageInfo.page).toBe(0);
});
});
......@@ -48,7 +60,7 @@ describe('import_projects store mutations', () => {
};
it('recreates importSource from response', () => {
state = {};
state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
......@@ -62,7 +74,7 @@ describe('import_projects store mutations', () => {
});
it('passes project to importProject', () => {
state = {};
state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
......@@ -74,7 +86,8 @@ describe('import_projects store mutations', () => {
describe('for importable projects', () => {
beforeEach(() => {
state = {};
state = getInitialState();
const response = {
importedProjects: [],
providerRepos: [SOURCE_PROJECT],
......@@ -95,7 +108,7 @@ describe('import_projects store mutations', () => {
};
beforeEach(() => {
state = {};
state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
});
......@@ -115,7 +128,8 @@ describe('import_projects store mutations', () => {
importedProjects: [],
providerRepos: [],
};
state = {};
state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
......@@ -125,16 +139,18 @@ describe('import_projects store mutations', () => {
it('passes response as it is', () => {
const response = [];
state = {};
state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
expect(state.repositories).toBe(response);
expect(state.repositories).toStrictEqual(response);
});
it('sets repos loading flag to false', () => {
const response = [];
state = getInitialState();
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
expect(state.isLoadingRepos).toBe(false);
......@@ -143,7 +159,7 @@ describe('import_projects store mutations', () => {
describe(`${types.RECEIVE_REPOS_ERROR}`, () => {
it('sets repos loading flag to false', () => {
state = {};
state = getInitialState();
mutations[types.RECEIVE_REPOS_ERROR](state);
......@@ -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}`, () => {
it('sets page number', () => {
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