diff options
Diffstat (limited to 'app/assets/javascripts/import_projects')
15 files changed, 479 insertions, 246 deletions
diff --git a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue index f673a0e42dc..bc8aa522596 100644 --- a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue +++ b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue @@ -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> diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue index 6a467fb8c6a..72fdaca7e24 100644 --- a/app/assets/javascripts/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue @@ -3,10 +3,12 @@ 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 eventHub from '../event_hub'; +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,23 +33,37 @@ export default { required: false, default: true, }, + paginatable: { + type: Boolean, + required: false, + default: false, + }, }, computed: { - ...mapState([ - 'importedProjects', - 'providerRepos', - 'incompatibleRepos', - 'isLoadingRepos', - 'filter', - ]), + ...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace', 'pageInfo']), ...mapGetters([ + 'isLoading', 'isImportingAnyRepo', - 'hasProviderRepos', - 'hasImportedProjects', + 'hasImportableRepos', 'hasIncompatibleRepos', ]), + availableNamespaces() { + const serializedNamespaces = this.namespaces.map(({ fullPath }) => ({ + id: fullPath, + text: fullPath, + })); + + return [ + { text: __('Groups'), children: serializedNamespaces }, + { + text: __('Users'), + children: [{ id: this.defaultTargetNamespace, text: this.defaultTargetNamespace }], + }, + ]; + }, + importAllButtonText() { return this.hasIncompatibleRepos ? __('Import all compatible repositories') @@ -64,7 +82,8 @@ export default { }, mounted() { - return this.fetchRepos(); + this.fetchNamespaces(); + this.fetchRepos(); }, beforeDestroy() { @@ -75,17 +94,14 @@ export default { methods: { ...mapActions([ 'fetchRepos', - 'fetchReposFiltered', - 'fetchJobs', + 'fetchNamespaces', 'stopJobsPolling', 'clearJobsEtagPoll', 'setFilter', + 'importAll', + 'setPage', ]), - importAll() { - eventHub.$emit('importAll'); - }, - handleFilterInput({ target }) { this.setFilter(target.value); }, @@ -93,79 +109,90 @@ export default { throttledFetchRepos: throttle(function fetch() { this.fetchRepos(); }, reposFetchThrottleDelay), + + isProjectImportable, }, }; </script> <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> + <slot name="incompatible-repos-warning"></slot> </template> - <div - v-if="!isLoadingRepos" - class="d-flex justify-content-between align-items-end flex-wrap mb-3" - > - <gl-button - variant="success" - :loading="isImportingAnyRepo" - :disabled="!hasProviderRepos" - 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="isLoadingRepos" + v-if="isLoading" class="js-loading-button-icon import-projects-loading-icon" size="md" /> - <div - v-else-if="hasProviderRepos || hasImportedProjects || hasIncompatibleRepos" - 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> - <imported-project-table-row - v-for="project in importedProjects" - :key="project.id" - :project="project" - /> - <provider-repo-table-row v-for="repo in providerRepos" :key="repo.id" :repo="repo" /> - <incompatible-repo-table-row - v-for="repo in incompatibleRepos" - :key="repo.id" - :repo="repo" + <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" /> - </tbody> - </table> - </div> - <div v-else class="text-center"> - <strong>{{ emptyStateText }}</strong> - </div> + </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> diff --git a/app/assets/javascripts/import_projects/components/imported_project_table_row.vue b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue index ab2bd87ee9f..50e735b4478 100644 --- a/app/assets/javascripts/import_projects/components/imported_project_table_row.vue +++ b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue @@ -1,4 +1,5 @@ <script> +import { GlIcon } from '@gitlab/ui'; import ImportStatus from './import_status.vue'; import { STATUSES } from '../constants'; @@ -6,6 +7,7 @@ export default { name: 'ImportedProjectTableRow', components: { ImportStatus, + GlIcon, }, props: { project: { @@ -16,7 +18,7 @@ export default { computed: { displayFullPath() { - return this.project.fullPath.replace(/^\//, ''); + return this.project.importedProject.fullPath.replace(/^\//, ''); }, isFinished() { @@ -27,28 +29,30 @@ export default { </script> <template> - <tr class="js-imported-project import-row"> + <tr class="import-row"> <td> <a - :href="project.providerLink" + :href="project.importSource.providerLink" rel="noreferrer noopener" target="_blank" - class="js-provider-link" - > - {{ project.importSource }} + data-testid="providerLink" + >{{ project.importSource.fullName }} + <gl-icon v-if="project.importSource.providerLink" name="external-link" /> </a> </td> - <td class="js-full-path">{{ displayFullPath }}</td> - <td><import-status :status="project.importStatus" /></td> + <td data-testid="fullPath">{{ displayFullPath }}</td> + <td> + <import-status :status="project.importStatus" /> + </td> <td> <a v-if="isFinished" - class="btn btn-default js-go-to-project" - :href="project.fullPath" + class="btn btn-default" + data-testid="goToProject" + :href="project.importedProject.fullPath" rel="noreferrer noopener" target="_blank" - > - {{ __('Go to project') }} + >{{ __('Go to project') }} </a> </td> </tr> diff --git a/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue b/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue index fa2fb439eac..3140585ccd7 100644 --- a/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue +++ b/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue @@ -1,9 +1,10 @@ <script> -import { GlBadge } from '@gitlab/ui'; +import { GlIcon, GlBadge } from '@gitlab/ui'; export default { components: { GlBadge, + GlIcon, }, props: { repo: { @@ -17,8 +18,9 @@ export default { <template> <tr class="import-row"> <td> - <a :href="repo.providerLink" rel="noreferrer noopener" target="_blank"> - {{ repo.fullName }} + <a :href="repo.importSource.providerLink" rel="noreferrer noopener" target="_blank" + >{{ repo.importSource.fullName }} + <gl-icon v-if="repo.importSource.providerLink" name="external-link" /> </a> </td> <td></td> diff --git a/app/assets/javascripts/import_projects/components/page_query_param_sync.vue b/app/assets/javascripts/import_projects/components/page_query_param_sync.vue new file mode 100644 index 00000000000..5ba3d70f5d0 --- /dev/null +++ b/app/assets/javascripts/import_projects/components/page_query_param_sync.vue @@ -0,0 +1,39 @@ +<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> diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue index 63524d61146..d8cffc6a7d5 100644 --- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue @@ -1,9 +1,8 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; +import { GlIcon } from '@gitlab/ui'; import Select2Select from '~/vue_shared/components/select2_select.vue'; import { __ } from '~/locale'; -import eventHub from '../event_hub'; -import { STATUSES } from '../constants'; import ImportStatus from './import_status.vue'; export default { @@ -11,25 +10,26 @@ export default { components: { Select2Select, ImportStatus, + GlIcon, }, props: { repo: { type: Object, required: true, }, - }, - - data() { - return { - targetNamespace: this.$store.state.defaultTargetNamespace, - newName: this.repo.sanitizedName, - }; + availableNamespaces: { + type: Array, + required: true, + }, }, computed: { - ...mapState(['namespaces', 'reposBeingImported', 'ciCdOnly']), + ...mapState(['ciCdOnly']), + ...mapGetters(['getImportTarget']), - ...mapGetters(['namespaceSelectOptions']), + importTarget() { + return this.getImportTarget(this.repo.importSource.id); + }, importButtonText() { return this.ciCdOnly ? __('Connect') : __('Import'); @@ -37,37 +37,36 @@ export default { select2Options() { return { - data: this.namespaceSelectOptions, - containerCssClass: - 'import-namespace-select js-namespace-select qa-project-namespace-select w-auto', + data: this.availableNamespaces, + containerCssClass: 'import-namespace-select qa-project-namespace-select w-auto', }; }, - isLoadingImport() { - return this.reposBeingImported.includes(this.repo.id); + targetNamespaceSelect: { + get() { + return this.importTarget.targetNamespace; + }, + set(value) { + this.updateImportTarget({ targetNamespace: value }); + }, }, - status() { - return this.isLoadingImport ? STATUSES.SCHEDULING : STATUSES.NONE; + newNameInput: { + get() { + return this.importTarget.newName; + }, + set(value) { + this.updateImportTarget({ newName: value }); + }, }, }, - created() { - eventHub.$on('importAll', this.importRepo); - }, - - beforeDestroy() { - eventHub.$off('importAll', this.importRepo); - }, - methods: { - ...mapActions(['fetchImport']), - - importRepo() { - return this.fetchImport({ - newName: this.newName, - targetNamespace: this.targetNamespace, - repo: this.repo, + ...mapActions(['fetchImport', 'setImportTarget']), + updateImportTarget(changedValues) { + this.setImportTarget({ + repoId: this.repo.importSource.id, + importTarget: { ...this.importTarget, ...changedValues }, }); }, }, @@ -75,35 +74,39 @@ export default { </script> <template> - <tr class="qa-project-import-row js-provider-repo import-row"> + <tr class="qa-project-import-row import-row"> <td> <a - :href="repo.providerLink" + :href="repo.importSource.providerLink" rel="noreferrer noopener" target="_blank" - class="js-provider-link" - > - {{ repo.fullName }} + data-testid="providerLink" + >{{ repo.importSource.fullName }} + <gl-icon v-if="repo.importSource.providerLink" name="external-link" /> </a> </td> <td class="d-flex flex-wrap flex-lg-nowrap"> - <select2-select v-model="targetNamespace" :options="select2Options" /> - <span class="px-2 import-slash-divider d-flex justify-content-center align-items-center" - >/</span - > - <input - v-model="newName" - type="text" - class="form-control import-project-name-input js-new-name qa-project-path-field" - /> + <template v-if="repo.target">{{ repo.target }}</template> + <template v-else> + <select2-select v-model="targetNamespaceSelect" :options="select2Options" /> + <span class="px-2 import-slash-divider d-flex justify-content-center align-items-center" + >/</span + > + <input + v-model="newNameInput" + type="text" + class="form-control import-project-name-input qa-project-path-field" + /> + </template> + </td> + <td> + <import-status :status="repo.importStatus" /> </td> - <td><import-status :status="status" /></td> <td> <button - v-if="!isLoadingImport" type="button" - class="qa-import-button js-import-button btn btn-default" - @click="importRepo" + class="qa-import-button btn btn-default" + @click="fetchImport(repo.importSource.id)" > {{ importButtonText }} </button> diff --git a/app/assets/javascripts/import_projects/event_hub.js b/app/assets/javascripts/import_projects/event_hub.js deleted file mode 100644 index e31806ad199..00000000000 --- a/app/assets/javascripts/import_projects/event_hub.js +++ /dev/null @@ -1,3 +0,0 @@ -import createEventHub from '~/helpers/event_hub_factory'; - -export default createEventHub(); diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js index 68ba04aa9dd..79fbd58e355 100644 --- a/app/assets/javascripts/import_projects/index.js +++ b/app/assets/javascripts/import_projects/index.js @@ -2,28 +2,44 @@ 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); export function initStoreFromElement(element) { const { - reposPath, - provider, + ciCdOnly, canSelectNamespace, + provider, + + reposPath, jobsPath, importPath, - ciCdOnly, + namespacesPath, + paginatable, } = element.dataset; + const params = queryToObject(document.location.search); + const page = parseInt(params.page ?? 1, 10); + return createStore({ - reposPath, - provider, - jobsPath, - importPath, - defaultTargetNamespace: gon.current_username, - ciCdOnly: parseBoolean(ciCdOnly), - canSelectNamespace: parseBoolean(canSelectNamespace), + initialState: { + defaultTargetNamespace: gon.current_username, + ciCdOnly: parseBoolean(ciCdOnly), + canSelectNamespace: parseBoolean(canSelectNamespace), + provider, + pageInfo: { + page, + }, + }, + endpoints: { + reposPath, + jobsPath, + importPath, + namespacesPath, + }, + hasPagination: parseBoolean(paginatable), }); } @@ -31,6 +47,7 @@ export function initPropsFromElement(element) { return { providerTitle: element.dataset.providerTitle, filterable: parseBoolean(element.dataset.filterable), + paginatable: parseBoolean(element.dataset.paginatable), }; } diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js index 8d8d33f5972..af410f411d8 100644 --- a/app/assets/javascripts/import_projects/store/actions.js +++ b/app/assets/javascripts/import_projects/store/actions.js @@ -1,41 +1,86 @@ import Visibility from 'visibilityjs'; import * as types from './mutation_types'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { isProjectImportable } from '../utils'; +import { + convertObjectPropsToCamelCase, + normalizeHeaders, + parseIntPagination, +} from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; -import { visitUrl } from '~/lib/utils/url_utility'; -import createFlash from '~/flash'; +import { visitUrl, objectToQuery } from '~/lib/utils/url_utility'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__, sprintf } from '~/locale'; import axios from '~/lib/utils/axios_utils'; -import { jobsPathWithFilter, reposPathWithFilter } from './getters'; let eTagPoll; const hasRedirectInError = e => e?.response?.data?.error?.redirect; const redirectToUrlInError = e => visitUrl(e.response.data.error.redirect); +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 + throw new Error('param is required'); +}; -export const clearJobsEtagPoll = () => { +const clearJobsEtagPoll = () => { eTagPoll = null; }; -export const stopJobsPolling = () => { + +const stopJobsPolling = () => { if (eTagPoll) eTagPoll.stop(); }; -export const restartJobsPolling = () => { + +const restartJobsPolling = () => { if (eTagPoll) eTagPoll.restart(); }; -export const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter); +const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter); + +const setImportTarget = ({ commit }, { repoId, importTarget }) => + commit(types.SET_IMPORT_TARGET, { repoId, importTarget }); + +const importAll = ({ state, dispatch }) => { + return Promise.all( + state.repositories + .filter(isProjectImportable) + .map(r => dispatch('fetchImport', r.importSource.id)), + ); +}; -export const fetchRepos = ({ state, dispatch, commit }) => { +const fetchReposFactory = ({ reposPath = isRequired(), hasPagination }) => ({ + state, + dispatch, + commit, +}) => { dispatch('stopJobsPolling'); commit(types.REQUEST_REPOS); - const { provider } = state; + const { provider, filter } = state; return axios - .get(reposPathWithFilter(state)) - .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)) { @@ -52,24 +97,26 @@ export const fetchRepos = ({ state, dispatch, commit }) => { }); }; -export const fetchImport = ({ state, commit }, { newName, targetNamespace, repo }) => { - if (!state.reposBeingImported.includes(repo.id)) { - commit(types.REQUEST_IMPORT, repo.id); - } +const fetchImportFactory = (importPath = isRequired()) => ({ state, commit, getters }, repoId) => { + const { ciCdOnly } = state; + const importTarget = getters.getImportTarget(repoId); + + commit(types.REQUEST_IMPORT, { repoId, importTarget }); + const { newName, targetNamespace } = importTarget; return axios - .post(state.importPath, { - ci_cd_only: state.ciCdOnly, + .post(importPath, { + repo_id: repoId, + ci_cd_only: ciCdOnly, new_name: newName, - repo_id: repo.id, target_namespace: targetNamespace, }) - .then(({ data }) => + .then(({ data }) => { commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject: convertObjectPropsToCamelCase(data, { deep: true }), - repoId: repo.id, - }), - ) + repoId, + }); + }) .catch(e => { const serverErrorMessage = e?.response?.data?.errors; const flashMessage = serverErrorMessage @@ -84,14 +131,11 @@ export const fetchImport = ({ state, commit }, { newName, targetNamespace, repo createFlash(flashMessage); - commit(types.RECEIVE_IMPORT_ERROR, repo.id); + commit(types.RECEIVE_IMPORT_ERROR, repoId); }); }; -export const receiveJobsSuccess = ({ commit }, updatedProjects) => - commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects); - -export const fetchJobs = ({ state, commit, dispatch }) => { +export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, dispatch }) => { const { filter } = state; if (eTagPoll) { @@ -101,7 +145,7 @@ export const fetchJobs = ({ state, commit, dispatch }) => { eTagPoll = new Poll({ resource: { - fetchJobs: () => axios.get(jobsPathWithFilter(state)), + fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, filter })), }, method: 'fetchJobs', successCallback: ({ data }) => @@ -129,5 +173,39 @@ export const fetchJobs = ({ state, commit, dispatch }) => { }); }; -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; +const fetchNamespacesFactory = (namespacesPath = isRequired()) => ({ commit }) => { + commit(types.REQUEST_NAMESPACES); + axios + .get(namespacesPath) + .then(({ data }) => + commit(types.RECEIVE_NAMESPACES_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), + ) + .catch(() => { + createFlash(s__('ImportProjects|Requesting namespaces failed')); + + commit(types.RECEIVE_NAMESPACES_ERROR); + }); +}; + +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, + setPage, + fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath, hasPagination }), + fetchImport: fetchImportFactory(endpoints.importPath), + fetchJobs: fetchJobsFactory(endpoints.jobsPath), + fetchNamespaces: fetchNamespacesFactory(endpoints.namespacesPath), +}); diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js index e6eb8f523de..7d529c94d7d 100644 --- a/app/assets/javascripts/import_projects/store/getters.js +++ b/app/assets/javascripts/import_projects/store/getters.js @@ -1,29 +1,27 @@ -import { __ } from '~/locale'; +import { STATUSES } from '../constants'; -export const namespaceSelectOptions = state => { - const serializedNamespaces = state.namespaces.map(({ fullPath }) => ({ - id: fullPath, - text: fullPath, - })); +export const isLoading = state => state.isLoadingRepos || state.isLoadingNamespaces; - return [ - { text: __('Groups'), children: serializedNamespaces }, - { - text: __('Users'), - children: [{ id: state.defaultTargetNamespace, text: state.defaultTargetNamespace }], - }, - ]; -}; +export const isImportingAnyRepo = state => + state.repositories.some(repo => + [STATUSES.SCHEDULING, STATUSES.SCHEDULED, STATUSES.STARTED].includes(repo.importStatus), + ); -export const isImportingAnyRepo = state => state.reposBeingImported.length > 0; +export const hasIncompatibleRepos = state => + state.repositories.some(repo => repo.importSource.incompatible); -export const hasProviderRepos = state => state.providerRepos.length > 0; +export const hasImportableRepos = state => + state.repositories.some(repo => repo.importStatus === STATUSES.NONE); -export const hasImportedProjects = state => state.importedProjects.length > 0; +export const getImportTarget = state => repoId => { + if (state.customImportTargets[repoId]) { + return state.customImportTargets[repoId]; + } -export const hasIncompatibleRepos = state => state.incompatibleRepos.length > 0; + const repo = state.repositories.find(r => r.importSource.id === repoId); -export const reposPathWithFilter = ({ reposPath, filter = '' }) => - filter ? `${reposPath}?filter=${filter}` : reposPath; -export const jobsPathWithFilter = ({ jobsPath, filter = '' }) => - filter ? `${jobsPath}?filter=${filter}` : jobsPath; + return { + newName: repo.importSource.sanitizedName, + targetNamespace: state.defaultTargetNamespace, + }; +}; diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js index 29deb7868ba..7ba12f81eb9 100644 --- a/app/assets/javascripts/import_projects/store/index.js +++ b/app/assets/javascripts/import_projects/store/index.js @@ -1,18 +1,16 @@ import Vue from 'vue'; import Vuex from 'vuex'; import state from './state'; -import * as actions from './actions'; +import actionsFactory from './actions'; import * as getters from './getters'; import mutations from './mutations'; Vue.use(Vuex); -export { state, actions, getters, mutations }; - -export default initialState => +export default ({ initialState, endpoints, hasPagination }) => new Vuex.Store({ state: { ...state(), ...initialState }, - actions, + actions: actionsFactory({ endpoints, hasPagination }), mutations, getters, }); diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_projects/store/mutation_types.js index a23b7eef986..6adf5e59cff 100644 --- a/app/assets/javascripts/import_projects/store/mutation_types.js +++ b/app/assets/javascripts/import_projects/store/mutation_types.js @@ -2,6 +2,10 @@ export const REQUEST_REPOS = 'REQUEST_REPOS'; export const RECEIVE_REPOS_SUCCESS = 'RECEIVE_REPOS_SUCCESS'; export const RECEIVE_REPOS_ERROR = 'RECEIVE_REPOS_ERROR'; +export const REQUEST_NAMESPACES = 'REQUEST_NAMESPACES'; +export const RECEIVE_NAMESPACES_SUCCESS = 'RECEIVE_NAMESPACES_SUCCESS'; +export const RECEIVE_NAMESPACES_ERROR = 'RECEIVE_NAMESPACES_ERROR'; + export const REQUEST_IMPORT = 'REQUEST_IMPORT'; export const RECEIVE_IMPORT_SUCCESS = 'RECEIVE_IMPORT_SUCCESS'; export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR'; @@ -9,3 +13,9 @@ export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR'; 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'; diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js index ec62d0640ef..b3dbef896a6 100644 --- a/app/assets/javascripts/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_projects/store/mutations.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import * as types from './mutation_types'; +import { STATUSES } from '../constants'; export default { [types.SET_FILTER](state, filter) { @@ -12,48 +13,103 @@ export default { [types.RECEIVE_REPOS_SUCCESS]( state, - { importedProjects, providerRepos, incompatibleRepos, namespaces }, + { importedProjects, providerRepos, incompatibleRepos = [] }, ) { + // Normalizing structure to support legacy backend format + // See https://gitlab.com/gitlab-org/gitlab/-/issues/27370#note_379034091 for details + state.isLoadingRepos = false; - state.importedProjects = importedProjects; - state.providerRepos = providerRepos; - state.incompatibleRepos = incompatibleRepos ?? []; - state.namespaces = namespaces; + state.repositories = [ + ...importedProjects.map(({ importSource, providerLink, importStatus, ...project }) => ({ + importSource: { + id: `finished-${project.id}`, + fullName: importSource, + sanitizedName: project.name, + providerLink, + }, + importStatus, + importedProject: project, + })), + ...providerRepos.map(project => ({ + importSource: project, + importStatus: STATUSES.NONE, + importedProject: null, + })), + ...incompatibleRepos.map(project => ({ + importSource: { ...project, incompatible: true }, + importStatus: STATUSES.NONE, + importedProject: null, + })), + ]; }, [types.RECEIVE_REPOS_ERROR](state) { state.isLoadingRepos = false; }, - [types.REQUEST_IMPORT](state, repoId) { - state.reposBeingImported.push(repoId); + [types.REQUEST_IMPORT](state, { repoId, importTarget }) { + const existingRepo = state.repositories.find(r => r.importSource.id === repoId); + existingRepo.importStatus = STATUSES.SCHEDULING; + existingRepo.importedProject = { + fullPath: `/${importTarget.targetNamespace}/${importTarget.newName}`, + }; }, [types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId }) { - const existingRepoIndex = state.reposBeingImported.indexOf(repoId); - if (state.reposBeingImported.includes(repoId)) - state.reposBeingImported.splice(existingRepoIndex, 1); + const { importStatus, ...project } = importedProject; - const providerRepoIndex = state.providerRepos.findIndex( - providerRepo => providerRepo.id === repoId, - ); - state.providerRepos.splice(providerRepoIndex, 1); - state.importedProjects.unshift(importedProject); + const existingRepo = state.repositories.find(r => r.importSource.id === repoId); + existingRepo.importStatus = importStatus; + existingRepo.importedProject = project; }, [types.RECEIVE_IMPORT_ERROR](state, repoId) { - const repoIndex = state.reposBeingImported.indexOf(repoId); - if (state.reposBeingImported.includes(repoId)) state.reposBeingImported.splice(repoIndex, 1); + const existingRepo = state.repositories.find(r => r.importSource.id === repoId); + existingRepo.importStatus = STATUSES.NONE; + existingRepo.importedProject = null; }, [types.RECEIVE_JOBS_SUCCESS](state, updatedProjects) { updatedProjects.forEach(updatedProject => { - const existingProject = state.importedProjects.find( - importedProject => importedProject.id === updatedProject.id, - ); - - Vue.set(existingProject, 'importStatus', updatedProject.importStatus); + const repo = state.repositories.find(p => p.importedProject?.id === updatedProject.id); + if (repo) { + repo.importStatus = updatedProject.importStatus; + } }); }, + + [types.REQUEST_NAMESPACES](state) { + state.isLoadingNamespaces = true; + }, + + [types.RECEIVE_NAMESPACES_SUCCESS](state, namespaces) { + state.isLoadingNamespaces = false; + state.namespaces = namespaces; + }, + + [types.RECEIVE_NAMESPACES_ERROR](state) { + state.isLoadingNamespaces = false; + }, + + [types.SET_IMPORT_TARGET](state, { repoId, importTarget }) { + const existingRepo = state.repositories.find(r => r.importSource.id === repoId); + + if ( + importTarget.targetNamespace === state.defaultTargetNamespace && + importTarget.newName === existingRepo.importSource.sanitizedName + ) { + Vue.delete(state.customImportTargets, repoId); + } else { + Vue.set(state.customImportTargets, repoId, importTarget); + } + }, + + [types.SET_PAGE_INFO](state, pageInfo) { + state.pageInfo = pageInfo; + }, + + [types.SET_PAGE](state, page) { + state.pageInfo.page = page; + }, }; diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_projects/store/state.js index 0418d735b1d..3318181e4af 100644 --- a/app/assets/javascripts/import_projects/store/state.js +++ b/app/assets/javascripts/import_projects/store/state.js @@ -1,17 +1,13 @@ export default () => ({ - reposPath: '', - importPath: '', - jobsPath: '', - currentProjectId: '', provider: '', - currentUsername: '', - importedProjects: [], - providerRepos: [], - incompatibleRepos: [], + repositories: [], namespaces: [], - reposBeingImported: [], + customImportTargets: {}, isLoadingRepos: false, - canSelectNamespace: false, + isLoadingNamespaces: false, ciCdOnly: false, filter: '', + pageInfo: { + page: 1, + }, }); diff --git a/app/assets/javascripts/import_projects/utils.js b/app/assets/javascripts/import_projects/utils.js new file mode 100644 index 00000000000..c2a2d5a607d --- /dev/null +++ b/app/assets/javascripts/import_projects/utils.js @@ -0,0 +1,7 @@ +import { STATUSES } from './constants'; + +// Will be expanded in future +// eslint-disable-next-line import/prefer-default-export +export function isProjectImportable(project) { + return project.importStatus === STATUSES.NONE && !project.importSource.incompatible; +} |