Commit 76ed471b authored by Illya Klymov's avatar Illya Klymov Committed by Nicolò Maria Mezzopera

Add pagination support to import_projects

* Support pagination with headers
* Support (legacy) pagination when headers are not available
parent b66f1d0d
......@@ -9,6 +9,7 @@ export default {
GlSprintf,
GlLink,
},
inheritAttrs: false,
props: {
providerTitle: {
type: String,
......@@ -28,7 +29,7 @@ export default {
};
</script>
<template>
<import-projects-table :provider-title="providerTitle">
<import-projects-table :provider-title="providerTitle" v-bind="$attrs">
<template #actions>
<slot name="actions"></slot>
</template>
......
......@@ -3,9 +3,11 @@ import { throttle } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import ImportedProjectTableRow from './imported_project_table_row.vue';
import ProviderRepoTableRow from './provider_repo_table_row.vue';
import IncompatibleRepoTableRow from './incompatible_repo_table_row.vue';
import PageQueryParamSync from './page_query_param_sync.vue';
import { isProjectImportable } from '../utils';
const reposFetchThrottleDelay = 1000;
......@@ -16,8 +18,10 @@ export default {
ImportedProjectTableRow,
ProviderRepoTableRow,
IncompatibleRepoTableRow,
PageQueryParamSync,
GlLoadingIcon,
GlButton,
PaginationLinks,
},
props: {
providerTitle: {
......@@ -29,10 +33,15 @@ export default {
required: false,
default: true,
},
paginatable: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace']),
...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace', 'pageInfo']),
...mapGetters([
'isLoading',
'isImportingAnyRepo',
......@@ -90,6 +99,7 @@ export default {
'clearJobsEtagPoll',
'setFilter',
'importAll',
'setPage',
]),
handleFilterInput({ target }) {
......@@ -107,69 +117,82 @@ export default {
<template>
<div>
<page-query-param-sync :page="pageInfo.page" @popstate="setPage" />
<p class="light text-nowrap mt-2">
{{ s__('ImportProjects|Select the projects you want to import') }}
</p>
<template v-if="hasIncompatibleRepos">
<slot name="incompatible-repos-warning"></slot>
</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
v-if="isLoading"
class="js-loading-button-icon import-projects-loading-icon"
size="md"
/>
<div v-else-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">
<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>
<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="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>
<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">
<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>
</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';
import Translate from '../vue_shared/translate';
import ImportProjectsTable from './components/import_projects_table.vue';
import { parseBoolean } from '../lib/utils/common_utils';
import { queryToObject } from '../lib/utils/url_utility';
import createStore from './store';
Vue.use(Translate);
......@@ -16,14 +17,21 @@ export function initStoreFromElement(element) {
jobsPath,
importPath,
namespacesPath,
paginatable,
} = element.dataset;
const params = queryToObject(document.location.search);
const page = parseInt(params.page ?? 1, 10);
return createStore({
initialState: {
defaultTargetNamespace: gon.current_username,
ciCdOnly: parseBoolean(ciCdOnly),
canSelectNamespace: parseBoolean(canSelectNamespace),
provider,
pageInfo: {
page,
},
},
endpoints: {
reposPath,
......@@ -31,6 +39,7 @@ export function initStoreFromElement(element) {
importPath,
namespacesPath,
},
hasPagination: parseBoolean(paginatable),
});
}
......@@ -38,6 +47,7 @@ export function initPropsFromElement(element) {
return {
providerTitle: element.dataset.providerTitle,
filterable: parseBoolean(element.dataset.filterable),
paginatable: parseBoolean(element.dataset.paginatable),
};
}
......
import Visibility from 'visibilityjs';
import * as types from './mutation_types';
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 { visitUrl } from '~/lib/utils/url_utility';
import { visitUrl, objectToQuery } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
......@@ -12,7 +16,13 @@ let eTagPoll;
const hasRedirectInError = e => 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 = () => {
// eslint-disable-next-line @gitlab/require-i18n-strings
......@@ -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');
commit(types.REQUEST_REPOS);
const { provider, filter } = state;
return axios
.get(pathWithFilter({ path: reposPath, filter }))
.then(({ data }) =>
commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })),
.get(
pathWithParams({
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'))
.catch(e => {
if (hasRedirectInError(e)) {
......@@ -85,12 +111,12 @@ const fetchImportFactory = (importPath = isRequired()) => ({ state, commit, gett
new_name: newName,
target_namespace: targetNamespace,
})
.then(({ data }) =>
.then(({ data }) => {
commit(types.RECEIVE_IMPORT_SUCCESS, {
importedProject: convertObjectPropsToCamelCase(data, { deep: true }),
repoId,
}),
)
});
})
.catch(e => {
const serverErrorMessage = e?.response?.data?.errors;
const flashMessage = serverErrorMessage
......@@ -119,7 +145,7 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d
eTagPoll = new Poll({
resource: {
fetchJobs: () => axios.get(pathWithFilter({ path: jobsPath, filter })),
fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, filter })),
},
method: 'fetchJobs',
successCallback: ({ data }) =>
......@@ -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,
stopJobsPolling,
restartJobsPolling,
setFilter,
setImportTarget,
importAll,
fetchRepos: fetchReposFactory(endpoints.reposPath),
setPage,
fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath, hasPagination }),
fetchImport: fetchImportFactory(endpoints.importPath),
fetchJobs: fetchJobsFactory(endpoints.jobsPath),
fetchNamespaces: fetchNamespacesFactory(endpoints.namespacesPath),
......
......@@ -7,10 +7,10 @@ import mutations from './mutations';
Vue.use(Vuex);
export default ({ initialState, endpoints }) =>
export default ({ initialState, endpoints, hasPagination }) =>
new Vuex.Store({
state: { ...state(), ...initialState },
actions: actionsFactory({ endpoints }),
actions: actionsFactory({ endpoints, hasPagination }),
mutations,
getters,
});
......@@ -15,3 +15,7 @@ export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS';
export const SET_FILTER = 'SET_FILTER';
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 {
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 () => ({
isLoadingNamespaces: false,
ciCdOnly: false,
filter: '',
pageInfo: {
page: 1,
},
});
......@@ -7,13 +7,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (!mountElement) return undefined;
const store = initStoreFromElement(mountElement);
const props = initPropsFromElement(mountElement);
const attrs = initPropsFromElement(mountElement);
return new Vue({
el: mountElement,
store,
render(createElement) {
return createElement(BitbucketStatusTable, { props });
return createElement(BitbucketStatusTable, { attrs });
},
});
});
......@@ -7,11 +7,8 @@ export default {
BitbucketStatusTable,
GlButton,
},
inheritAttrs: false,
props: {
providerTitle: {
type: String,
required: true,
},
reconfigurePath: {
type: String,
required: true,
......@@ -20,7 +17,7 @@ export default {
};
</script>
<template>
<bitbucket-status-table :provider-title="providerTitle">
<bitbucket-status-table v-bind="$attrs">
<template #actions>
<gl-button variant="info" class="gl-ml-3" data-method="post" :href="reconfigurePath">{{
__('Reconfigure')
......
......@@ -7,14 +7,16 @@ document.addEventListener('DOMContentLoaded', () => {
if (!mountElement) return undefined;
const store = initStoreFromElement(mountElement);
const props = initPropsFromElement(mountElement);
const attrs = initPropsFromElement(mountElement);
const { reconfigurePath } = mountElement.dataset;
return new Vue({
el: mountElement,
store,
render(createElement) {
return createElement(BitbucketServerStatusTable, { props: { ...props, reconfigurePath } });
return createElement(BitbucketServerStatusTable, {
attrs: { ...attrs, reconfigurePath },
});
},
});
});
- provider = local_assigns.fetch(:provider)
- extra_data = local_assigns.fetch(:extra_data, {})
- filterable = local_assigns.fetch(:filterable, true)
- paginatable = local_assigns.fetch(:paginatable, false)
- provider_title = Gitlab::ImportSources.title(provider)
#import-projects-mount-element{ data: { provider: provider, provider_title: provider_title,
......@@ -10,4 +11,5 @@
repos_path: url_for([:status, :import, provider, format: :json]),
jobs_path: url_for([:realtime_changes, :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 @@
%i.fa.fa-bitbucket-square
= _('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
import ImportedProjectTableRow from '~/import_projects/components/imported_project_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 PageQueryParamSync from '~/import_projects/components/page_query_param_sync.vue';
describe('ImportProjectsTable', () => {
let wrapper;
......@@ -26,11 +27,14 @@ describe('ImportProjectsTable', () => {
.at(0);
const importAllFn = jest.fn();
const setPageFn = jest.fn();
function createComponent({
state: initialState,
getters: customGetters,
slots,
filterable,
paginatable,
} = {}) {
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -49,6 +53,7 @@ describe('ImportProjectsTable', () => {
stopJobsPolling: jest.fn(),
clearJobsEtagPoll: jest.fn(),
setFilter: jest.fn(),
setPage: setPageFn,
},
});
......@@ -58,6 +63,7 @@ describe('ImportProjectsTable', () => {
propsData: {
providerTitle,
filterable,
paginatable,
},
slots,
});
......@@ -167,6 +173,37 @@ describe('ImportProjectsTable', () => {
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`
hasIncompatibleRepos | shouldRenderSlot | action
${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 {
REQUEST_NAMESPACES,
RECEIVE_NAMESPACES_SUCCESS,
RECEIVE_NAMESPACES_ERROR,
SET_PAGE,
} from '~/import_projects/store/mutation_types';
import actionsFactory from '~/import_projects/store/actions';
import { getImportTarget } from '~/import_projects/store/getters';
......@@ -24,6 +25,12 @@ import { STATUSES } from '~/import_projects/constants';
jest.mock('~/flash');
const MOCK_ENDPOINT = `${TEST_HOST}/endpoint.json`;
const endpoints = {
reposPath: MOCK_ENDPOINT,
importPath: MOCK_ENDPOINT,
jobsPath: MOCK_ENDPOINT,
namespacesPath: MOCK_ENDPOINT,
};
const {
clearJobsEtagPoll,
......@@ -33,13 +40,9 @@ const {
fetchImport,
fetchJobs,
fetchNamespaces,
setPage,
} = actionsFactory({
endpoints: {
reposPath: MOCK_ENDPOINT,
importPath: MOCK_ENDPOINT,
jobsPath: MOCK_ENDPOINT,
namespacesPath: MOCK_ENDPOINT,
},
endpoints,
});
describe('import_projects store actions', () => {
......@@ -110,18 +113,39 @@ describe('import_projects store actions', () => {
);
});
describe('when filtered', () => {
beforeEach(() => {
localState.filter = 'filter';
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),
);
expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localState.pageInfo.page}`);
});
});
describe('when filtered', () => {
it('fetches repos with filter applied', () => {
mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload);
return testAction(
fetchRepos,
null,
localState,
{ ...localState, filter: 'filter' },
[
{ type: REQUEST_REPOS },
{
......@@ -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', () => {
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