Improve the GitHub and Gitea import feature table interface
These are frontend changes. Use Vue for the import feature UI for "githubish" providers (GitHub and Gitea). Add "Go to project" button after a successful import. Use CI-style status icons and improve spacing of the table and its component. Adds ETag polling to the github and gitea import jobs endpoint.
This commit is contained in:
parent
534a61179e
commit
af989df0ec
31 changed files with 1486 additions and 90 deletions
|
@ -0,0 +1,101 @@
|
|||
<script>
|
||||
import { mapActions, mapState, mapGetters } from 'vuex';
|
||||
import { GlLoadingIcon } from '@gitlab/ui';
|
||||
import LoadingButton from '~/vue_shared/components/loading_button.vue';
|
||||
import { __, sprintf } from '~/locale';
|
||||
import ImportedProjectTableRow from './imported_project_table_row.vue';
|
||||
import ProviderRepoTableRow from './provider_repo_table_row.vue';
|
||||
import eventHub from '../event_hub';
|
||||
|
||||
export default {
|
||||
name: 'ImportProjectsTable',
|
||||
components: {
|
||||
ImportedProjectTableRow,
|
||||
ProviderRepoTableRow,
|
||||
LoadingButton,
|
||||
GlLoadingIcon,
|
||||
},
|
||||
props: {
|
||||
providerTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos']),
|
||||
...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']),
|
||||
|
||||
emptyStateText() {
|
||||
return sprintf(__('No %{providerTitle} repositories available to import'), {
|
||||
providerTitle: this.providerTitle,
|
||||
});
|
||||
},
|
||||
|
||||
fromHeaderText() {
|
||||
return sprintf(__('From %{providerTitle}'), { providerTitle: this.providerTitle });
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
return this.fetchRepos();
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.stopJobsPolling();
|
||||
this.clearJobsEtagPoll();
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions(['fetchRepos', 'fetchJobs', 'stopJobsPolling', 'clearJobsEtagPoll']),
|
||||
|
||||
importAll() {
|
||||
eventHub.$emit('importAll');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-end flex-wrap mb-3">
|
||||
<p class="light text-nowrap mt-2 my-sm-0">
|
||||
{{ s__('ImportProjects|Select the projects you want to import') }}
|
||||
</p>
|
||||
<loading-button
|
||||
container-class="btn btn-success js-import-all"
|
||||
:loading="isImportingAnyRepo"
|
||||
:label="__('Import all repositories')"
|
||||
:disabled="!hasProviderRepos"
|
||||
type="button"
|
||||
@click="importAll"
|
||||
/>
|
||||
</div>
|
||||
<gl-loading-icon
|
||||
v-if="isLoadingRepos"
|
||||
class="js-loading-button-icon import-projects-loading-icon"
|
||||
:size="4"
|
||||
/>
|
||||
<div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive">
|
||||
<table class="table import-table">
|
||||
<thead>
|
||||
<th class="import-jobs-from-col">{{ fromHeaderText }}</th>
|
||||
<th class="import-jobs-to-col">{{ __('To GitLab') }}</th>
|
||||
<th class="import-jobs-status-col">{{ __('Status') }}</th>
|
||||
<th class="import-jobs-cta-col"></th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<imported-project-table-row
|
||||
v-for="project in importedProjects"
|
||||
:key="project.id"
|
||||
:project="project"
|
||||
/>
|
||||
<provider-repo-table-row v-for="repo in providerRepos" :key="repo.id" :repo="repo" />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
<strong>{{ emptyStateText }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,47 @@
|
|||
<script>
|
||||
import { GlLoadingIcon } from '@gitlab/ui';
|
||||
import CiIcon from '~/vue_shared/components/ci_icon.vue';
|
||||
import STATUS_MAP from '../constants';
|
||||
|
||||
export default {
|
||||
name: 'ImportStatus',
|
||||
components: {
|
||||
CiIcon,
|
||||
GlLoadingIcon,
|
||||
},
|
||||
props: {
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
mappedStatus() {
|
||||
return STATUS_MAP[this.status];
|
||||
},
|
||||
|
||||
ciIconStatus() {
|
||||
const { icon } = this.mappedStatus;
|
||||
|
||||
return {
|
||||
icon: `status_${icon}`,
|
||||
group: icon,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<gl-loading-icon
|
||||
v-if="mappedStatus.loadingIcon"
|
||||
:inline="true"
|
||||
:class="mappedStatus.textClass"
|
||||
class="align-middle mr-2"
|
||||
/>
|
||||
<ci-icon v-else css-classes="align-middle mr-2" :status="ciIconStatus" />
|
||||
<span :class="mappedStatus.textClass">{{ mappedStatus.text }}</span>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,55 @@
|
|||
<script>
|
||||
import ImportStatus from './import_status.vue';
|
||||
import { STATUSES } from '../constants';
|
||||
|
||||
export default {
|
||||
name: 'ImportedProjectTableRow',
|
||||
components: {
|
||||
ImportStatus,
|
||||
},
|
||||
props: {
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
displayFullPath() {
|
||||
return this.project.fullPath.replace(/^\//, '');
|
||||
},
|
||||
|
||||
isFinished() {
|
||||
return this.project.importStatus === STATUSES.FINISHED;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr class="js-imported-project import-row">
|
||||
<td>
|
||||
<a
|
||||
:href="project.providerLink"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
class="js-provider-link"
|
||||
>
|
||||
{{ project.importSource }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="js-full-path">{{ 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"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{{ __('Go to project') }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
|
@ -0,0 +1,110 @@
|
|||
<script>
|
||||
import { mapState, mapGetters, mapActions } from 'vuex';
|
||||
import Select2Select from '~/vue_shared/components/select2_select.vue';
|
||||
import { __ } from '~/locale';
|
||||
import LoadingButton from '~/vue_shared/components/loading_button.vue';
|
||||
import eventHub from '../event_hub';
|
||||
import { STATUSES } from '../constants';
|
||||
import ImportStatus from './import_status.vue';
|
||||
|
||||
export default {
|
||||
name: 'ProviderRepoTableRow',
|
||||
components: {
|
||||
Select2Select,
|
||||
LoadingButton,
|
||||
ImportStatus,
|
||||
},
|
||||
props: {
|
||||
repo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
targetNamespace: this.$store.state.defaultTargetNamespace,
|
||||
newName: this.repo.sanitizedName,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['namespaces', 'reposBeingImported', 'ciCdOnly']),
|
||||
|
||||
...mapGetters(['namespaceSelectOptions']),
|
||||
|
||||
importButtonText() {
|
||||
return this.ciCdOnly ? __('Connect') : __('Import');
|
||||
},
|
||||
|
||||
select2Options() {
|
||||
return {
|
||||
data: this.namespaceSelectOptions,
|
||||
containerCssClass:
|
||||
'import-namespace-select js-namespace-select qa-project-namespace-select',
|
||||
};
|
||||
},
|
||||
|
||||
isLoadingImport() {
|
||||
return this.reposBeingImported.includes(this.repo.id);
|
||||
},
|
||||
|
||||
status() {
|
||||
return this.isLoadingImport ? STATUSES.SCHEDULING : STATUSES.NONE;
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
eventHub.$on('importAll', () => this.importRepo());
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions(['fetchImport']),
|
||||
|
||||
importRepo() {
|
||||
return this.fetchImport({
|
||||
newName: this.newName,
|
||||
targetNamespace: this.targetNamespace,
|
||||
repo: this.repo,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr class="qa-project-import-row js-provider-repo import-row">
|
||||
<td>
|
||||
<a
|
||||
:href="repo.providerLink"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
class="js-provider-link"
|
||||
>
|
||||
{{ repo.fullName }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="d-flex flex-wrap flex-lg-nowrap">
|
||||
<select2-select v-model="targetNamespace" :options="select2Options" />
|
||||
<span class="px-2 import-slash-divider d-flex justify-content-center align-items-center"
|
||||
>/</span
|
||||
>
|
||||
<input
|
||||
v-model="newName"
|
||||
type="text"
|
||||
class="form-control import-project-name-input js-new-name qa-project-path-field"
|
||||
/>
|
||||
</td>
|
||||
<td><import-status :status="status" /></td>
|
||||
<td>
|
||||
<button
|
||||
v-if="!isLoadingImport"
|
||||
type="button"
|
||||
class="qa-import-button js-import-button btn btn-default"
|
||||
@click="importRepo"
|
||||
>
|
||||
{{ importButtonText }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
48
app/assets/javascripts/import_projects/constants.js
Normal file
48
app/assets/javascripts/import_projects/constants.js
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { __ } from '../locale';
|
||||
|
||||
// The `scheduling` status is only present on the client-side,
|
||||
// it is used as the status when we are requesting to start an import.
|
||||
|
||||
export const STATUSES = {
|
||||
FINISHED: 'finished',
|
||||
FAILED: 'failed',
|
||||
SCHEDULED: 'scheduled',
|
||||
STARTED: 'started',
|
||||
NONE: 'none',
|
||||
SCHEDULING: 'scheduling',
|
||||
};
|
||||
|
||||
const STATUS_MAP = {
|
||||
[STATUSES.FINISHED]: {
|
||||
icon: 'success',
|
||||
text: __('Done'),
|
||||
textClass: 'text-success',
|
||||
},
|
||||
[STATUSES.FAILED]: {
|
||||
icon: 'failed',
|
||||
text: __('Failed'),
|
||||
textClass: 'text-danger',
|
||||
},
|
||||
[STATUSES.SCHEDULED]: {
|
||||
icon: 'pending',
|
||||
text: __('Scheduled'),
|
||||
textClass: 'text-warning',
|
||||
},
|
||||
[STATUSES.STARTED]: {
|
||||
icon: 'running',
|
||||
text: __('Running…'),
|
||||
textClass: 'text-info',
|
||||
},
|
||||
[STATUSES.NONE]: {
|
||||
icon: 'created',
|
||||
text: __('Not started'),
|
||||
textClass: 'text-muted',
|
||||
},
|
||||
[STATUSES.SCHEDULING]: {
|
||||
loadingIcon: true,
|
||||
text: __('Scheduling'),
|
||||
textClass: 'text-warning',
|
||||
},
|
||||
};
|
||||
|
||||
export default STATUS_MAP;
|
3
app/assets/javascripts/import_projects/event_hub.js
Normal file
3
app/assets/javascripts/import_projects/event_hub.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
export default new Vue();
|
47
app/assets/javascripts/import_projects/index.js
Normal file
47
app/assets/javascripts/import_projects/index.js
Normal file
|
@ -0,0 +1,47 @@
|
|||
import Vue from 'vue';
|
||||
import { mapActions } from 'vuex';
|
||||
import Translate from '../vue_shared/translate';
|
||||
import ImportProjectsTable from './components/import_projects_table.vue';
|
||||
import { parseBoolean } from '../lib/utils/common_utils';
|
||||
import store from './store';
|
||||
|
||||
Vue.use(Translate);
|
||||
|
||||
export default function mountImportProjectsTable(mountElement) {
|
||||
if (!mountElement) return undefined;
|
||||
|
||||
const {
|
||||
reposPath,
|
||||
provider,
|
||||
providerTitle,
|
||||
canSelectNamespace,
|
||||
jobsPath,
|
||||
importPath,
|
||||
ciCdOnly,
|
||||
} = mountElement.dataset;
|
||||
|
||||
return new Vue({
|
||||
el: mountElement,
|
||||
store,
|
||||
|
||||
created() {
|
||||
this.setInitialData({
|
||||
reposPath,
|
||||
provider,
|
||||
jobsPath,
|
||||
importPath,
|
||||
defaultTargetNamespace: gon.current_username,
|
||||
ciCdOnly: parseBoolean(ciCdOnly),
|
||||
canSelectNamespace: parseBoolean(canSelectNamespace),
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions(['setInitialData']),
|
||||
},
|
||||
|
||||
render(createElement) {
|
||||
return createElement(ImportProjectsTable, { props: { providerTitle } });
|
||||
},
|
||||
});
|
||||
}
|
106
app/assets/javascripts/import_projects/store/actions.js
Normal file
106
app/assets/javascripts/import_projects/store/actions.js
Normal file
|
@ -0,0 +1,106 @@
|
|||
import Visibility from 'visibilityjs';
|
||||
import * as types from './mutation_types';
|
||||
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
import Poll from '~/lib/utils/poll';
|
||||
import createFlash from '~/flash';
|
||||
import { s__, sprintf } from '~/locale';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
let eTagPoll;
|
||||
|
||||
export const clearJobsEtagPoll = () => {
|
||||
eTagPoll = null;
|
||||
};
|
||||
export const stopJobsPolling = () => {
|
||||
if (eTagPoll) eTagPoll.stop();
|
||||
};
|
||||
export const restartJobsPolling = () => {
|
||||
if (eTagPoll) eTagPoll.restart();
|
||||
};
|
||||
|
||||
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
|
||||
|
||||
export const requestRepos = ({ commit }, repos) => commit(types.REQUEST_REPOS, repos);
|
||||
export const receiveReposSuccess = ({ commit }, repos) =>
|
||||
commit(types.RECEIVE_REPOS_SUCCESS, repos);
|
||||
export const receiveReposError = ({ commit }) => commit(types.RECEIVE_REPOS_ERROR);
|
||||
export const fetchRepos = ({ state, dispatch }) => {
|
||||
dispatch('requestRepos');
|
||||
|
||||
return axios
|
||||
.get(state.reposPath)
|
||||
.then(({ data }) =>
|
||||
dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
|
||||
)
|
||||
.then(() => dispatch('fetchJobs'))
|
||||
.catch(() => {
|
||||
createFlash(
|
||||
sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), {
|
||||
provider: state.provider,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch('receiveReposError');
|
||||
});
|
||||
};
|
||||
|
||||
export const requestImport = ({ commit, state }, repoId) => {
|
||||
if (!state.reposBeingImported.includes(repoId)) commit(types.REQUEST_IMPORT, repoId);
|
||||
};
|
||||
export const receiveImportSuccess = ({ commit }, { importedProject, repoId }) =>
|
||||
commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject, repoId });
|
||||
export const receiveImportError = ({ commit }, repoId) =>
|
||||
commit(types.RECEIVE_IMPORT_ERROR, repoId);
|
||||
export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, repo }) => {
|
||||
dispatch('requestImport', repo.id);
|
||||
|
||||
return axios
|
||||
.post(state.importPath, {
|
||||
ci_cd_only: state.ciCdOnly,
|
||||
new_name: newName,
|
||||
repo_id: repo.id,
|
||||
target_namespace: targetNamespace,
|
||||
})
|
||||
.then(({ data }) =>
|
||||
dispatch('receiveImportSuccess', {
|
||||
importedProject: convertObjectPropsToCamelCase(data, { deep: true }),
|
||||
repoId: repo.id,
|
||||
}),
|
||||
)
|
||||
.catch(() => {
|
||||
createFlash(s__('ImportProjects|Importing the project failed'));
|
||||
|
||||
dispatch('receiveImportError', { repoId: repo.id });
|
||||
});
|
||||
};
|
||||
|
||||
export const receiveJobsSuccess = ({ commit }, updatedProjects) =>
|
||||
commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects);
|
||||
export const fetchJobs = ({ state, dispatch }) => {
|
||||
if (eTagPoll) return;
|
||||
|
||||
eTagPoll = new Poll({
|
||||
resource: {
|
||||
fetchJobs: () => axios.get(state.jobsPath),
|
||||
},
|
||||
method: 'fetchJobs',
|
||||
successCallback: ({ data }) =>
|
||||
dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
|
||||
errorCallback: () => createFlash(s__('ImportProjects|Updating the imported projects failed')),
|
||||
});
|
||||
|
||||
if (!Visibility.hidden()) {
|
||||
eTagPoll.makeRequest();
|
||||
}
|
||||
|
||||
Visibility.change(() => {
|
||||
if (!Visibility.hidden()) {
|
||||
dispatch('restartJobsPolling');
|
||||
} else {
|
||||
dispatch('stopJobsPolling');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// prevent babel-plugin-rewire from generating an invalid default during karma tests
|
||||
export default () => {};
|
20
app/assets/javascripts/import_projects/store/getters.js
Normal file
20
app/assets/javascripts/import_projects/store/getters.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
export const namespaceSelectOptions = state => {
|
||||
const serializedNamespaces = state.namespaces.map(({ fullPath }) => ({
|
||||
id: fullPath,
|
||||
text: fullPath,
|
||||
}));
|
||||
|
||||
return [
|
||||
{ text: 'Groups', children: serializedNamespaces },
|
||||
{
|
||||
text: 'Users',
|
||||
children: [{ id: state.defaultTargetNamespace, text: state.defaultTargetNamespace }],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const isImportingAnyRepo = state => state.reposBeingImported.length > 0;
|
||||
|
||||
export const hasProviderRepos = state => state.providerRepos.length > 0;
|
||||
|
||||
export const hasImportedProjects = state => state.importedProjects.length > 0;
|
15
app/assets/javascripts/import_projects/store/index.js
Normal file
15
app/assets/javascripts/import_projects/store/index.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import state from './state';
|
||||
import * as actions from './actions';
|
||||
import * as getters from './getters';
|
||||
import mutations from './mutations';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
export default new Vuex.Store({
|
||||
state: state(),
|
||||
actions,
|
||||
mutations,
|
||||
getters,
|
||||
});
|
|
@ -0,0 +1,11 @@
|
|||
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
|
||||
|
||||
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_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';
|
55
app/assets/javascripts/import_projects/store/mutations.js
Normal file
55
app/assets/javascripts/import_projects/store/mutations.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
import Vue from 'vue';
|
||||
import * as types from './mutation_types';
|
||||
|
||||
export default {
|
||||
[types.SET_INITIAL_DATA](state, data) {
|
||||
Object.assign(state, data);
|
||||
},
|
||||
|
||||
[types.REQUEST_REPOS](state) {
|
||||
state.isLoadingRepos = true;
|
||||
},
|
||||
|
||||
[types.RECEIVE_REPOS_SUCCESS](state, { importedProjects, providerRepos, namespaces }) {
|
||||
state.isLoadingRepos = false;
|
||||
|
||||
state.importedProjects = importedProjects;
|
||||
state.providerRepos = providerRepos;
|
||||
state.namespaces = namespaces;
|
||||
},
|
||||
|
||||
[types.RECEIVE_REPOS_ERROR](state) {
|
||||
state.isLoadingRepos = false;
|
||||
},
|
||||
|
||||
[types.REQUEST_IMPORT](state, repoId) {
|
||||
state.reposBeingImported.push(repoId);
|
||||
},
|
||||
|
||||
[types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId }) {
|
||||
const existingRepoIndex = state.reposBeingImported.indexOf(repoId);
|
||||
if (state.reposBeingImported.includes(repoId))
|
||||
state.reposBeingImported.splice(existingRepoIndex, 1);
|
||||
|
||||
const providerRepoIndex = state.providerRepos.findIndex(
|
||||
providerRepo => providerRepo.id === repoId,
|
||||
);
|
||||
state.providerRepos.splice(providerRepoIndex, 1);
|
||||
state.importedProjects.unshift(importedProject);
|
||||
},
|
||||
|
||||
[types.RECEIVE_IMPORT_ERROR](state, repoId) {
|
||||
const repoIndex = state.reposBeingImported.indexOf(repoId);
|
||||
if (state.reposBeingImported.includes(repoId)) state.reposBeingImported.splice(repoIndex, 1);
|
||||
},
|
||||
|
||||
[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);
|
||||
});
|
||||
},
|
||||
};
|
15
app/assets/javascripts/import_projects/store/state.js
Normal file
15
app/assets/javascripts/import_projects/store/state.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
export default () => ({
|
||||
reposPath: '',
|
||||
importPath: '',
|
||||
jobsPath: '',
|
||||
currentProjectId: '',
|
||||
provider: '',
|
||||
currentUsername: '',
|
||||
importedProjects: [],
|
||||
providerRepos: [],
|
||||
namespaces: [],
|
||||
reposBeingImported: [],
|
||||
isLoadingRepos: false,
|
||||
canSelectNamespace: false,
|
||||
ciCdOnly: false,
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
import mountImportProjectsTable from '~/import_projects';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const mountElement = document.getElementById('import-projects-mount-element');
|
||||
|
||||
mountImportProjectsTable(mountElement);
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
import mountImportProjectsTable from '~/import_projects';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const mountElement = document.getElementById('import-projects-mount-element');
|
||||
|
||||
mountImportProjectsTable(mountElement);
|
||||
});
|
|
@ -46,6 +46,11 @@ export default {
|
|||
required: false,
|
||||
default: false,
|
||||
},
|
||||
cssClasses: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
cssClass() {
|
||||
|
@ -59,5 +64,5 @@ export default {
|
|||
};
|
||||
</script>
|
||||
<template>
|
||||
<span :class="cssClass"> <icon :name="icon" :size="size" /> </span>
|
||||
<span :class="cssClass"> <icon :name="icon" :size="size" :css-classes="cssClasses" /> </span>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
<script>
|
||||
import $ from 'jquery';
|
||||
|
||||
export default {
|
||||
name: 'Select2Select',
|
||||
props: {
|
||||
options: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
$(this.$refs.dropdownInput)
|
||||
.val(this.value)
|
||||
.select2(this.options)
|
||||
.on('change', event => this.$emit('input', event.target.value));
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
$(this.$refs.dropdownInput).select2('destroy');
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input ref="dropdownInput" type="hidden" />
|
||||
</template>
|
53
app/assets/stylesheets/pages/import.scss
vendored
53
app/assets/stylesheets/pages/import.scss
vendored
|
@ -1,20 +1,51 @@
|
|||
.import-jobs-from-col,
|
||||
.import-jobs-to-col {
|
||||
width: 40%;
|
||||
width: 39%;
|
||||
}
|
||||
|
||||
.import-jobs-status-col {
|
||||
width: 20%;
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.btn-import {
|
||||
.loading-icon {
|
||||
display: none;
|
||||
}
|
||||
.import-jobs-cta-col {
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
.loading-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
.import-project-name-input {
|
||||
border-radius: 0 $border-radius-default $border-radius-default 0;
|
||||
position: relative;
|
||||
left: -1px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.import-namespace-select {
|
||||
width: auto !important;
|
||||
|
||||
> .select2-choice {
|
||||
border-radius: $border-radius-default 0 0 $border-radius-default;
|
||||
position: relative;
|
||||
left: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.import-slash-divider {
|
||||
background-color: $gray-lightest;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.import-row {
|
||||
height: 55px;
|
||||
}
|
||||
|
||||
.import-table {
|
||||
.import-jobs-from-col,
|
||||
.import-jobs-to-col,
|
||||
.import-jobs-status-col,
|
||||
.import-jobs-cta-col {
|
||||
border-bottom-width: 1px;
|
||||
padding-left: $gl-padding;
|
||||
}
|
||||
}
|
||||
|
||||
.import-projects-loading-icon {
|
||||
margin-top: $gl-padding-32;
|
||||
}
|
||||
|
|
|
@ -44,10 +44,6 @@ module ImportHelper
|
|||
_('Please wait while we import the repository for you. Refresh at will.')
|
||||
end
|
||||
|
||||
def import_github_title
|
||||
_('Import repositories from GitHub')
|
||||
end
|
||||
|
||||
def import_github_authorize_message
|
||||
_('To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:')
|
||||
end
|
||||
|
@ -71,12 +67,4 @@ module ImportHelper
|
|||
_('Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link }
|
||||
end
|
||||
end
|
||||
|
||||
def import_githubish_choose_repository_message
|
||||
_('Choose which repositories you want to import.')
|
||||
end
|
||||
|
||||
def import_all_githubish_repositories_button_label
|
||||
_('Import all repositories')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,56 +1,9 @@
|
|||
- provider = local_assigns.fetch(:provider)
|
||||
- provider_title = Gitlab::ImportSources.title(provider)
|
||||
|
||||
%p.light
|
||||
= import_githubish_choose_repository_message
|
||||
%hr
|
||||
%p
|
||||
= button_tag class: "btn btn-import btn-success js-import-all" do
|
||||
= import_all_githubish_repositories_button_label
|
||||
= icon("spinner spin", class: "loading-icon")
|
||||
|
||||
.table-responsive
|
||||
%table.table.import-jobs
|
||||
%colgroup.import-jobs-from-col
|
||||
%colgroup.import-jobs-to-col
|
||||
%colgroup.import-jobs-status-col
|
||||
%thead
|
||||
%tr
|
||||
%th= _('From %{provider_title}') % { provider_title: provider_title }
|
||||
%th= _('To GitLab')
|
||||
%th= _('Status')
|
||||
%tbody
|
||||
- @already_added_projects.each do |project|
|
||||
%tr{ id: "project_#{project.id}", class: project_status_css_class(project.import_status) }
|
||||
%td
|
||||
= provider_project_link(provider, project.import_source)
|
||||
%td
|
||||
= link_to project.full_path, [project.namespace.becomes(Namespace), project]
|
||||
%td.job-status
|
||||
= render 'import/project_status', project: project
|
||||
|
||||
- @repos.each do |repo|
|
||||
%tr{ id: "repo_#{repo.id}", data: { qa: { repo_path: repo.full_name } } }
|
||||
%td
|
||||
= provider_project_link(provider, repo.full_name)
|
||||
%td.import-target
|
||||
%fieldset.row
|
||||
.input-group
|
||||
.project-path.input-group-prepend
|
||||
- if current_user.can_select_namespace?
|
||||
- selected = params[:namespace_id] || :current_user
|
||||
- opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner.login, path: repo.owner.login) } : {}
|
||||
= select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace qa-project-namespace-select', tabindex: 1 }
|
||||
- else
|
||||
= text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
|
||||
%span.input-group-prepend
|
||||
.input-group-text /
|
||||
= text_field_tag :path, sanitize_project_name(repo.name), class: "input-mini form-control", tabindex: 2, autofocus: true, required: true
|
||||
%td.import-actions.job-status
|
||||
= button_tag class: "btn btn-import js-add-to-import" do
|
||||
= has_ci_cd_only_params? ? _('Connect') : _('Import')
|
||||
= icon("spinner spin", class: "loading-icon")
|
||||
|
||||
.js-importer-status{ data: { jobs_import_path: url_for([:jobs, :import, provider]),
|
||||
import_path: url_for([:import, provider]),
|
||||
ci_cd_only: has_ci_cd_only_params?.to_s } }
|
||||
#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,
|
||||
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]) } }
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
- header_title _("Projects"), root_path
|
||||
|
||||
%h3.page-title
|
||||
= icon 'github', text: import_github_title
|
||||
= icon 'github', text: _('Import repositories from GitHub')
|
||||
|
||||
- if github_import_configured?
|
||||
%p
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
- page_title title
|
||||
- breadcrumb_title title
|
||||
- header_title _("Projects"), root_path
|
||||
%h3.page-title
|
||||
= icon 'github', text: import_github_title
|
||||
%h3.page-title.mb-0
|
||||
= icon 'github', class: 'fa-2x', text: _('Import repositories from GitHub')
|
||||
|
||||
= render 'import/githubish_status', provider: 'github'
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
%p
|
||||
= button_tag class: "btn btn-import btn-success js-import-all" do
|
||||
= import_all_githubish_repositories_button_label
|
||||
= _('Import all repositories')
|
||||
= icon("spinner spin", class: "loading-icon")
|
||||
|
||||
.table-responsive
|
||||
|
|
|
@ -1389,9 +1389,6 @@ msgstr ""
|
|||
msgid "Choose the top-level group for your repository imports."
|
||||
msgstr ""
|
||||
|
||||
msgid "Choose which repositories you want to import."
|
||||
msgstr ""
|
||||
|
||||
msgid "CiStatusLabel|canceled"
|
||||
msgstr ""
|
||||
|
||||
|
@ -3459,7 +3456,7 @@ msgstr ""
|
|||
msgid "Found errors in your .gitlab-ci.yml:"
|
||||
msgstr ""
|
||||
|
||||
msgid "From %{provider_title}"
|
||||
msgid "From %{providerTitle}"
|
||||
msgstr ""
|
||||
|
||||
msgid "From Bitbucket"
|
||||
|
@ -3582,6 +3579,9 @@ msgstr ""
|
|||
msgid "Go to %{link_to_google_takeout}."
|
||||
msgstr ""
|
||||
|
||||
msgid "Go to project"
|
||||
msgstr ""
|
||||
|
||||
msgid "Google Code import"
|
||||
msgstr ""
|
||||
|
||||
|
@ -3953,6 +3953,18 @@ msgstr ""
|
|||
msgid "Import timed out. Import took longer than %{import_jobs_expiration} seconds"
|
||||
msgstr ""
|
||||
|
||||
msgid "ImportProjects|Importing the project failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "ImportProjects|Requesting your %{provider} repositories failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "ImportProjects|Select the projects you want to import"
|
||||
msgstr ""
|
||||
|
||||
msgid "ImportProjects|Updating the imported projects failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "In order to enable instance-level analytics, please ask an admin to enable %{usage_ping_link_start}usage ping%{usage_ping_link_end}."
|
||||
msgstr ""
|
||||
|
||||
|
@ -4862,6 +4874,9 @@ msgstr ""
|
|||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
msgid "No %{providerTitle} repositories available to import"
|
||||
msgstr ""
|
||||
|
||||
msgid "No activities found"
|
||||
msgstr ""
|
||||
|
||||
|
@ -4970,6 +4985,9 @@ msgstr ""
|
|||
msgid "Not now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not started"
|
||||
msgstr ""
|
||||
|
||||
msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
|
||||
msgstr ""
|
||||
|
||||
|
@ -6320,6 +6338,9 @@ msgstr ""
|
|||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Running…"
|
||||
msgstr ""
|
||||
|
||||
msgid "SSH Keys"
|
||||
msgstr ""
|
||||
|
||||
|
@ -6362,6 +6383,9 @@ msgstr ""
|
|||
msgid "Schedules"
|
||||
msgstr ""
|
||||
|
||||
msgid "Scheduling"
|
||||
msgstr ""
|
||||
|
||||
msgid "Scheduling Pipelines"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -10,12 +10,11 @@ module QA
|
|||
element :list_repos_button, "submit_tag _('List your GitHub repositories')" # rubocop:disable QA/ElementWithPattern
|
||||
end
|
||||
|
||||
view 'app/views/import/_githubish_status.html.haml' do
|
||||
element :project_import_row, 'data: { qa: { repo_path: repo.full_name } }' # rubocop:disable QA/ElementWithPattern
|
||||
view 'app/assets/javascripts/import_projects/components/provider_repo_table_row.vue' do
|
||||
element :project_import_row
|
||||
element :project_namespace_select
|
||||
element :project_namespace_field, 'select_tag :namespace_id' # rubocop:disable QA/ElementWithPattern
|
||||
element :project_path_field, 'text_field_tag :path, sanitize_project_name(repo.name)' # rubocop:disable QA/ElementWithPattern
|
||||
element :import_button, "_('Import')" # rubocop:disable QA/ElementWithPattern
|
||||
element :project_path_field
|
||||
element :import_button
|
||||
end
|
||||
|
||||
def add_personal_access_token(personal_access_token)
|
||||
|
|
|
@ -0,0 +1,186 @@
|
|||
import Vue from 'vue';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import store from '~/import_projects/store';
|
||||
import importProjectsTable from '~/import_projects/components/import_projects_table.vue';
|
||||
import STATUS_MAP from '~/import_projects/constants';
|
||||
import setTimeoutPromise from '../../helpers/set_timeout_promise_helper';
|
||||
|
||||
describe('ImportProjectsTable', () => {
|
||||
let vm;
|
||||
let mock;
|
||||
const reposPath = '/repos-path';
|
||||
const jobsPath = '/jobs-path';
|
||||
const providerTitle = 'THE PROVIDER';
|
||||
const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' };
|
||||
const importedProject = {
|
||||
id: 1,
|
||||
fullPath: 'fullPath',
|
||||
importStatus: 'started',
|
||||
providerLink: 'providerLink',
|
||||
importSource: 'importSource',
|
||||
};
|
||||
|
||||
function createComponent() {
|
||||
const ImportProjectsTable = Vue.extend(importProjectsTable);
|
||||
|
||||
const component = new ImportProjectsTable({
|
||||
store,
|
||||
propsData: {
|
||||
providerTitle,
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
component.$store.dispatch('stopJobsPolling');
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store.dispatch('setInitialData', { reposPath });
|
||||
mock = new MockAdapter(axios);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('renders a loading icon whilst repos are loading', done => {
|
||||
mock.restore(); // Stop the mock adapter from responding to the request, keeping the spinner up
|
||||
|
||||
vm = createComponent();
|
||||
|
||||
setTimeoutPromise()
|
||||
.then(() => {
|
||||
expect(vm.$el.querySelector('.js-loading-button-icon')).not.toBeNull();
|
||||
})
|
||||
.then(() => done())
|
||||
.catch(() => done.fail());
|
||||
});
|
||||
|
||||
it('renders a table with imported projects and provider repos', done => {
|
||||
const response = {
|
||||
importedProjects: [importedProject],
|
||||
providerRepos: [providerRepo],
|
||||
namespaces: [{ path: 'path' }],
|
||||
};
|
||||
mock.onGet(reposPath).reply(200, response);
|
||||
|
||||
vm = createComponent();
|
||||
|
||||
setTimeoutPromise()
|
||||
.then(() => {
|
||||
expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull();
|
||||
expect(vm.$el.querySelector('.table')).not.toBeNull();
|
||||
expect(vm.$el.querySelector('.import-jobs-from-col').innerText).toMatch(
|
||||
`From ${providerTitle}`,
|
||||
);
|
||||
|
||||
expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull();
|
||||
expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull();
|
||||
})
|
||||
.then(() => done())
|
||||
.catch(() => done.fail());
|
||||
});
|
||||
|
||||
it('renders an empty state if there are no imported projects or provider repos', done => {
|
||||
const response = {
|
||||
importedProjects: [],
|
||||
providerRepos: [],
|
||||
namespaces: [],
|
||||
};
|
||||
mock.onGet(reposPath).reply(200, response);
|
||||
|
||||
vm = createComponent();
|
||||
|
||||
setTimeoutPromise()
|
||||
.then(() => {
|
||||
expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull();
|
||||
expect(vm.$el.querySelector('.table')).toBeNull();
|
||||
expect(vm.$el.innerText).toMatch(`No ${providerTitle} repositories available to import`);
|
||||
})
|
||||
.then(() => done())
|
||||
.catch(() => done.fail());
|
||||
});
|
||||
|
||||
it('imports provider repos if bulk import button is clicked', done => {
|
||||
const importPath = '/import-path';
|
||||
const response = {
|
||||
importedProjects: [],
|
||||
providerRepos: [providerRepo],
|
||||
namespaces: [{ path: 'path' }],
|
||||
};
|
||||
|
||||
mock.onGet(reposPath).replyOnce(200, response);
|
||||
mock.onPost(importPath).replyOnce(200, importedProject);
|
||||
|
||||
store.dispatch('setInitialData', { importPath });
|
||||
|
||||
vm = createComponent();
|
||||
|
||||
setTimeoutPromise()
|
||||
.then(() => {
|
||||
expect(vm.$el.querySelector('.js-imported-project')).toBeNull();
|
||||
expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull();
|
||||
|
||||
vm.$el.querySelector('.js-import-all').click();
|
||||
})
|
||||
.then(() => setTimeoutPromise())
|
||||
.then(() => {
|
||||
expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull();
|
||||
expect(vm.$el.querySelector('.js-provider-repo')).toBeNull();
|
||||
})
|
||||
.then(() => done())
|
||||
.catch(() => done.fail());
|
||||
});
|
||||
|
||||
it('polls to update the status of imported projects', done => {
|
||||
const importPath = '/import-path';
|
||||
const response = {
|
||||
importedProjects: [importedProject],
|
||||
providerRepos: [],
|
||||
namespaces: [{ path: 'path' }],
|
||||
};
|
||||
const updatedProjects = [
|
||||
{
|
||||
id: importedProject.id,
|
||||
importStatus: 'finished',
|
||||
},
|
||||
];
|
||||
|
||||
mock.onGet(reposPath).replyOnce(200, response);
|
||||
|
||||
store.dispatch('setInitialData', { importPath, jobsPath });
|
||||
|
||||
vm = createComponent();
|
||||
|
||||
setTimeoutPromise()
|
||||
.then(() => {
|
||||
const statusObject = STATUS_MAP[importedProject.importStatus];
|
||||
|
||||
expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull();
|
||||
expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch(
|
||||
statusObject.text,
|
||||
);
|
||||
|
||||
expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull();
|
||||
|
||||
mock.onGet(jobsPath).replyOnce(200, updatedProjects);
|
||||
return vm.$store.dispatch('restartJobsPolling');
|
||||
})
|
||||
.then(() => setTimeoutPromise())
|
||||
.then(() => {
|
||||
const statusObject = STATUS_MAP[updatedProjects[0].importStatus];
|
||||
|
||||
expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull();
|
||||
expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch(
|
||||
statusObject.text,
|
||||
);
|
||||
|
||||
expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull();
|
||||
})
|
||||
.then(() => done())
|
||||
.catch(() => done.fail());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,50 @@
|
|||
import Vue from 'vue';
|
||||
import store from '~/import_projects/store';
|
||||
import importedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue';
|
||||
import STATUS_MAP from '~/import_projects/constants';
|
||||
|
||||
describe('ImportedProjectTableRow', () => {
|
||||
let vm;
|
||||
const project = {
|
||||
id: 1,
|
||||
fullPath: 'fullPath',
|
||||
importStatus: 'finished',
|
||||
providerLink: 'providerLink',
|
||||
importSource: 'importSource',
|
||||
};
|
||||
|
||||
function createComponent() {
|
||||
const ImportedProjectTableRow = Vue.extend(importedProjectTableRow);
|
||||
|
||||
return new ImportedProjectTableRow({
|
||||
store,
|
||||
propsData: {
|
||||
project: {
|
||||
...project,
|
||||
},
|
||||
},
|
||||
}).$mount();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('renders an imported project table row', () => {
|
||||
vm = createComponent();
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull();
|
||||
expect(vm.$el.querySelector('.js-go-to-project').href).toMatch(project.fullPath);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,78 @@
|
|||
import Vue from 'vue';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import store from '~/import_projects/store';
|
||||
import providerRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue';
|
||||
import STATUS_MAP, { STATUSES } from '~/import_projects/constants';
|
||||
import setTimeoutPromise from '../../helpers/set_timeout_promise_helper';
|
||||
|
||||
describe('ProviderRepoTableRow', () => {
|
||||
let vm;
|
||||
const repo = {
|
||||
id: 10,
|
||||
sanitizedName: 'sanitizedName',
|
||||
fullName: 'fullName',
|
||||
providerLink: 'providerLink',
|
||||
};
|
||||
|
||||
function createComponent() {
|
||||
const ProviderRepoTableRow = Vue.extend(providerRepoTableRow);
|
||||
|
||||
return new ProviderRepoTableRow({
|
||||
store,
|
||||
propsData: {
|
||||
repo: {
|
||||
...repo,
|
||||
},
|
||||
},
|
||||
}).$mount();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('renders a provider repo table row', () => {
|
||||
vm = createComponent();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('imports repo when clicking import button', done => {
|
||||
const importPath = '/import-path';
|
||||
const defaultTargetNamespace = 'user';
|
||||
const ciCdOnly = true;
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
store.dispatch('setInitialData', { importPath, defaultTargetNamespace, ciCdOnly });
|
||||
mock.onPost(importPath).replyOnce(200);
|
||||
spyOn(store, 'dispatch').and.returnValue(new Promise(() => {}));
|
||||
|
||||
vm = createComponent();
|
||||
|
||||
vm.$el.querySelector('.js-import-button').click();
|
||||
|
||||
setTimeoutPromise()
|
||||
.then(() => {
|
||||
expect(store.dispatch).toHaveBeenCalledWith('fetchImport', {
|
||||
repo,
|
||||
newName: repo.sanitizedName,
|
||||
targetNamespace: defaultTargetNamespace,
|
||||
});
|
||||
})
|
||||
.then(() => mock.restore())
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
});
|
284
spec/javascripts/import_projects/store/actions_spec.js
Normal file
284
spec/javascripts/import_projects/store/actions_spec.js
Normal file
|
@ -0,0 +1,284 @@
|
|||
import MockAdapter from 'axios-mock-adapter';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
import {
|
||||
SET_INITIAL_DATA,
|
||||
REQUEST_REPOS,
|
||||
RECEIVE_REPOS_SUCCESS,
|
||||
RECEIVE_REPOS_ERROR,
|
||||
REQUEST_IMPORT,
|
||||
RECEIVE_IMPORT_SUCCESS,
|
||||
RECEIVE_IMPORT_ERROR,
|
||||
RECEIVE_JOBS_SUCCESS,
|
||||
} from '~/import_projects/store/mutation_types';
|
||||
import {
|
||||
setInitialData,
|
||||
requestRepos,
|
||||
receiveReposSuccess,
|
||||
receiveReposError,
|
||||
fetchRepos,
|
||||
requestImport,
|
||||
receiveImportSuccess,
|
||||
receiveImportError,
|
||||
fetchImport,
|
||||
receiveJobsSuccess,
|
||||
fetchJobs,
|
||||
clearJobsEtagPoll,
|
||||
stopJobsPolling,
|
||||
} from '~/import_projects/store/actions';
|
||||
import state from '~/import_projects/store/state';
|
||||
import testAction from 'spec/helpers/vuex_action_helper';
|
||||
import { TEST_HOST } from 'spec/test_constants';
|
||||
|
||||
describe('import_projects store actions', () => {
|
||||
let localState;
|
||||
const repoId = 1;
|
||||
const repos = [{ id: 1 }, { id: 2 }];
|
||||
const importPayload = { newName: 'newName', targetNamespace: 'targetNamespace', repo: { id: 1 } };
|
||||
|
||||
beforeEach(() => {
|
||||
localState = state();
|
||||
});
|
||||
|
||||
describe('setInitialData', () => {
|
||||
it(`commits ${SET_INITIAL_DATA} mutation`, done => {
|
||||
const initialData = {
|
||||
reposPath: 'reposPath',
|
||||
provider: 'provider',
|
||||
jobsPath: 'jobsPath',
|
||||
importPath: 'impapp/assets/javascripts/vue_shared/components/select2_select.vueortPath',
|
||||
defaultTargetNamespace: 'defaultTargetNamespace',
|
||||
ciCdOnly: 'ciCdOnly',
|
||||
canSelectNamespace: 'canSelectNamespace',
|
||||
};
|
||||
|
||||
testAction(
|
||||
setInitialData,
|
||||
initialData,
|
||||
localState,
|
||||
[{ type: SET_INITIAL_DATA, payload: initialData }],
|
||||
[],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestRepos', () => {
|
||||
it(`requestRepos commits ${REQUEST_REPOS} mutation`, done => {
|
||||
testAction(
|
||||
requestRepos,
|
||||
null,
|
||||
localState,
|
||||
[{ type: REQUEST_REPOS, payload: null }],
|
||||
[],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receiveReposSuccess', () => {
|
||||
it(`commits ${RECEIVE_REPOS_SUCCESS} mutation`, done => {
|
||||
testAction(
|
||||
receiveReposSuccess,
|
||||
repos,
|
||||
localState,
|
||||
[{ type: RECEIVE_REPOS_SUCCESS, payload: repos }],
|
||||
[],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receiveReposError', () => {
|
||||
it(`commits ${RECEIVE_REPOS_ERROR} mutation`, done => {
|
||||
testAction(receiveReposError, repos, localState, [{ type: RECEIVE_REPOS_ERROR }], [], done);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchRepos', () => {
|
||||
let mock;
|
||||
|
||||
beforeEach(() => {
|
||||
localState.reposPath = `${TEST_HOST}/endpoint.json`;
|
||||
mock = new MockAdapter(axios);
|
||||
});
|
||||
|
||||
afterEach(() => mock.restore());
|
||||
|
||||
it('dispatches requestRepos and receiveReposSuccess actions on a successful request', done => {
|
||||
const payload = { imported_projects: [{}], provider_repos: [{}], namespaces: [{}] };
|
||||
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, payload);
|
||||
|
||||
testAction(
|
||||
fetchRepos,
|
||||
null,
|
||||
localState,
|
||||
[],
|
||||
[
|
||||
{ type: 'requestRepos' },
|
||||
{
|
||||
type: 'receiveReposSuccess',
|
||||
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
|
||||
},
|
||||
{
|
||||
type: 'fetchJobs',
|
||||
},
|
||||
],
|
||||
done,
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches requestRepos and receiveReposSuccess actions on an unsuccessful request', done => {
|
||||
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
|
||||
|
||||
testAction(
|
||||
fetchRepos,
|
||||
null,
|
||||
localState,
|
||||
[],
|
||||
[{ type: 'requestRepos' }, { type: 'receiveReposError' }],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestImport', () => {
|
||||
it(`commits ${REQUEST_IMPORT} mutation`, done => {
|
||||
testAction(
|
||||
requestImport,
|
||||
repoId,
|
||||
localState,
|
||||
[{ type: REQUEST_IMPORT, payload: repoId }],
|
||||
[],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receiveImportSuccess', () => {
|
||||
it(`commits ${RECEIVE_IMPORT_SUCCESS} mutation`, done => {
|
||||
const payload = { importedProject: { name: 'imported/project' }, repoId: 2 };
|
||||
|
||||
testAction(
|
||||
receiveImportSuccess,
|
||||
payload,
|
||||
localState,
|
||||
[{ type: RECEIVE_IMPORT_SUCCESS, payload }],
|
||||
[],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receiveImportError', () => {
|
||||
it(`commits ${RECEIVE_IMPORT_ERROR} mutation`, done => {
|
||||
testAction(
|
||||
receiveImportError,
|
||||
repoId,
|
||||
localState,
|
||||
[{ type: RECEIVE_IMPORT_ERROR, payload: repoId }],
|
||||
[],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchImport', () => {
|
||||
let mock;
|
||||
|
||||
beforeEach(() => {
|
||||
localState.importPath = `${TEST_HOST}/endpoint.json`;
|
||||
mock = new MockAdapter(axios);
|
||||
});
|
||||
|
||||
afterEach(() => mock.restore());
|
||||
|
||||
it('dispatches requestImport and receiveImportSuccess actions on a successful request', done => {
|
||||
const importedProject = { name: 'imported/project' };
|
||||
const importRepoId = importPayload.repo.id;
|
||||
mock.onPost(`${TEST_HOST}/endpoint.json`).reply(200, importedProject);
|
||||
|
||||
testAction(
|
||||
fetchImport,
|
||||
importPayload,
|
||||
localState,
|
||||
[],
|
||||
[
|
||||
{ type: 'requestImport', payload: importRepoId },
|
||||
{
|
||||
type: 'receiveImportSuccess',
|
||||
payload: {
|
||||
importedProject: convertObjectPropsToCamelCase(importedProject, { deep: true }),
|
||||
repoId: importRepoId,
|
||||
},
|
||||
},
|
||||
],
|
||||
done,
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches requestImport and receiveImportSuccess actions on an unsuccessful request', done => {
|
||||
mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500);
|
||||
|
||||
testAction(
|
||||
fetchImport,
|
||||
importPayload,
|
||||
localState,
|
||||
[],
|
||||
[
|
||||
{ type: 'requestImport', payload: importPayload.repo.id },
|
||||
{ type: 'receiveImportError', payload: { repoId: importPayload.repo.id } },
|
||||
],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receiveJobsSuccess', () => {
|
||||
it(`commits ${RECEIVE_JOBS_SUCCESS} mutation`, done => {
|
||||
testAction(
|
||||
receiveJobsSuccess,
|
||||
repos,
|
||||
localState,
|
||||
[{ type: RECEIVE_JOBS_SUCCESS, payload: repos }],
|
||||
[],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchJobs', () => {
|
||||
let mock;
|
||||
|
||||
beforeEach(() => {
|
||||
localState.jobsPath = `${TEST_HOST}/endpoint.json`;
|
||||
mock = new MockAdapter(axios);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopJobsPolling();
|
||||
clearJobsEtagPoll();
|
||||
});
|
||||
|
||||
afterEach(() => mock.restore());
|
||||
|
||||
it('dispatches requestJobs and receiveJobsSuccess actions on a successful request', done => {
|
||||
const updatedProjects = [{ name: 'imported/project' }, { name: 'provider/repo' }];
|
||||
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, updatedProjects);
|
||||
|
||||
testAction(
|
||||
fetchJobs,
|
||||
null,
|
||||
localState,
|
||||
[],
|
||||
[
|
||||
{
|
||||
type: 'receiveJobsSuccess',
|
||||
payload: convertObjectPropsToCamelCase(updatedProjects, { deep: true }),
|
||||
},
|
||||
],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
83
spec/javascripts/import_projects/store/getters_spec.js
Normal file
83
spec/javascripts/import_projects/store/getters_spec.js
Normal file
|
@ -0,0 +1,83 @@
|
|||
import {
|
||||
namespaceSelectOptions,
|
||||
isImportingAnyRepo,
|
||||
hasProviderRepos,
|
||||
hasImportedProjects,
|
||||
} from '~/import_projects/store/getters';
|
||||
import state from '~/import_projects/store/state';
|
||||
|
||||
describe('import_projects store getters', () => {
|
||||
let localState;
|
||||
|
||||
beforeEach(() => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isImportingAnyRepo', () => {
|
||||
it('returns true if there are any reposBeingImported', () => {
|
||||
localState.reposBeingImported = new Array(1);
|
||||
|
||||
expect(isImportingAnyRepo(localState)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if there are no reposBeingImported', () => {
|
||||
localState.reposBeingImported = [];
|
||||
|
||||
expect(isImportingAnyRepo(localState)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasProviderRepos', () => {
|
||||
it('returns true if there are any providerRepos', () => {
|
||||
localState.providerRepos = new Array(1);
|
||||
|
||||
expect(hasProviderRepos(localState)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if there are no providerRepos', () => {
|
||||
localState.providerRepos = [];
|
||||
|
||||
expect(hasProviderRepos(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 = [];
|
||||
|
||||
expect(hasImportedProjects(localState)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
34
spec/javascripts/import_projects/store/mutations_spec.js
Normal file
34
spec/javascripts/import_projects/store/mutations_spec.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
import * as types from '~/import_projects/store/mutation_types';
|
||||
import mutations from '~/import_projects/store/mutations';
|
||||
|
||||
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 }],
|
||||
importedProjects: [],
|
||||
};
|
||||
const importedProject = { id: repoId };
|
||||
|
||||
mutations[types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId });
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe(types.RECEIVE_JOBS_SUCCESS, () => {
|
||||
it('updates importStatus of existing importedProjects', () => {
|
||||
const repoId = 1;
|
||||
const state = { importedProjects: [{ id: repoId, importStatus: 'started' }] };
|
||||
const updatedProjects = [{ id: repoId, importStatus: 'finished' }];
|
||||
|
||||
mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects);
|
||||
|
||||
expect(state.importedProjects[0].importStatus).toBe(updatedProjects[0].importStatus);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue