Commit 66e3288b authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'xanf-refactor-import-projects-store' into 'master'

Refactor import_projects store

See merge request gitlab-org/gitlab!39383
parents 5df7a5d0 344cfc45
......@@ -6,7 +6,7 @@ import { __, sprintf } from '~/locale';
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 { isProjectImportable } from '../utils';
const reposFetchThrottleDelay = 1000;
......@@ -32,20 +32,29 @@ export default {
},
computed: {
...mapState([
'importedProjects',
'providerRepos',
'incompatibleRepos',
'isLoadingRepos',
'filter',
]),
...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace']),
...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 +73,8 @@ export default {
},
mounted() {
return this.fetchRepos();
this.fetchNamespaces();
this.fetchRepos();
},
beforeDestroy() {
......@@ -75,17 +85,13 @@ export default {
methods: {
...mapActions([
'fetchRepos',
'fetchReposFiltered',
'fetchJobs',
'fetchNamespaces',
'stopJobsPolling',
'clearJobsEtagPoll',
'setFilter',
'importAll',
]),
importAll() {
eventHub.$emit('importAll');
},
handleFilterInput({ target }) {
this.setFilter(target.value);
},
......@@ -93,6 +99,8 @@ export default {
throttledFetchRepos: throttle(function fetch() {
this.fetchRepos();
}, reposFetchThrottleDelay),
isProjectImportable,
},
};
</script>
......@@ -103,21 +111,17 @@ export default {
{{ 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"
>
<div v-if="!isLoading" class="d-flex justify-content-between align-items-end flex-wrap mb-3">
<gl-button
variant="success"
:loading="isImportingAnyRepo"
:disabled="!hasProviderRepos"
:disabled="!hasImportableRepos"
type="button"
@click="importAll"
>{{ importAllButtonText }}</gl-button
>
{{ importAllButtonText }}
</gl-button>
<slot name="actions"></slot>
<form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent>
<input
......@@ -134,14 +138,11 @@ export default {
</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"
>
<div v-else-if="repositories.length" class="table-responsive">
<table class="table import-table">
<thead>
<th class="import-jobs-from-col">{{ fromHeaderText }}</th>
......@@ -150,17 +151,20 @@ export default {
<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" />
<template v-for="repo in repositories">
<incompatible-repo-table-row
v-for="repo in incompatibleRepos"
:key="repo.id"
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>
......
......@@ -18,7 +18,7 @@ export default {
computed: {
displayFullPath() {
return this.project.fullPath.replace(/^\//, '');
return this.project.importedProject.fullPath.replace(/^\//, '');
},
isFinished() {
......@@ -29,29 +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 }}
<gl-icon v-if="project.providerLink" name="external-link" />
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>
......
......@@ -18,9 +18,9 @@ export default {
<template>
<tr class="import-row">
<td>
<a :href="repo.providerLink" rel="noreferrer noopener" target="_blank">
{{ repo.fullName }}
<gl-icon v-if="repo.providerLink" name="external-link" />
<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>
......
......@@ -3,8 +3,6 @@ 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 {
......@@ -19,19 +17,19 @@ export default {
type: Object,
required: true,
},
availableNamespaces: {
type: Array,
required: true,
},
data() {
return {
targetNamespace: this.$store.state.defaultTargetNamespace,
newName: this.repo.sanitizedName,
};
},
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');
......@@ -39,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;
},
status() {
return this.isLoadingImport ? STATUSES.SCHEDULING : STATUSES.NONE;
set(value) {
this.updateImportTarget({ targetNamespace: value });
},
},
created() {
eventHub.$on('importAll', this.importRepo);
newNameInput: {
get() {
return this.importTarget.newName;
},
set(value) {
this.updateImportTarget({ newName: value });
},
},
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 },
});
},
},
......@@ -77,36 +74,36 @@ 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 }}
<gl-icon v-if="repo.providerLink" name="external-link" />
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" />
<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="newName"
v-model="newNameInput"
type="text"
class="form-control import-project-name-input js-new-name qa-project-path-field"
class="form-control import-project-name-input qa-project-path-field"
/>
</td>
<td><import-status :status="status" /></td>
<td>
<import-status :status="repo.importStatus" />
</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>
......
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
......@@ -8,22 +8,29 @@ Vue.use(Translate);
export function initStoreFromElement(element) {
const {
reposPath,
provider,
ciCdOnly,
canSelectNamespace,
provider,
reposPath,
jobsPath,
importPath,
ciCdOnly,
namespacesPath,
} = element.dataset;
return createStore({
reposPath,
provider,
jobsPath,
importPath,
initialState: {
defaultTargetNamespace: gon.current_username,
ciCdOnly: parseBoolean(ciCdOnly),
canSelectNamespace: parseBoolean(canSelectNamespace),
provider,
},
endpoints: {
reposPath,
jobsPath,
importPath,
namespacesPath,
},
});
}
......
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 } from '~/lib/utils/url_utility';
import 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 pathWithFilter = ({ path, filter = '' }) => (filter ? `${path}?filter=${filter}` : path);
export const clearJobsEtagPoll = () => {
const isRequired = () => {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('param is required');
};
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 });
export const fetchRepos = ({ state, dispatch, commit }) => {
const importAll = ({ state, dispatch }) => {
return Promise.all(
state.repositories
.filter(isProjectImportable)
.map(r => dispatch('fetchImport', r.importSource.id)),
);
};
const fetchReposFactory = (reposPath = isRequired()) => ({ state, dispatch, commit }) => {
dispatch('stopJobsPolling');
commit(types.REQUEST_REPOS);
const { provider } = state;
const { provider, filter } = state;
return axios
.get(reposPathWithFilter(state))
.get(pathWithFilter({ path: reposPath, filter }))
.then(({ data }) =>
commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })),
)
......@@ -52,22 +71,24 @@ 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 }) =>
commit(types.RECEIVE_IMPORT_SUCCESS, {
importedProject: convertObjectPropsToCamelCase(data, { deep: true }),
repoId: repo.id,
repoId,
}),
)
.catch(e => {
......@@ -84,14 +105,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 +119,7 @@ export const fetchJobs = ({ state, commit, dispatch }) => {
eTagPoll = new Poll({
resource: {
fetchJobs: () => axios.get(jobsPathWithFilter(state)),
fetchJobs: () => axios.get(pathWithFilter({ path: jobsPath, filter })),
},
method: 'fetchJobs',
successCallback: ({ data }) =>
......@@ -128,3 +146,30 @@ export const fetchJobs = ({ state, commit, dispatch }) => {
}
});
};
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);
});
};
export default ({ endpoints = isRequired() }) => ({
clearJobsEtagPoll,
stopJobsPolling,
restartJobsPolling,
setFilter,
setImportTarget,
importAll,
fetchRepos: fetchReposFactory(endpoints.reposPath),
fetchImport: fetchImportFactory(endpoints.importPath),
fetchJobs: fetchJobsFactory(endpoints.jobsPath),
fetchNamespaces: fetchNamespacesFactory(endpoints.namespacesPath),
});
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,
};
};
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 }) =>
new Vuex.Store({
state: { ...state(), ...initialState },
actions,
actions: actionsFactory({ endpoints }),
mutations,
getters,
});
......@@ -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,5 @@ 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';
import Vue from 'vue';
import * as types from './mutation_types';
import { STATUSES } from '../constants';
export default {
[types.SET_FILTER](state, filter) {
......@@ -12,48 +13,95 @@ 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);
}
},
};
export default () => ({
reposPath: '',
importPath: '',
jobsPath: '',
currentProjectId: '',
provider: '',
currentUsername: '',
importedProjects: [],
providerRepos: [],
incompatibleRepos: [],
repositories: [],
namespaces: [],
reposBeingImported: [],
customImportTargets: {},
isLoadingRepos: false,
canSelectNamespace: false,
isLoadingNamespaces: false,
ciCdOnly: false,
filter: '',
});
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;
}
......@@ -6,6 +6,7 @@
#import-projects-mount-element{ data: { provider: provider, provider_title: provider_title,
can_select_namespace: current_user.can_select_namespace?.to_s,
ci_cd_only: has_ci_cd_only_params?.to_s,
namespaces_path: import_available_namespaces_path,
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]),
......
......@@ -12851,6 +12851,9 @@ msgstr ""
msgid "ImportProjects|Importing the project failed: %{reason}"
msgstr ""
msgid "ImportProjects|Requesting namespaces failed"
msgstr ""
msgid "ImportProjects|Requesting your %{provider} repositories failed"
msgstr ""
......
......@@ -2,17 +2,14 @@ import { nextTick } from 'vue';
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import { state, getters } from '~/import_projects/store';
import eventHub from '~/import_projects/event_hub';
import state from '~/import_projects/store/state';
import * as getters from '~/import_projects/store/getters';
import { STATUSES } from '~/import_projects/constants';
import ImportProjectsTable from '~/import_projects/components/import_projects_table.vue';
import 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';
jest.mock('~/import_projects/event_hub', () => ({
$emit: jest.fn(),
}));
describe('ImportProjectsTable', () => {
let wrapper;
......@@ -21,13 +18,6 @@ describe('ImportProjectsTable', () => {
const providerTitle = 'THE PROVIDER';
const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' };
const importedProject = {
id: 1,
fullPath: 'fullPath',
importStatus: 'started',
providerLink: 'providerLink',
importSource: 'importSource',
};
const findImportAllButton = () =>
wrapper
......@@ -35,6 +25,7 @@ describe('ImportProjectsTable', () => {
.filter(w => w.props().variant === 'success')
.at(0);
const importAllFn = jest.fn();
function createComponent({
state: initialState,
getters: customGetters,
......@@ -52,8 +43,9 @@ describe('ImportProjectsTable', () => {
},
actions: {
fetchRepos: jest.fn(),
fetchReposFiltered: jest.fn(),
fetchJobs: jest.fn(),
fetchNamespaces: jest.fn(),
importAll: importAllFn,
stopJobsPolling: jest.fn(),
clearJobsEtagPoll: jest.fn(),
setFilter: jest.fn(),
......@@ -79,22 +71,30 @@ describe('ImportProjectsTable', () => {
});
it('renders a loading icon while repos are loading', () => {
createComponent({
state: {
isLoadingRepos: true,
},
createComponent({ state: { isLoadingRepos: true } });
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
});
it('renders a loading icon while namespaces are loading', () => {
createComponent({ state: { isLoadingNamespaces: true } });
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
});
it('renders a table with imported projects and provider repos', () => {
createComponent({
state: {
importedProjects: [importedProject],
providerRepos: [providerRepo],
incompatibleRepos: [{ ...providerRepo, id: 11 }],
namespaces: [{ path: 'path' }],
namespaces: [{ fullPath: 'path' }],
repositories: [
{ importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE },
{ importSource: { id: 2 }, importedProject: {}, importStatus: STATUSES.FINISHED },
{
importSource: { id: 3, incompatible: true },
importedProject: {},
importStatus: STATUSES.NONE,
},
],
},
});
......@@ -133,13 +133,7 @@ describe('ImportProjectsTable', () => {
);
it('renders an empty state if there are no projects available', () => {
createComponent({
state: {
importedProjects: [],
providerRepos: [],
incompatibleProjects: [],
},
});
createComponent({ state: { repositories: [] } });
expect(wrapper.contains(ProviderRepoTableRow)).toBe(false);
expect(wrapper.contains(ImportedProjectTableRow)).toBe(false);
......@@ -147,34 +141,29 @@ describe('ImportProjectsTable', () => {
});
it('sends importAll event when import button is clicked', async () => {
createComponent({
state: {
providerRepos: [providerRepo],
},
});
createComponent({ state: { providerRepos: [providerRepo] } });
findImportAllButton().vm.$emit('click');
await nextTick();
expect(eventHub.$emit).toHaveBeenCalledWith('importAll');
expect(importAllFn).toHaveBeenCalled();
});
it('shows loading spinner when import is in progress', () => {
createComponent({
getters: {
isImportingAnyRepo: () => true,
},
});
createComponent({ getters: { isImportingAnyRepo: () => true } });
expect(findImportAllButton().props().loading).toBe(true);
});
it('renders filtering input field by default', () => {
createComponent();
expect(findFilterField().exists()).toBe(true);
});
it('does not render filtering input field when filterable is false', () => {
createComponent({ filterable: false });
expect(findFilterField().exists()).toBe(false);
});
......
import Vuex from 'vuex';
import { createLocalVue, mount } from '@vue/test-utils';
import createStore from '~/import_projects/store';
import importedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue';
import STATUS_MAP from '~/import_projects/constants';
import { mount } from '@vue/test-utils';
import ImportedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue';
import ImportStatus from '~/import_projects/components/import_status.vue';
import { STATUSES } from '~/import_projects/constants';
describe('ImportedProjectTableRow', () => {
let vm;
let wrapper;
const project = {
importSource: {
fullName: 'fullName',
providerLink: 'providerLink',
},
importedProject: {
id: 1,
fullPath: 'fullPath',
importStatus: 'finished',
providerLink: 'providerLink',
importSource: 'importSource',
},
importStatus: STATUSES.FINISHED,
};
function mountComponent() {
const localVue = createLocalVue();
localVue.use(Vuex);
const component = mount(importedProjectTableRow, {
localVue,
store: createStore(),
propsData: {
project: {
...project,
},
},
});
return component.vm;
wrapper = mount(ImportedProjectTableRow, { propsData: { project } });
}
beforeEach(() => {
vm = mountComponent();
mountComponent();
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
it('renders an imported project table row', () => {
const providerLink = vm.$el.querySelector('.js-provider-link');
const statusObject = STATUS_MAP[project.importStatus];
expect(vm.$el.classList.contains('js-imported-project')).toBe(true);
expect(providerLink.href).toMatch(project.providerLink);
expect(providerLink.textContent).toMatch(project.importSource);
expect(vm.$el.querySelector('.js-full-path').textContent).toMatch(project.fullPath);
expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch(
statusObject.text,
const providerLink = wrapper.find('[data-testid=providerLink]');
expect(providerLink.attributes().href).toMatch(project.importSource.providerLink);
expect(providerLink.text()).toMatch(project.importSource.fullName);
expect(wrapper.find('[data-testid=fullPath]').text()).toMatch(project.importedProject.fullPath);
expect(wrapper.find(ImportStatus).props().status).toBe(project.importStatus);
expect(wrapper.find('[data-testid=goToProject').attributes().href).toMatch(
project.importedProject.fullPath,
);
expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull();
expect(vm.$el.querySelector('.js-go-to-project').href).toMatch(project.fullPath);
});
});
import { nextTick } from 'vue';
import Vuex from 'vuex';
import { createLocalVue, mount } from '@vue/test-utils';
import { state, actions, getters, mutations } from '~/import_projects/store';
import providerRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue';
import STATUS_MAP, { STATUSES } from '~/import_projects/constants';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue';
import ImportStatus from '~/import_projects/components/import_status.vue';
import { STATUSES } from '~/import_projects/constants';
import Select2Select from '~/vue_shared/components/select2_select.vue';
describe('ProviderRepoTableRow', () => {
let vm;
let wrapper;
const fetchImport = jest.fn();
const importPath = '/import-path';
const defaultTargetNamespace = 'user';
const ciCdOnly = true;
const setImportTarget = jest.fn();
const fakeImportTarget = {
targetNamespace: 'target',
newName: 'newName',
};
const ciCdOnly = false;
const repo = {
id: 10,
sanitizedName: 'sanitizedName',
importSource: {
id: 'remote-1',
fullName: 'fullName',
providerLink: 'providerLink',
},
importedProject: {
id: 1,
fullPath: 'fullPath',
importSource: 'importSource',
},
importStatus: STATUSES.FINISHED,
};
function initStore(initialState) {
const stubbedActions = { ...actions, fetchImport };
const availableNamespaces = [
{ text: 'Groups', children: [{ id: 'test', text: 'test' }] },
{ text: 'Users', children: [{ id: 'root', text: 'root' }] },
];
function initStore(initialState) {
const store = new Vuex.Store({
state: { ...state(), ...initialState },
actions: stubbedActions,
mutations,
getters,
state: initialState,
getters: {
getImportTarget: () => () => fakeImportTarget,
},
actions: { fetchImport, setImportTarget },
});
return store;
}
const findImportButton = () =>
wrapper
.findAll('button')
.filter(node => node.text() === 'Import')
.at(0);
function mountComponent(initialState) {
const localVue = createLocalVue();
localVue.use(Vuex);
const store = initStore({ importPath, defaultTargetNamespace, ciCdOnly, ...initialState });
const store = initStore({ ciCdOnly, ...initialState });
const component = mount(providerRepoTableRow, {
wrapper = shallowMount(ProviderRepoTableRow, {
localVue,
store,
propsData: {
repo,
},
propsData: { repo, availableNamespaces },
});
return component.vm;
}
beforeEach(() => {
vm = mountComponent();
mountComponent();
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
it('renders a provider repo table row', () => {
const providerLink = vm.$el.querySelector('.js-provider-link');
const statusObject = STATUS_MAP[STATUSES.NONE];
expect(vm.$el.classList.contains('js-provider-repo')).toBe(true);
expect(providerLink.href).toMatch(repo.providerLink);
expect(providerLink.textContent).toMatch(repo.fullName);
expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch(
statusObject.text,
);
expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull();
expect(vm.$el.querySelector('.js-import-button')).not.toBeNull();
const providerLink = wrapper.find('[data-testid=providerLink]');
expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink);
expect(providerLink.text()).toMatch(repo.importSource.fullName);
expect(wrapper.find(ImportStatus).props().status).toBe(repo.importStatus);
expect(wrapper.contains('button')).toBe(true);
});
it('renders a select2 namespace select', () => {
const dropdownTrigger = vm.$el.querySelector('.js-namespace-select');
expect(dropdownTrigger).not.toBeNull();
expect(dropdownTrigger.classList.contains('select2-container')).toBe(true);
dropdownTrigger.click();
expect(vm.$el.querySelector('.select2-drop')).not.toBeNull();
expect(wrapper.contains(Select2Select)).toBe(true);
expect(wrapper.find(Select2Select).props().options.data).toBe(availableNamespaces);
});
it('imports repo when clicking import button', () => {
vm.$el.querySelector('.js-import-button').click();
it('imports repo when clicking import button', async () => {
findImportButton().trigger('click');
await nextTick();
return vm.$nextTick().then(() => {
const { calls } = fetchImport.mock;
// Not using .toBeCalledWith because it expects
// an unmatchable and undefined 3rd argument.
expect(calls.length).toBe(1);
expect(calls[0][1]).toEqual({
repo,
newName: repo.sanitizedName,
targetNamespace: defaultTargetNamespace,
});
});
expect(calls).toHaveLength(1);
expect(calls[0][1]).toBe(repo.importSource.id);
});
});
......@@ -12,41 +12,76 @@ import {
RECEIVE_IMPORT_SUCCESS,
RECEIVE_IMPORT_ERROR,
RECEIVE_JOBS_SUCCESS,
REQUEST_NAMESPACES,
RECEIVE_NAMESPACES_SUCCESS,
RECEIVE_NAMESPACES_ERROR,
} from '~/import_projects/store/mutation_types';
import {
fetchRepos,
fetchImport,
receiveJobsSuccess,
fetchJobs,
clearJobsEtagPoll,
stopJobsPolling,
} from '~/import_projects/store/actions';
import actionsFactory from '~/import_projects/store/actions';
import { getImportTarget } from '~/import_projects/store/getters';
import state from '~/import_projects/store/state';
import { STATUSES } from '~/import_projects/constants';
jest.mock('~/flash');
const MOCK_ENDPOINT = `${TEST_HOST}/endpoint.json`;
const {
clearJobsEtagPoll,
stopJobsPolling,
importAll,
fetchRepos,
fetchImport,
fetchJobs,
fetchNamespaces,
} = actionsFactory({
endpoints: {
reposPath: MOCK_ENDPOINT,
importPath: MOCK_ENDPOINT,
jobsPath: MOCK_ENDPOINT,
namespacesPath: MOCK_ENDPOINT,
},
});
describe('import_projects store actions', () => {
let localState;
const repos = [{ id: 1 }, { id: 2 }];
const importPayload = { newName: 'newName', targetNamespace: 'targetNamespace', repo: { id: 1 } };
const importRepoId = 1;
const otherImportRepoId = 2;
const defaultTargetNamespace = 'default';
const sanitizedName = 'sanitizedName';
const defaultImportTarget = { newName: sanitizedName, targetNamespace: defaultTargetNamespace };
beforeEach(() => {
localState = state();
localState = {
...state(),
defaultTargetNamespace,
repositories: [
{ importSource: { id: importRepoId, sanitizedName }, importStatus: STATUSES.NONE },
{
importSource: { id: otherImportRepoId, sanitizedName: 's2' },
importStatus: STATUSES.NONE,
},
{
importSource: { id: 3, sanitizedName: 's3', incompatible: true },
importStatus: STATUSES.NONE,
},
],
};
localState.getImportTarget = getImportTarget(localState);
});
describe('fetchRepos', () => {
let mock;
const payload = { imported_projects: [{}], provider_repos: [{}], namespaces: [{}] };
const payload = { imported_projects: [{}], provider_repos: [{}] };
beforeEach(() => {
localState.reposPath = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
});
afterEach(() => mock.restore());
it('dispatches stopJobsPolling actions and commits REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, payload);
mock.onGet(MOCK_ENDPOINT).reply(200, payload);
return testAction(
fetchRepos,
......@@ -64,7 +99,7 @@ describe('import_projects store actions', () => {
});
it('dispatches stopJobsPolling action and commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
mock.onGet(MOCK_ENDPOINT).reply(500);
return testAction(
fetchRepos,
......@@ -104,7 +139,6 @@ describe('import_projects store actions', () => {
let mock;
beforeEach(() => {
localState.importPath = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
});
......@@ -112,15 +146,17 @@ describe('import_projects store actions', () => {
it('commits REQUEST_IMPORT and REQUEST_IMPORT_SUCCESS mutations on a successful request', () => {
const importedProject = { name: 'imported/project' };
const importRepoId = importPayload.repo.id;
mock.onPost(`${TEST_HOST}/endpoint.json`).reply(200, importedProject);
mock.onPost(MOCK_ENDPOINT).reply(200, importedProject);
return testAction(
fetchImport,
importPayload,
importRepoId,
localState,
[
{ type: REQUEST_IMPORT, payload: importRepoId },
{
type: REQUEST_IMPORT,
payload: { repoId: importRepoId, importTarget: defaultImportTarget },
},
{
type: RECEIVE_IMPORT_SUCCESS,
payload: {
......@@ -134,15 +170,18 @@ describe('import_projects store actions', () => {
});
it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR and shows generic error message on an unsuccessful request', async () => {
mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500);
mock.onPost(MOCK_ENDPOINT).reply(500);
await testAction(
fetchImport,
importPayload,
importRepoId,
localState,
[
{ type: REQUEST_IMPORT, payload: importPayload.repo.id },
{ type: RECEIVE_IMPORT_ERROR, payload: importPayload.repo.id },
{
type: REQUEST_IMPORT,
payload: { repoId: importRepoId, importTarget: defaultImportTarget },
},
{ type: RECEIVE_IMPORT_ERROR, payload: importRepoId },
],
[],
);
......@@ -152,15 +191,18 @@ describe('import_projects store actions', () => {
it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR and shows detailed error message on an unsuccessful request with errors fields in response', async () => {
const ERROR_MESSAGE = 'dummy';
mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500, { errors: ERROR_MESSAGE });
mock.onPost(MOCK_ENDPOINT).reply(500, { errors: ERROR_MESSAGE });
await testAction(
fetchImport,
importPayload,
importRepoId,
localState,
[
{ type: REQUEST_IMPORT, payload: importPayload.repo.id },
{ type: RECEIVE_IMPORT_ERROR, payload: importPayload.repo.id },
{
type: REQUEST_IMPORT,
payload: { repoId: importRepoId, importTarget: defaultImportTarget },
},
{ type: RECEIVE_IMPORT_ERROR, payload: importRepoId },
],
[],
);
......@@ -169,24 +211,11 @@ describe('import_projects store actions', () => {
});
});
describe('receiveJobsSuccess', () => {
it(`commits ${RECEIVE_JOBS_SUCCESS} mutation`, () => {
return testAction(
receiveJobsSuccess,
repos,
localState,
[{ type: RECEIVE_JOBS_SUCCESS, payload: repos }],
[],
);
});
});
describe('fetchJobs', () => {
let mock;
const updatedProjects = [{ name: 'imported/project' }, { name: 'provider/repo' }];
beforeEach(() => {
localState.jobsPath = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
});
......@@ -198,7 +227,7 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore());
it('commits RECEIVE_JOBS_SUCCESS mutation on a successful request', async () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, updatedProjects);
mock.onGet(MOCK_ENDPOINT).reply(200, updatedProjects);
await testAction(
fetchJobs,
......@@ -237,4 +266,62 @@ describe('import_projects store actions', () => {
});
});
});
describe('fetchNamespaces', () => {
let mock;
const namespaces = [{ full_name: 'test/ns1' }, { full_name: 'test_ns2' }];
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => mock.restore());
it('commits REQUEST_NAMESPACES and RECEIVE_NAMESPACES_SUCCESS on success', async () => {
mock.onGet(MOCK_ENDPOINT).reply(200, namespaces);
await testAction(
fetchNamespaces,
null,
localState,
[
{ type: REQUEST_NAMESPACES },
{
type: RECEIVE_NAMESPACES_SUCCESS,
payload: convertObjectPropsToCamelCase(namespaces, { deep: true }),
},
],
[],
);
});
it('commits REQUEST_NAMESPACES and RECEIVE_NAMESPACES_ERROR and shows generic error message on an unsuccessful request', async () => {
mock.onGet(MOCK_ENDPOINT).reply(500);
await testAction(
fetchNamespaces,
null,
localState,
[{ type: REQUEST_NAMESPACES }, { type: RECEIVE_NAMESPACES_ERROR }],
[],
);
expect(createFlash).toHaveBeenCalledWith('Requesting namespaces failed');
});
});
describe('importAll', () => {
it('dispatches multiple fetchImport actions', async () => {
await testAction(
importAll,
null,
localState,
[],
[
{ type: 'fetchImport', payload: importRepoId },
{ type: 'fetchImport', payload: otherImportRepoId },
],
);
});
});
});
import {
namespaceSelectOptions,
isLoading,
isImportingAnyRepo,
hasProviderRepos,
hasIncompatibleRepos,
hasImportedProjects,
hasImportableRepos,
getImportTarget,
} from '~/import_projects/store/getters';
import { STATUSES } from '~/import_projects/constants';
import state from '~/import_projects/store/state';
const IMPORTED_REPO = {
importSource: {},
importedProject: { fullPath: 'some/path' },
};
const IMPORTABLE_REPO = {
importSource: { id: 'some-id', sanitizedName: 'sanitized' },
importedProject: null,
importStatus: STATUSES.NONE,
};
const INCOMPATIBLE_REPO = {
importSource: { incompatible: true },
};
describe('import_projects store getters', () => {
let localState;
......@@ -14,85 +30,87 @@ describe('import_projects store getters', () => {
localState = state();
});
describe('namespaceSelectOptions', () => {
const namespaces = [{ fullPath: 'namespace-0' }, { fullPath: 'namespace-1' }];
const defaultTargetNamespace = 'current-user';
it('returns an options array with a "Users" and "Groups" optgroups', () => {
localState.namespaces = namespaces;
localState.defaultTargetNamespace = defaultTargetNamespace;
const optionsArray = namespaceSelectOptions(localState);
const groupsGroup = optionsArray[0];
const usersGroup = optionsArray[1];
expect(groupsGroup.text).toBe('Groups');
expect(usersGroup.text).toBe('Users');
groupsGroup.children.forEach((child, index) => {
expect(child.id).toBe(namespaces[index].fullPath);
expect(child.text).toBe(namespaces[index].fullPath);
});
expect(usersGroup.children.length).toBe(1);
expect(usersGroup.children[0].id).toBe(defaultTargetNamespace);
expect(usersGroup.children[0].text).toBe(defaultTargetNamespace);
});
});
it.each`
isLoadingRepos | isLoadingNamespaces | isLoadingValue
${false} | ${false} | ${false}
${true} | ${false} | ${true}
${false} | ${true} | ${true}
${true} | ${true} | ${true}
`(
'isLoading returns $isLoadingValue when isLoadingRepos is $isLoadingRepos and isLoadingNamespaces is $isLoadingNamespaces',
({ isLoadingRepos, isLoadingNamespaces, isLoadingValue }) => {
Object.assign(localState, {
isLoadingRepos,
isLoadingNamespaces,
});
expect(isLoading(localState)).toBe(isLoadingValue);
},
);
it.each`
importStatus | value
${STATUSES.NONE} | ${false}
${STATUSES.SCHEDULING} | ${true}
${STATUSES.SCHEDULED} | ${true}
${STATUSES.STARTED} | ${true}
${STATUSES.FINISHED} | ${false}
`(
'isImportingAnyRepo returns $value when repo with $importStatus status is available',
({ importStatus, value }) => {
localState.repositories = [{ importStatus }];
expect(isImportingAnyRepo(localState)).toBe(value);
},
);
describe('isImportingAnyRepo', () => {
it('returns true if there are any reposBeingImported', () => {
localState.reposBeingImported = new Array(1);
describe('hasIncompatibleRepos', () => {
it('returns true if there are any incompatible projects', () => {
localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO, INCOMPATIBLE_REPO];
expect(isImportingAnyRepo(localState)).toBe(true);
expect(hasIncompatibleRepos(localState)).toBe(true);
});
it('returns false if there are no reposBeingImported', () => {
localState.reposBeingImported = [];
it('returns false if there are no incompatible projects', () => {
localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO];
expect(isImportingAnyRepo(localState)).toBe(false);
expect(hasIncompatibleRepos(localState)).toBe(false);
});
});
describe('hasProviderRepos', () => {
it('returns true if there are any providerRepos', () => {
localState.providerRepos = new Array(1);
describe('hasImportableRepos', () => {
it('returns true if there are any importable projects ', () => {
localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO, INCOMPATIBLE_REPO];
expect(hasProviderRepos(localState)).toBe(true);
expect(hasImportableRepos(localState)).toBe(true);
});
it('returns false if there are no providerRepos', () => {
localState.providerRepos = [];
it('returns false if there are no importable projects', () => {
localState.repositories = [IMPORTED_REPO, INCOMPATIBLE_REPO];
expect(hasProviderRepos(localState)).toBe(false);
});
expect(hasImportableRepos(localState)).toBe(false);
});
describe('hasImportedProjects', () => {
it('returns true if there are any importedProjects', () => {
localState.importedProjects = new Array(1);
expect(hasImportedProjects(localState)).toBe(true);
});
it('returns false if there are no importedProjects', () => {
localState.importedProjects = [];
describe('getImportTarget', () => {
it('returns default value if no custom target available', () => {
localState.defaultTargetNamespace = 'default';
localState.repositories = [IMPORTABLE_REPO];
expect(hasImportedProjects(localState)).toBe(false);
expect(getImportTarget(localState)(IMPORTABLE_REPO.importSource.id)).toStrictEqual({
newName: IMPORTABLE_REPO.importSource.sanitizedName,
targetNamespace: localState.defaultTargetNamespace,
});
});
describe('hasIncompatibleRepos', () => {
it('returns true if there are any incompatibleProjects', () => {
localState.incompatibleRepos = new Array(1);
it('returns custom import target if available', () => {
const fakeTarget = { newName: 'something', targetNamespace: 'ns' };
localState.repositories = [IMPORTABLE_REPO];
localState.customImportTargets[IMPORTABLE_REPO.importSource.id] = fakeTarget;
expect(hasIncompatibleRepos(localState)).toBe(true);
});
it('returns false if there are no incompatibleProjects', () => {
localState.incompatibleRepos = [];
expect(hasIncompatibleRepos(localState)).toBe(false);
expect(getImportTarget(localState)(IMPORTABLE_REPO.importSource.id)).toStrictEqual(
fakeTarget,
);
});
});
});
import * as types from '~/import_projects/store/mutation_types';
import mutations from '~/import_projects/store/mutations';
import { STATUSES } from '~/import_projects/constants';
describe('import_projects store mutations', () => {
describe(`${types.RECEIVE_IMPORT_SUCCESS}`, () => {
it('removes repoId from reposBeingImported and providerRepos, adds to importedProjects', () => {
const repoId = 1;
const state = {
reposBeingImported: [repoId],
providerRepos: [{ id: repoId }],
let state;
const SOURCE_PROJECT = {
id: 1,
full_name: 'full/name',
sanitized_name: 'name',
provider_link: 'https://demo.link/full/name',
};
const IMPORTED_PROJECT = {
name: 'demo',
importSource: 'something',
providerLink: 'custom-link',
importStatus: 'status',
fullName: 'fullName',
};
describe(`${types.SET_FILTER}`, () => {
it('overwrites current filter value', () => {
state = { filter: 'some-value' };
const NEW_VALUE = 'new-value';
mutations[types.SET_FILTER](state, NEW_VALUE);
expect(state.filter).toBe(NEW_VALUE);
});
});
describe(`${types.REQUEST_REPOS}`, () => {
it('sets repos loading flag to true', () => {
state = {};
mutations[types.REQUEST_REPOS](state);
expect(state.isLoadingRepos).toBe(true);
});
});
describe(`${types.RECEIVE_REPOS_SUCCESS}`, () => {
describe('for imported projects', () => {
const response = {
importedProjects: [IMPORTED_PROJECT],
providerRepos: [],
};
it('picks import status from response', () => {
state = {};
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
expect(state.repositories[0].importStatus).toBe(IMPORTED_PROJECT.importStatus);
});
it('recreates importSource from response', () => {
state = {};
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
expect(state.repositories[0].importSource).toStrictEqual(
expect.objectContaining({
fullName: IMPORTED_PROJECT.importSource,
sanitizedName: IMPORTED_PROJECT.name,
providerLink: IMPORTED_PROJECT.providerLink,
}),
);
});
it('passes project to importProject', () => {
state = {};
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
expect(IMPORTED_PROJECT).toStrictEqual(
expect.objectContaining(state.repositories[0].importedProject),
);
});
});
describe('for importable projects', () => {
beforeEach(() => {
state = {};
const response = {
importedProjects: [],
providerRepos: [SOURCE_PROJECT],
};
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
});
it('sets import status to none', () => {
expect(state.repositories[0].importStatus).toBe(STATUSES.NONE);
});
it('sets importSource to project', () => {
expect(state.repositories[0].importSource).toBe(SOURCE_PROJECT);
});
});
describe('for incompatible projects', () => {
const response = {
importedProjects: [],
providerRepos: [],
incompatibleRepos: [SOURCE_PROJECT],
};
const importedProject = { id: repoId };
mutations[types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId });
beforeEach(() => {
state = {};
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
});
it('sets incompatible flag', () => {
expect(state.repositories[0].importSource.incompatible).toBe(true);
});
it('sets importSource to project', () => {
expect(state.repositories[0].importSource).toStrictEqual(
expect.objectContaining(SOURCE_PROJECT),
);
});
});
it('sets repos loading flag to false', () => {
const response = {
importedProjects: [],
providerRepos: [],
};
state = {};
mutations[types.RECEIVE_REPOS_SUCCESS](state, response);
expect(state.isLoadingRepos).toBe(false);
});
});
describe(`${types.RECEIVE_REPOS_ERROR}`, () => {
it('sets repos loading flag to false', () => {
state = {};
mutations[types.RECEIVE_REPOS_ERROR](state);
expect(state.reposBeingImported.includes(repoId)).toBe(false);
expect(state.providerRepos.some(repo => repo.id === repoId)).toBe(false);
expect(state.importedProjects.some(repo => repo.id === repoId)).toBe(true);
expect(state.isLoadingRepos).toBe(false);
});
});
describe(`${types.REQUEST_IMPORT}`, () => {
beforeEach(() => {
const REPO_ID = 1;
const importTarget = { targetNamespace: 'ns', newName: 'name ' };
state = { repositories: [{ importSource: { id: REPO_ID } }] };
mutations[types.REQUEST_IMPORT](state, { repoId: REPO_ID, importTarget });
});
it(`sets status to ${STATUSES.SCHEDULING}`, () => {
expect(state.repositories[0].importStatus).toBe(STATUSES.SCHEDULING);
});
});
describe(`${types.RECEIVE_IMPORT_SUCCESS}`, () => {
beforeEach(() => {
const REPO_ID = 1;
state = { repositories: [{ importSource: { id: REPO_ID } }] };
mutations[types.RECEIVE_IMPORT_SUCCESS](state, {
repoId: REPO_ID,
importedProject: IMPORTED_PROJECT,
});
});
it('sets import status', () => {
expect(state.repositories[0].importStatus).toBe(IMPORTED_PROJECT.importStatus);
});
it('sets imported project', () => {
expect(IMPORTED_PROJECT).toStrictEqual(
expect.objectContaining(state.repositories[0].importedProject),
);
});
});
describe(`${types.RECEIVE_IMPORT_ERROR}`, () => {
beforeEach(() => {
const REPO_ID = 1;
state = { repositories: [{ importSource: { id: REPO_ID } }] };
mutations[types.RECEIVE_IMPORT_ERROR](state, REPO_ID);
});
it(`resets import status to ${STATUSES.NONE}`, () => {
expect(state.repositories[0].importStatus).toBe(STATUSES.NONE);
});
});
describe(`${types.RECEIVE_JOBS_SUCCESS}`, () => {
it('updates importStatus of existing importedProjects', () => {
it('updates import status of existing project', () => {
const repoId = 1;
const state = { importedProjects: [{ id: repoId, importStatus: 'started' }] };
const updatedProjects = [{ id: repoId, importStatus: 'finished' }];
state = {
repositories: [{ importedProject: { id: repoId }, importStatus: STATUSES.STARTED }],
};
const updatedProjects = [{ id: repoId, importStatus: STATUSES.FINISHED }];
mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects);
expect(state.importedProjects[0].importStatus).toBe(updatedProjects[0].importStatus);
expect(state.repositories[0].importStatus).toBe(updatedProjects[0].importStatus);
});
});
describe(`${types.REQUEST_NAMESPACES}`, () => {
it('sets namespaces loading flag to true', () => {
state = {};
mutations[types.REQUEST_NAMESPACES](state);
expect(state.isLoadingNamespaces).toBe(true);
});
});
describe(`${types.RECEIVE_NAMESPACES_SUCCESS}`, () => {
const response = [{ fullPath: 'some/path' }];
beforeEach(() => {
state = {};
mutations[types.RECEIVE_NAMESPACES_SUCCESS](state, response);
});
it('stores namespaces to state', () => {
expect(state.namespaces).toStrictEqual(response);
});
it('sets namespaces loading flag to false', () => {
expect(state.isLoadingNamespaces).toBe(false);
});
});
describe(`${types.RECEIVE_NAMESPACES_ERROR}`, () => {
it('sets namespaces loading flag to false', () => {
state = {};
mutations[types.RECEIVE_NAMESPACES_ERROR](state);
expect(state.isLoadingNamespaces).toBe(false);
});
});
describe(`${types.SET_IMPORT_TARGET}`, () => {
const PROJECT = {
id: 2,
sanitizedName: 'sanitizedName',
};
it('stores custom target if it differs from defaults', () => {
state = { customImportTargets: {}, repositories: [{ importSource: PROJECT }] };
const importTarget = { targetNamespace: 'ns', newName: 'name ' };
mutations[types.SET_IMPORT_TARGET](state, { repoId: PROJECT.id, importTarget });
expect(state.customImportTargets[PROJECT.id]).toBe(importTarget);
});
it('removes custom target if it is equal to defaults', () => {
const importTarget = { targetNamespace: 'ns', newName: 'name ' };
state = {
defaultTargetNamespace: 'default',
customImportTargets: {
[PROJECT.id]: importTarget,
},
repositories: [{ importSource: PROJECT }],
};
mutations[types.SET_IMPORT_TARGET](state, {
repoId: PROJECT.id,
importTarget: {
targetNamespace: state.defaultTargetNamespace,
newName: PROJECT.sanitizedName,
},
});
expect(state.customImportTargets[SOURCE_PROJECT.id]).toBeUndefined();
});
});
});
import { isProjectImportable } from '~/import_projects/utils';
import { STATUSES } from '~/import_projects/constants';
describe('import_projects utils', () => {
describe('isProjectImportable', () => {
it.each`
status | result
${STATUSES.FINISHED} | ${false}
${STATUSES.FAILED} | ${false}
${STATUSES.SCHEDULED} | ${false}
${STATUSES.STARTED} | ${false}
${STATUSES.NONE} | ${true}
${STATUSES.SCHEDULING} | ${false}
`('returns $result when project is compatible and status is $status', ({ status, result }) => {
expect(
isProjectImportable({
importStatus: status,
importSource: { incompatible: false },
}),
).toBe(result);
});
it('returns false if project is not compatible', () => {
expect(
isProjectImportable({
importStatus: STATUSES.NONE,
importSource: { incompatible: true },
}),
).toBe(false);
});
});
});
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