summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/import_entities/import_projects
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/import_entities/import_projects')
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/bitbucket_status_table.vue75
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue187
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue151
-rw-r--r--app/assets/javascripts/import_entities/import_projects/index.js60
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/actions.js201
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/getters.js30
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/index.js16
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/mutation_types.js21
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/mutations.js149
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/state.js13
-rw-r--r--app/assets/javascripts/import_entities/import_projects/utils.js13
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;
+}