diff options
author | Lin Jen-Shin <godfat@godfat.org> | 2019-02-15 14:59:55 +0300 |
---|---|---|
committer | Lin Jen-Shin <godfat@godfat.org> | 2019-02-15 14:59:55 +0300 |
commit | 7aa0be3c4307cb21024328273611ebb6473842fe (patch) | |
tree | a22b5100f4e21f8041a042525e85b7c29ca58714 /app | |
parent | 50193341d8d5bfbe0725619119e2f2c4ba5a7288 (diff) | |
parent | a4bb4fbf8f8dce953d05b3916553d0a2fb984bba (diff) |
Merge branch 'import-go-to-project-cta-nibble-backend' into 'master'
BE Improve the GitHub and Gitea import feature table interface
See merge request gitlab-org/gitlab-ce!24606
Diffstat (limited to 'app')
33 files changed, 895 insertions, 126 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/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb index f067ef625aa..68ad8650dba 100644 --- a/app/controllers/import/gitea_controller.rb +++ b/app/controllers/import/gitea_controller.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true class Import::GiteaController < Import::GithubController + extend ::Gitlab::Utils::Override + def new - if session[access_token_key].present? && session[host_key].present? + if session[access_token_key].present? && provider_url.present? redirect_to status_import_url end end @@ -12,8 +14,8 @@ class Import::GiteaController < Import::GithubController super end + # Must be defined or it will 404 def status - @gitea_host_url = session[host_key] super end @@ -23,25 +25,33 @@ class Import::GiteaController < Import::GithubController :"#{provider}_host_url" end - # Overridden methods + override :provider def provider :gitea end + override :provider_url + def provider_url + session[host_key] + end + # Gitea is not yet an OAuth provider # See https://github.com/go-gitea/gitea/issues/27 + override :logged_in_with_provider? def logged_in_with_provider? false end + override :provider_auth def provider_auth - if session[access_token_key].blank? || session[host_key].blank? + if session[access_token_key].blank? || provider_url.blank? redirect_to new_import_gitea_url, alert: 'You need to specify both an Access Token and a Host URL.' end end + override :client_options def client_options - { host: session[host_key], api_version: 'v1' } + { host: provider_url, api_version: 'v1' } end end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 3fbc0817e95..aa4aa0fbdac 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true class Import::GithubController < Import::BaseController + include ImportHelper + before_action :verify_import_enabled - before_action :provider_auth, only: [:status, :jobs, :create] + before_action :provider_auth, only: [:status, :realtime_changes, :create] + before_action :expire_etag_cache, only: [:status, :create] rescue_from Octokit::Unauthorized, with: :provider_unauthorized @@ -24,30 +27,37 @@ class Import::GithubController < Import::BaseController redirect_to status_import_url end - # rubocop: disable CodeReuse/ActiveRecord def status - @repos = client.repos - @already_added_projects = find_already_added_projects(provider) - already_added_projects_names = @already_added_projects.pluck(:import_source) - - @repos.reject! { |repo| already_added_projects_names.include? repo.full_name } - end - # rubocop: enable CodeReuse/ActiveRecord - - def jobs - render json: find_jobs(provider) + # Request repos to display error page if provider token is invalid + # Improving in https://gitlab.com/gitlab-org/gitlab-ce/issues/55585 + client_repos + + respond_to do |format| + format.json do + render json: { imported_projects: serialized_imported_projects, + provider_repos: serialized_provider_repos, + namespaces: serialized_namespaces } + end + format.html + end end def create result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider) if result[:status] == :success - render json: ProjectSerializer.new.represent(result[:project]) + render json: serialized_imported_projects(result[:project]) else render json: { errors: result[:message] }, status: result[:http_status] end end + def realtime_changes + Gitlab::PollingInterval.set_header(response, interval: 3_000) + + render json: find_jobs(provider) + end + private def import_params @@ -58,10 +68,45 @@ class Import::GithubController < Import::BaseController [:repo_id, :new_name, :target_namespace] end + def serialized_imported_projects(projects = already_added_projects) + ProjectSerializer.new.represent(projects, serializer: :import, provider_url: provider_url) + end + + def serialized_provider_repos + repos = client_repos.reject { |repo| already_added_project_names.include? repo.full_name } + ProviderRepoSerializer.new(current_user: current_user).represent(repos, provider: provider, provider_url: provider_url) + end + + def serialized_namespaces + NamespaceSerializer.new.represent(namespaces) + end + + def already_added_projects + @already_added_projects ||= find_already_added_projects(provider) + end + + def already_added_project_names + @already_added_projects_names ||= already_added_projects.pluck(:import_source) # rubocop:disable CodeReuse/ActiveRecord + end + + def namespaces + current_user.manageable_groups_with_routes + end + + def expire_etag_cache + Gitlab::EtagCaching::Store.new.tap do |store| + store.touch(realtime_changes_path) + end + end + def client @client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options) end + def client_repos + @client_repos ||= client.repos + end + def verify_import_enabled render_404 unless import_enabled? end @@ -74,6 +119,10 @@ class Import::GithubController < Import::BaseController __send__("#{provider}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend end + def realtime_changes_path + public_send("realtime_changes_import_#{provider}_path", format: :json) # rubocop:disable GitlabSecurity/PublicSend + end + def new_import_url public_send("new_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend end @@ -105,6 +154,14 @@ class Import::GithubController < Import::BaseController :github end + def provider_url + strong_memoize(:provider_url) do + provider = Gitlab::Auth::OAuth::Provider.config_for('github') + + provider&.dig('url').presence || 'https://github.com' + end + end + # rubocop: disable CodeReuse/ActiveRecord def logged_in_with_provider? current_user.identities.exists?(provider: provider) diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb index d3befd87ccc..3d494c3de6a 100644 --- a/app/helpers/import_helper.rb +++ b/app/helpers/import_helper.rb @@ -18,10 +18,8 @@ module ImportHelper "#{namespace}/#{name}" end - def provider_project_link(provider, full_path) - url = __send__("#{provider}_project_url", full_path) # rubocop:disable GitlabSecurity/PublicSend - - link_to full_path, url, target: '_blank', rel: 'noopener noreferrer' + def provider_project_link_url(provider_url, full_path) + Gitlab::Utils.append_path(provider_url, full_path) end def import_will_timeout_message(_ci_cd_only) @@ -46,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 @@ -73,30 +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 - - private - - def github_project_url(full_path) - Gitlab::Utils.append_path(github_root_url, full_path) - end - - def github_root_url - strong_memoize(:github_url) do - provider = Gitlab::Auth::OAuth::Provider.config_for('github') - - provider&.dig('url').presence || 'https://github.com' - end - end - - def gitea_project_url(full_path) - Gitlab::Utils.append_path(@gitea_host_url, full_path) - end end diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 6c65e573307..ea3bcfc791a 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -5,11 +5,8 @@ module NamespacesHelper params.dig(:project, :namespace_id) || params[:namespace_id] end - # rubocop: disable CodeReuse/ActiveRecord def namespaces_options(selected = :current_user, display_path: false, groups: nil, extra_group: nil, groups_only: false) - groups ||= current_user.manageable_groups - .eager_load(:route) - .order('routes.path') + groups ||= current_user.manageable_groups_with_routes users = [current_user.namespace] selected_id = selected @@ -43,7 +40,6 @@ module NamespacesHelper grouped_options_for_select(options, selected_id) end - # rubocop: enable CodeReuse/ActiveRecord def namespace_icon(namespace, size = 40) if namespace.is_a?(Group) diff --git a/app/models/user.rb b/app/models/user.rb index 0d52006db28..fd32d838e53 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1168,6 +1168,10 @@ class User < ApplicationRecord Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants end + def manageable_groups_with_routes + manageable_groups.eager_load(:route).order('routes.path') + end + def namespaces namespace_ids = groups.pluck(:id) namespace_ids.push(namespace.id) diff --git a/app/serializers/namespace_basic_entity.rb b/app/serializers/namespace_basic_entity.rb new file mode 100644 index 00000000000..8bcbb2bca60 --- /dev/null +++ b/app/serializers/namespace_basic_entity.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class NamespaceBasicEntity < Grape::Entity + expose :id + expose :full_path +end diff --git a/app/serializers/namespace_serializer.rb b/app/serializers/namespace_serializer.rb new file mode 100644 index 00000000000..bf3f154b558 --- /dev/null +++ b/app/serializers/namespace_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class NamespaceSerializer < BaseSerializer + entity NamespaceBasicEntity +end diff --git a/app/serializers/project_import_entity.rb b/app/serializers/project_import_entity.rb new file mode 100644 index 00000000000..9b51af685e7 --- /dev/null +++ b/app/serializers/project_import_entity.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ProjectImportEntity < ProjectEntity + include ImportHelper + + expose :import_source + expose :import_status + expose :human_import_status_name + + expose :provider_link do |project, options| + provider_project_link_url(options[:provider_url], project[:import_source]) + end +end diff --git a/app/serializers/project_serializer.rb b/app/serializers/project_serializer.rb index 23b96c2fc9e..52ac2fa0e09 100644 --- a/app/serializers/project_serializer.rb +++ b/app/serializers/project_serializer.rb @@ -1,5 +1,15 @@ # frozen_string_literal: true class ProjectSerializer < BaseSerializer - entity ProjectEntity + def represent(project, opts = {}) + entity = + case opts[:serializer] + when :import + ProjectImportEntity + else + ProjectEntity + end + + super(project, opts, entity) + end end diff --git a/app/serializers/provider_repo_entity.rb b/app/serializers/provider_repo_entity.rb new file mode 100644 index 00000000000..d70aaa91324 --- /dev/null +++ b/app/serializers/provider_repo_entity.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ProviderRepoEntity < Grape::Entity + include ImportHelper + + expose :id + expose :full_name + expose :owner_name do |provider_repo, options| + owner_name(provider_repo, options[:provider]) + end + + expose :sanitized_name do |provider_repo| + sanitize_project_name(provider_repo[:name]) + end + + expose :provider_link do |provider_repo, options| + provider_project_link_url(options[:provider_url], provider_repo[:full_name]) + end + + private + + def owner_name(provider_repo, provider) + provider_repo.dig(:owner, :login) if provider == :github + end +end diff --git a/app/serializers/provider_repo_serializer.rb b/app/serializers/provider_repo_serializer.rb new file mode 100644 index 00000000000..8a73f6fe6df --- /dev/null +++ b/app/serializers/provider_repo_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ProviderRepoSerializer < BaseSerializer + entity ProviderRepoEntity +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 |