diff options
Diffstat (limited to 'app/assets/javascripts/import_entities/import_projects')
11 files changed, 916 insertions, 0 deletions
diff --git a/app/assets/javascripts/import_entities/import_projects/components/bitbucket_status_table.vue b/app/assets/javascripts/import_entities/import_projects/components/bitbucket_status_table.vue new file mode 100644 index 00000000000..bc8aa522596 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_projects/components/bitbucket_status_table.vue @@ -0,0 +1,75 @@ +<script> +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import ImportProjectsTable from './import_projects_table.vue'; + +export default { + components: { + ImportProjectsTable, + GlAlert, + GlSprintf, + GlLink, + }, + inheritAttrs: false, + props: { + providerTitle: { + type: String, + required: true, + }, + }, + data() { + return { + isWarningDismissed: false, + }; + }, + computed: { + currentPage() { + return window.location.href; + }, + }, +}; +</script> +<template> + <import-projects-table :provider-title="providerTitle" v-bind="$attrs"> + <template #actions> + <slot name="actions"></slot> + </template> + <template #incompatible-repos-warning> + <gl-alert + v-if="!isWarningDismissed" + variant="warning" + class="gl-my-2" + @dismiss="isWarningDismissed = true" + > + <gl-sprintf + :message=" + __( + 'One or more of your %{provider} projects cannot be imported into GitLab directly because they use Subversion or Mercurial for version control, rather than Git.', + ) + " + > + <template #provider> + {{ providerTitle }} + </template> + </gl-sprintf> + <gl-sprintf + :message=" + __( + 'Please convert %{linkStart}them to Git%{linkEnd}, and go through the %{linkToImportFlow} again.', + ) + " + > + <template #link="{ content }"> + <gl-link + href="https://www.atlassian.com/git/tutorials/migrating-overview" + target="_blank" + >{{ content }}</gl-link + > + </template> + <template #linkToImportFlow> + <gl-link :href="currentPage">{{ __('import flow') }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + </template> + </import-projects-table> +</template> diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue new file mode 100644 index 00000000000..2b6b8b765a2 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -0,0 +1,187 @@ +<script> +import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlButton, GlLoadingIcon, GlIntersectionObserver, GlModal } from '@gitlab/ui'; +import { n__, __, sprintf } from '~/locale'; +import ProviderRepoTableRow from './provider_repo_table_row.vue'; + +export default { + name: 'ImportProjectsTable', + components: { + ProviderRepoTableRow, + GlLoadingIcon, + GlButton, + GlModal, + GlIntersectionObserver, + }, + props: { + providerTitle: { + type: String, + required: true, + }, + filterable: { + type: Boolean, + required: false, + default: true, + }, + paginatable: { + type: Boolean, + required: false, + default: false, + }, + }, + + computed: { + ...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace']), + ...mapGetters([ + 'isLoading', + 'isImportingAnyRepo', + 'hasImportableRepos', + 'hasIncompatibleRepos', + 'importAllCount', + ]), + + pagePaginationStateKey() { + return `${this.filter}-${this.repositories.length}`; + }, + + 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 + ? n__( + 'Import %d compatible repository', + 'Import %d compatible repositories', + this.importAllCount, + ) + : n__('Import %d repository', 'Import %d repositories', this.importAllCount); + }, + + emptyStateText() { + return sprintf(__('No %{providerTitle} repositories found'), { + providerTitle: this.providerTitle, + }); + }, + + fromHeaderText() { + return sprintf(__('From %{providerTitle}'), { providerTitle: this.providerTitle }); + }, + }, + + mounted() { + this.fetchNamespaces(); + this.fetchJobs(); + + if (!this.paginatable) { + this.fetchRepos(); + } + }, + + beforeDestroy() { + this.stopJobsPolling(); + this.clearJobsEtagPoll(); + }, + + methods: { + ...mapActions([ + 'fetchRepos', + 'fetchJobs', + 'fetchNamespaces', + 'stopJobsPolling', + 'clearJobsEtagPoll', + 'setFilter', + 'importAll', + ]), + }, +}; +</script> + +<template> + <div> + <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> + <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 v-if="isLoading" class="import-projects-loading-icon" size="md" /> + + <div v-if="!isLoading && repositories.length === 0" class="text-center"> + <strong>{{ emptyStateText }}</strong> + </div> + </div> +</template> diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue new file mode 100644 index 00000000000..983abda57f7 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue @@ -0,0 +1,151 @@ +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; +import { GlIcon, GlBadge } from '@gitlab/ui'; +import Select2Select from '~/vue_shared/components/select2_select.vue'; +import { __ } from '~/locale'; +import ImportStatus from '../../components/import_status.vue'; +import { STATUSES } from '../../constants'; +import { isProjectImportable, isIncompatible, getImportStatus } from '../utils'; + +export default { + name: 'ProviderRepoTableRow', + components: { + Select2Select, + ImportStatus, + GlIcon, + GlBadge, + }, + props: { + repo: { + type: Object, + required: true, + }, + availableNamespaces: { + type: Array, + required: true, + }, + }, + + computed: { + ...mapState(['ciCdOnly']), + ...mapGetters(['getImportTarget']), + + displayFullPath() { + return this.repo.importedProject.fullPath.replace(/^\//, ''); + }, + + isFinished() { + return this.repo.importedProject?.importStatus === STATUSES.FINISHED; + }, + + isImportNotStarted() { + return isProjectImportable(this.repo); + }, + + isIncompatible() { + return isIncompatible(this.repo); + }, + + importStatus() { + return getImportStatus(this.repo); + }, + + importTarget() { + return this.getImportTarget(this.repo.importSource.id); + }, + + importButtonText() { + return this.ciCdOnly ? __('Connect') : __('Import'); + }, + + select2Options() { + return { + data: this.availableNamespaces, + containerCssClass: 'import-namespace-select qa-project-namespace-select w-auto', + }; + }, + + targetNamespaceSelect: { + get() { + return this.importTarget.targetNamespace; + }, + set(value) { + this.updateImportTarget({ targetNamespace: value }); + }, + }, + + newNameInput: { + get() { + return this.importTarget.newName; + }, + set(value) { + this.updateImportTarget({ newName: value }); + }, + }, + }, + + methods: { + ...mapActions(['fetchImport', 'setImportTarget']), + updateImportTarget(changedValues) { + this.setImportTarget({ + repoId: this.repo.importSource.id, + importTarget: { ...this.importTarget, ...changedValues }, + }); + }, + }, +}; +</script> + +<template> + <tr class="qa-project-import-row import-row"> + <td> + <a + :href="repo.importSource.providerLink" + rel="noreferrer noopener" + target="_blank" + 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" data-testid="fullPath"> + <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template> + <template v-else-if="isImportNotStarted"> + <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> + <template v-else-if="repo.importedProject">{{ displayFullPath }}</template> + </td> + <td> + <import-status :status="importStatus" /> + </td> + <td data-testid="actions"> + <a + v-if="isFinished" + class="btn btn-default" + :href="repo.importedProject.fullPath" + rel="noreferrer noopener" + target="_blank" + >{{ __('Go to project') }} + </a> + <button + v-if="isImportNotStarted" + type="button" + class="qa-import-button btn btn-default" + @click="fetchImport(repo.importSource.id)" + > + {{ importButtonText }} + </button> + <gl-badge v-else-if="isIncompatible" variant="danger">{{ + __('Incompatible project') + }}</gl-badge> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js new file mode 100644 index 00000000000..7373b628f2b --- /dev/null +++ b/app/assets/javascripts/import_entities/import_projects/index.js @@ -0,0 +1,60 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ImportProjectsTable from './components/import_projects_table.vue'; +import createStore from './store'; + +Vue.use(Translate); + +export function initStoreFromElement(element) { + const { + ciCdOnly, + canSelectNamespace, + provider, + + reposPath, + jobsPath, + importPath, + namespacesPath, + paginatable, + } = element.dataset; + + return createStore({ + initialState: { + defaultTargetNamespace: gon.current_username, + ciCdOnly: parseBoolean(ciCdOnly), + canSelectNamespace: parseBoolean(canSelectNamespace), + provider, + }, + endpoints: { + reposPath, + jobsPath, + importPath, + namespacesPath, + }, + hasPagination: parseBoolean(paginatable), + }); +} + +export function initPropsFromElement(element) { + return { + providerTitle: element.dataset.providerTitle, + filterable: parseBoolean(element.dataset.filterable), + paginatable: parseBoolean(element.dataset.paginatable), + }; +} + +export default function mountImportProjectsTable(mountElement) { + if (!mountElement) return undefined; + + const store = initStoreFromElement(mountElement); + const props = initPropsFromElement(mountElement); + + return new Vue({ + el: mountElement, + store, + render(createElement) { + return createElement(ImportProjectsTable, { props }); + }, + }); +} diff --git a/app/assets/javascripts/import_entities/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js new file mode 100644 index 00000000000..7b7afd13c55 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js @@ -0,0 +1,201 @@ +import Visibility from 'visibilityjs'; +import * as types from './mutation_types'; +import { isProjectImportable } from '../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'; +import { s__, sprintf } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; + +let eTagPoll; + +const hasRedirectInError = e => e?.response?.data?.error?.redirect; +const redirectToUrlInError = e => visitUrl(e.response.data.error.redirect); +const tooManyRequests = e => e.response.status === httpStatusCodes.TOO_MANY_REQUESTS; +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'); +}; + +const clearJobsEtagPoll = () => { + eTagPoll = null; +}; + +const stopJobsPolling = () => { + if (eTagPoll) eTagPoll.stop(); +}; + +const restartJobsPolling = () => { + if (eTagPoll) eTagPoll.restart(); +}; + +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)), + ); +}; + +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; + + return axios + .get( + pathWithParams({ + path: reposPath, + filter: filter ?? '', + page: nextPage === 1 ? '' : nextPage.toString(), + }), + ) + .then(({ data }) => { + commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })); + }) + .catch(e => { + commit(types.SET_PAGE, nextPage - 1); + + if (hasRedirectInError(e)) { + redirectToUrlInError(e); + } else if (tooManyRequests(e)) { + createFlash( + sprintf(s__('ImportProjects|%{provider} rate limit exceeded. Try again later'), { + provider: capitalizeFirstCharacter(provider), + }), + ); + + commit(types.RECEIVE_REPOS_ERROR); + } else { + createFlash( + sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), { + provider, + }), + ); + + commit(types.RECEIVE_REPOS_ERROR); + } + }); +}; + +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(importPath, { + repo_id: repoId, + ci_cd_only: ciCdOnly, + new_name: newName, + target_namespace: targetNamespace, + }) + .then(({ data }) => { + commit(types.RECEIVE_IMPORT_SUCCESS, { + importedProject: convertObjectPropsToCamelCase(data, { deep: true }), + repoId, + }); + }) + .catch(e => { + const serverErrorMessage = e?.response?.data?.errors; + const flashMessage = serverErrorMessage + ? sprintf( + s__('ImportProjects|Importing the project failed: %{reason}'), + { + reason: serverErrorMessage, + }, + false, + ) + : s__('ImportProjects|Importing the project failed'); + + createFlash(flashMessage); + + commit(types.RECEIVE_IMPORT_ERROR, repoId); + }); +}; + +export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, dispatch }) => { + if (eTagPoll) { + stopJobsPolling(); + clearJobsEtagPoll(); + } + + eTagPoll = new Poll({ + resource: { + fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, filter: state.filter })), + }, + method: 'fetchJobs', + successCallback: ({ data }) => + commit(types.RECEIVE_JOBS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), + errorCallback: e => { + if (hasRedirectInError(e)) { + redirectToUrlInError(e); + } else { + createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed')); + } + }, + }); + + if (!Visibility.hidden()) { + eTagPoll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + dispatch('restartJobsPolling'); + } else { + dispatch('stopJobsPolling'); + } + }); +}; + +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 setFilter = ({ commit, dispatch }, filter) => { + commit(types.SET_FILTER, filter); + + return dispatch('fetchRepos'); +}; + +export default ({ endpoints = isRequired() }) => ({ + clearJobsEtagPoll, + stopJobsPolling, + restartJobsPolling, + setFilter, + setImportTarget, + importAll, + fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath }), + fetchImport: fetchImportFactory(endpoints.importPath), + fetchJobs: fetchJobsFactory(endpoints.jobsPath), + fetchNamespaces: fetchNamespacesFactory(endpoints.namespacesPath), +}); diff --git a/app/assets/javascripts/import_entities/import_projects/store/getters.js b/app/assets/javascripts/import_entities/import_projects/store/getters.js new file mode 100644 index 00000000000..31e22b50554 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_projects/store/getters.js @@ -0,0 +1,30 @@ +import { STATUSES } from '../../constants'; +import { isProjectImportable, isIncompatible } from '../utils'; + +export const isLoading = state => state.isLoadingRepos || state.isLoadingNamespaces; + +export const isImportingAnyRepo = state => + state.repositories.some(repo => + [STATUSES.SCHEDULING, STATUSES.SCHEDULED, STATUSES.STARTED].includes( + repo.importedProject?.importStatus, + ), + ); + +export const hasIncompatibleRepos = state => state.repositories.some(isIncompatible); + +export const hasImportableRepos = state => state.repositories.some(isProjectImportable); + +export const importAllCount = state => state.repositories.filter(isProjectImportable).length; + +export const getImportTarget = state => repoId => { + if (state.customImportTargets[repoId]) { + return state.customImportTargets[repoId]; + } + + const repo = state.repositories.find(r => r.importSource.id === repoId); + + return { + newName: repo.importSource.sanitizedName, + targetNamespace: state.defaultTargetNamespace, + }; +}; diff --git a/app/assets/javascripts/import_entities/import_projects/store/index.js b/app/assets/javascripts/import_entities/import_projects/store/index.js new file mode 100644 index 00000000000..7ba12f81eb9 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_projects/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import actionsFactory from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default ({ initialState, endpoints, hasPagination }) => + new Vuex.Store({ + state: { ...state(), ...initialState }, + actions: actionsFactory({ endpoints, hasPagination }), + mutations, + getters, + }); diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js new file mode 100644 index 00000000000..6adf5e59cff --- /dev/null +++ b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js @@ -0,0 +1,21 @@ +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'; + +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_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js new file mode 100644 index 00000000000..3d718a6a386 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js @@ -0,0 +1,149 @@ +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) { + state.isLoadingRepos = true; + }, + + [types.RECEIVE_REPOS_SUCCESS](state, repositories) { + state.isLoadingRepos = false; + + 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 = [ + ...newImportedProjects, + ...state.repositories, + ...repositories.providerRepos.map(project => ({ + importSource: project, + importedProject: null, + })), + ...newIncompatibleProjects, + ]; + + if (incompatibleRepos.length === 0 && repositories.providerRepos.length === 0) { + state.pageInfo.page -= 1; + } + + return; + } + + state.repositories = [...state.repositories, ...repositories]; + if (repositories.length === 0) { + state.pageInfo.page -= 1; + } + }, + + [types.RECEIVE_REPOS_ERROR](state) { + state.isLoadingRepos = false; + }, + + [types.REQUEST_IMPORT](state, { repoId, importTarget }) { + const existingRepo = state.repositories.find(r => r.importSource.id === repoId); + existingRepo.importedProject = { + importStatus: STATUSES.SCHEDULING, + fullPath: `/${importTarget.targetNamespace}/${importTarget.newName}`, + }; + }, + + [types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId }) { + const existingRepo = state.repositories.find(r => r.importSource.id === repoId); + existingRepo.importedProject = importedProject; + }, + + [types.RECEIVE_IMPORT_ERROR](state, repoId) { + const existingRepo = state.repositories.find(r => r.importSource.id === repoId); + existingRepo.importedProject = null; + }, + + [types.RECEIVE_JOBS_SUCCESS](state, updatedProjects) { + updatedProjects.forEach(updatedProject => { + const repo = state.repositories.find(p => p.importedProject?.id === updatedProject.id); + if (repo?.importedProject) { + repo.importedProject.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](state, page) { + state.pageInfo.page = page; + }, +}; diff --git a/app/assets/javascripts/import_entities/import_projects/store/state.js b/app/assets/javascripts/import_entities/import_projects/store/state.js new file mode 100644 index 00000000000..ecd93561d52 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_projects/store/state.js @@ -0,0 +1,13 @@ +export default () => ({ + provider: '', + repositories: [], + namespaces: [], + customImportTargets: {}, + isLoadingRepos: false, + isLoadingNamespaces: false, + ciCdOnly: false, + filter: '', + pageInfo: { + page: 0, + }, +}); diff --git a/app/assets/javascripts/import_entities/import_projects/utils.js b/app/assets/javascripts/import_entities/import_projects/utils.js new file mode 100644 index 00000000000..0610117e09b --- /dev/null +++ b/app/assets/javascripts/import_entities/import_projects/utils.js @@ -0,0 +1,13 @@ +import { STATUSES } from '../constants'; + +export function isIncompatible(project) { + return project.importSource.incompatible; +} + +export function getImportStatus(project) { + return project.importedProject?.importStatus ?? STATUSES.NONE; +} + +export function isProjectImportable(project) { + return !isIncompatible(project) && getImportStatus(project) === STATUSES.NONE; +} |