summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Bennett <lbennett@gitlab.com>2018-11-07 16:44:21 +0000
committerLuke Bennett <lbennett@gitlab.com>2019-02-13 00:17:52 +0000
commitaf989df0ec0c15f269071080ab08417e688dabf7 (patch)
tree53096af07d17412dc38b70327e69433b965504dd
parent534a61179e2d0d7f9f376af1d01ed536e27f5b6d (diff)
downloadgitlab-ce-import-go-to-project-cta-nibble-frontend.tar.gz
Improve the GitHub and Gitea import feature table interfaceimport-go-to-project-cta-nibble-frontend
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.
-rw-r--r--app/assets/javascripts/import_projects/components/import_projects_table.vue101
-rw-r--r--app/assets/javascripts/import_projects/components/import_status.vue47
-rw-r--r--app/assets/javascripts/import_projects/components/imported_project_table_row.vue55
-rw-r--r--app/assets/javascripts/import_projects/components/provider_repo_table_row.vue110
-rw-r--r--app/assets/javascripts/import_projects/constants.js48
-rw-r--r--app/assets/javascripts/import_projects/event_hub.js3
-rw-r--r--app/assets/javascripts/import_projects/index.js47
-rw-r--r--app/assets/javascripts/import_projects/store/actions.js106
-rw-r--r--app/assets/javascripts/import_projects/store/getters.js20
-rw-r--r--app/assets/javascripts/import_projects/store/index.js15
-rw-r--r--app/assets/javascripts/import_projects/store/mutation_types.js11
-rw-r--r--app/assets/javascripts/import_projects/store/mutations.js55
-rw-r--r--app/assets/javascripts/import_projects/store/state.js15
-rw-r--r--app/assets/javascripts/pages/import/gitea/status/index.js7
-rw-r--r--app/assets/javascripts/pages/import/github/status/index.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/select2_select.vue34
-rw-r--r--app/assets/stylesheets/pages/import.scss51
-rw-r--r--app/helpers/import_helper.rb12
-rw-r--r--app/views/import/_githubish_status.html.haml59
-rw-r--r--app/views/import/github/new.html.haml2
-rw-r--r--app/views/import/github/status.html.haml4
-rw-r--r--app/views/import/manifest/status.html.haml2
-rw-r--r--locale/gitlab.pot32
-rw-r--r--qa/qa/page/project/import/github.rb9
-rw-r--r--spec/javascripts/import_projects/components/import_projects_table_spec.js186
-rw-r--r--spec/javascripts/import_projects/components/imported_project_table_row_spec.js50
-rw-r--r--spec/javascripts/import_projects/components/provider_repo_table_row_spec.js78
-rw-r--r--spec/javascripts/import_projects/store/actions_spec.js284
-rw-r--r--spec/javascripts/import_projects/store/getters_spec.js83
-rw-r--r--spec/javascripts/import_projects/store/mutations_spec.js34
31 files changed, 1485 insertions, 89 deletions
diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue
new file mode 100644
index 00000000000..777f8fa6691
--- /dev/null
+++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue
@@ -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>
diff --git a/app/assets/javascripts/import_projects/components/import_status.vue b/app/assets/javascripts/import_projects/components/import_status.vue
new file mode 100644
index 00000000000..9e3347a657f
--- /dev/null
+++ b/app/assets/javascripts/import_projects/components/import_status.vue
@@ -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>
diff --git a/app/assets/javascripts/import_projects/components/imported_project_table_row.vue b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue
new file mode 100644
index 00000000000..ab2bd87ee9f
--- /dev/null
+++ b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue
@@ -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>
diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
new file mode 100644
index 00000000000..7cc29fa1b91
--- /dev/null
+++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
@@ -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>
diff --git a/app/assets/javascripts/import_projects/constants.js b/app/assets/javascripts/import_projects/constants.js
new file mode 100644
index 00000000000..ad33ca158d2
--- /dev/null
+++ b/app/assets/javascripts/import_projects/constants.js
@@ -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;
diff --git a/app/assets/javascripts/import_projects/event_hub.js b/app/assets/javascripts/import_projects/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/import_projects/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js
new file mode 100644
index 00000000000..5c77484aee1
--- /dev/null
+++ b/app/assets/javascripts/import_projects/index.js
@@ -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 } });
+ },
+ });
+}
diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js
new file mode 100644
index 00000000000..c44500937cc
--- /dev/null
+++ b/app/assets/javascripts/import_projects/store/actions.js
@@ -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 () => {};
diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js
new file mode 100644
index 00000000000..f03474a8404
--- /dev/null
+++ b/app/assets/javascripts/import_projects/store/getters.js
@@ -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;
diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js
new file mode 100644
index 00000000000..6ac9bfd8189
--- /dev/null
+++ b/app/assets/javascripts/import_projects/store/index.js
@@ -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,
+});
diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_projects/store/mutation_types.js
new file mode 100644
index 00000000000..6ba3fd6f29e
--- /dev/null
+++ b/app/assets/javascripts/import_projects/store/mutation_types.js
@@ -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';
diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js
new file mode 100644
index 00000000000..b88de0268e7
--- /dev/null
+++ b/app/assets/javascripts/import_projects/store/mutations.js
@@ -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);
+ });
+ },
+};
diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_projects/store/state.js
new file mode 100644
index 00000000000..637fef6e53c
--- /dev/null
+++ b/app/assets/javascripts/import_projects/store/state.js
@@ -0,0 +1,15 @@
+export default () => ({
+ reposPath: '',
+ importPath: '',
+ jobsPath: '',
+ currentProjectId: '',
+ provider: '',
+ currentUsername: '',
+ importedProjects: [],
+ providerRepos: [],
+ namespaces: [],
+ reposBeingImported: [],
+ isLoadingRepos: false,
+ canSelectNamespace: false,
+ ciCdOnly: false,
+});
diff --git a/app/assets/javascripts/pages/import/gitea/status/index.js b/app/assets/javascripts/pages/import/gitea/status/index.js
new file mode 100644
index 00000000000..dcd84f0faf9
--- /dev/null
+++ b/app/assets/javascripts/pages/import/gitea/status/index.js
@@ -0,0 +1,7 @@
+import mountImportProjectsTable from '~/import_projects';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const mountElement = document.getElementById('import-projects-mount-element');
+
+ mountImportProjectsTable(mountElement);
+});
diff --git a/app/assets/javascripts/pages/import/github/status/index.js b/app/assets/javascripts/pages/import/github/status/index.js
new file mode 100644
index 00000000000..dcd84f0faf9
--- /dev/null
+++ b/app/assets/javascripts/pages/import/github/status/index.js
@@ -0,0 +1,7 @@
+import mountImportProjectsTable from '~/import_projects';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const mountElement = document.getElementById('import-projects-mount-element');
+
+ mountImportProjectsTable(mountElement);
+});
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index b8eb555106f..2f498c4fa2a 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -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>
diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue
new file mode 100644
index 00000000000..19c5da0461a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/select2_select.vue
@@ -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>
diff --git a/app/assets/stylesheets/pages/import.scss b/app/assets/stylesheets/pages/import.scss
index a4f76a9495a..7f800367cad 100644
--- a/app/assets/stylesheets/pages/import.scss
+++ b/app/assets/stylesheets/pages/import.scss
@@ -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%;
+}
+
+.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;
}
+}
- &.is-loading {
- .loading-icon {
- display: inline-block;
- }
+.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;
+}
diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb
index 2306963347e..3d494c3de6a 100644
--- a/app/helpers/import_helper.rb
+++ b/app/helpers/import_helper.rb
@@ -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
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index f4a29ed18dc..b05c039c85c 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -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]) } }
diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml
index 6ff25f2c842..cf32c5c9387 100644
--- a/app/views/import/github/new.html.haml
+++ b/app/views/import/github/new.html.haml
@@ -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
diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml
index be057be6d1a..ee295e70cce 100644
--- a/app/views/import/github/status.html.haml
+++ b/app/views/import/github/status.html.haml
@@ -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'
diff --git a/app/views/import/manifest/status.html.haml b/app/views/import/manifest/status.html.haml
index 5b2e1005398..3d4abc32b88 100644
--- a/app/views/import/manifest/status.html.haml
+++ b/app/views/import/manifest/status.html.haml
@@ -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
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 7cbba0779f7..b3356a018d9 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/qa/qa/page/project/import/github.rb b/qa/qa/page/project/import/github.rb
index a3cde73d3f2..488157d9878 100644
--- a/qa/qa/page/project/import/github.rb
+++ b/qa/qa/page/project/import/github.rb
@@ -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)
diff --git a/spec/javascripts/import_projects/components/import_projects_table_spec.js b/spec/javascripts/import_projects/components/import_projects_table_spec.js
new file mode 100644
index 00000000000..a1ff84ce259
--- /dev/null
+++ b/spec/javascripts/import_projects/components/import_projects_table_spec.js
@@ -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());
+ });
+});
diff --git a/spec/javascripts/import_projects/components/imported_project_table_row_spec.js b/spec/javascripts/import_projects/components/imported_project_table_row_spec.js
new file mode 100644
index 00000000000..8af3b5954a9
--- /dev/null
+++ b/spec/javascripts/import_projects/components/imported_project_table_row_spec.js
@@ -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);
+ });
+});
diff --git a/spec/javascripts/import_projects/components/provider_repo_table_row_spec.js b/spec/javascripts/import_projects/components/provider_repo_table_row_spec.js
new file mode 100644
index 00000000000..7191fc923ce
--- /dev/null
+++ b/spec/javascripts/import_projects/components/provider_repo_table_row_spec.js
@@ -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);
+ });
+});
diff --git a/spec/javascripts/import_projects/store/actions_spec.js b/spec/javascripts/import_projects/store/actions_spec.js
new file mode 100644
index 00000000000..77850ee3283
--- /dev/null
+++ b/spec/javascripts/import_projects/store/actions_spec.js
@@ -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,
+ );
+ });
+ });
+});
diff --git a/spec/javascripts/import_projects/store/getters_spec.js b/spec/javascripts/import_projects/store/getters_spec.js
new file mode 100644
index 00000000000..e5e4a95f473
--- /dev/null
+++ b/spec/javascripts/import_projects/store/getters_spec.js
@@ -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);
+ });
+ });
+});
diff --git a/spec/javascripts/import_projects/store/mutations_spec.js b/spec/javascripts/import_projects/store/mutations_spec.js
new file mode 100644
index 00000000000..8db8e9819ba
--- /dev/null
+++ b/spec/javascripts/import_projects/store/mutations_spec.js
@@ -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);
+ });
+ });
+});