diff options
author | Luke Bennett <lbennett@gitlab.com> | 2018-11-07 16:44:21 +0000 |
---|---|---|
committer | Luke Bennett <lbennett@gitlab.com> | 2018-12-28 21:25:43 +0000 |
commit | 83a82bc759221b4c3fbfa65a5f08875eebbc459b (patch) | |
tree | 722b3c2832457f55e4fae72d716ba5d325dc1404 | |
parent | e962baf4417e59cbb2ef8621ef0662f93f180f92 (diff) | |
download | gitlab-ce-import-go-to-project-cta.tar.gz |
Improve the GitHub and Gitea import feature table interfaceimport-go-to-project-cta
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.
55 files changed, 1863 insertions, 208 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..605724af3df 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: $theme-gray-50; + 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 d4c26fa0709..e346d21af46 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,17 +27,24 @@ 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 } + # 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: serialize_imported_projects, + provider_repos: serialized_provider_repos, + namespaces: serialized_namespaces } + end + format.html + end end - # rubocop: enable CodeReuse/ActiveRecord - def jobs + def realtime_changes + Gitlab::PollingInterval.set_header(response, interval: 3_000) + render json: find_jobs(provider) end @@ -50,7 +60,7 @@ class Import::GithubController < Import::BaseController .execute(extra_project_attrs) if project.persisted? - render json: ProjectSerializer.new.represent(project) + render json: serialize_imported_projects(project) else render json: { errors: project_save_error(project) }, status: :unprocessable_entity end @@ -61,10 +71,48 @@ class Import::GithubController < Import::BaseController private + def serialize_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 + return if request.format.json? + + # this forces to reload json content + 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 @@ -77,6 +125,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 @@ -108,6 +160,14 @@ class Import::GithubController < Import::BaseController :github end + def provider_url + strong_memoize(:github_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 49171df1433..f2fd9057e00 100644 --- a/app/helpers/import_helper.rb +++ b/app/helpers/import_helper.rb @@ -16,10 +16,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) @@ -44,10 +42,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,30 +65,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 26fd2d903a1..9c196efae16 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1156,6 +1156,10 @@ class User < ActiveRecord::Base 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 diff --git a/changelogs/unreleased/import-go-to-project-cta.yml b/changelogs/unreleased/import-go-to-project-cta.yml new file mode 100644 index 00000000000..ed8d7e3561a --- /dev/null +++ b/changelogs/unreleased/import-go-to-project-cta.yml @@ -0,0 +1,5 @@ +--- +title: Improve GitHub and Gitea project import table UI +merge_request: 22458 +author: +type: other diff --git a/config/routes/import.rb b/config/routes/import.rb index 3998d977c81..fda8bc14d59 100644 --- a/config/routes/import.rb +++ b/config/routes/import.rb @@ -3,13 +3,13 @@ namespace :import do post :personal_access_token get :status get :callback - get :jobs + get :realtime_changes end resource :gitea, only: [:create, :new], controller: :gitea do post :personal_access_token get :status - get :jobs + get :realtime_changes end resource :gitlab, only: [:create], controller: :gitlab do diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb index 08e30214b46..0891f79198d 100644 --- a/lib/gitlab/etag_caching/router.rb +++ b/lib/gitlab/etag_caching/router.rb @@ -52,6 +52,14 @@ module Gitlab Gitlab::EtagCaching::Router::Route.new( %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/environments\.json\z), 'environments' + ), + Gitlab::EtagCaching::Router::Route.new( + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/import/github/realtime_changes\.json\z), + 'realtime_changes_import_github' + ), + Gitlab::EtagCaching::Router::Route.new( + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/import/gitea/realtime_changes\.json\z), + 'realtime_changes_import_gitea' ) ].freeze diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 88eff3b6645..44d20aa3fe1 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1305,9 +1305,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 "" @@ -3151,7 +3148,7 @@ msgstr "" msgid "Found errors in your .gitlab-ci.yml:" msgstr "" -msgid "From %{provider_title}" +msgid "From %{providerTitle}" msgstr "" msgid "From Bitbucket" @@ -3271,6 +3268,9 @@ msgstr "" msgid "Go to %{link_to_google_takeout}." msgstr "" +msgid "Go to project" +msgstr "" + msgid "Google Code import" msgstr "" @@ -3621,6 +3621,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 "" @@ -4416,6 +4428,9 @@ msgstr "" msgid "No" msgstr "" +msgid "No %{providerTitle} repositories available to import" +msgstr "" + msgid "No assignee" msgstr "" @@ -4512,6 +4527,9 @@ msgstr "" msgid "Not now" msgstr "" +msgid "Not started" +msgstr "" + msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}" msgstr "" @@ -5790,6 +5808,9 @@ msgstr "" msgid "Running" msgstr "" +msgid "Running…" +msgstr "" + msgid "SSH Keys" msgstr "" @@ -5826,6 +5847,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/controllers/import/gitea_controller_spec.rb b/spec/controllers/import/gitea_controller_spec.rb index 5ba64ab3eed..8cbec79095f 100644 --- a/spec/controllers/import/gitea_controller_spec.rb +++ b/spec/controllers/import/gitea_controller_spec.rb @@ -40,4 +40,12 @@ describe Import::GiteaController do end end end + + describe "GET realtime_changes" do + it_behaves_like 'a GitHub-ish import controller: GET realtime_changes' do + before do + assign_host_url + end + end + end end diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb index 780e49f7b93..fadb399c581 100644 --- a/spec/controllers/import/github_controller_spec.rb +++ b/spec/controllers/import/github_controller_spec.rb @@ -54,4 +54,8 @@ describe Import::GithubController do describe "POST create" do it_behaves_like 'a GitHub-ish import controller: POST create' end + + describe "GET realtime_changes" do + it_behaves_like 'a GitHub-ish import controller: GET realtime_changes' + end end diff --git a/spec/helpers/import_helper_spec.rb b/spec/helpers/import_helper_spec.rb index cb0ea4e26ba..7369b15d74e 100644 --- a/spec/helpers/import_helper_spec.rb +++ b/spec/helpers/import_helper_spec.rb @@ -35,59 +35,12 @@ describe ImportHelper do end end - describe '#provider_project_link' do - context 'when provider is "github"' do - let(:github_server_url) { nil } - let(:provider) { OpenStruct.new(name: 'github', url: github_server_url) } + describe '#provider_project_link_url' do + let(:full_path) { '/repo/path' } + let(:host_url) { 'provider.com' } - before do - stub_omniauth_setting(providers: [provider]) - end - - context 'when provider does not specify a custom URL' do - it 'uses default GitHub URL' do - expect(helper.provider_project_link('github', 'octocat/Hello-World')) - .to include('href="https://github.com/octocat/Hello-World"') - end - end - - context 'when provider specify a custom URL' do - let(:github_server_url) { 'https://github.company.com' } - - it 'uses custom URL' do - expect(helper.provider_project_link('github', 'octocat/Hello-World')) - .to include('href="https://github.company.com/octocat/Hello-World"') - end - end - - context "when custom URL contains a '/' char at the end" do - let(:github_server_url) { 'https://github.company.com/' } - - it "doesn't render double slash" do - expect(helper.provider_project_link('github', 'octocat/Hello-World')) - .to include('href="https://github.company.com/octocat/Hello-World"') - end - end - - context 'when provider is missing' do - it 'uses the default URL' do - allow(Gitlab.config.omniauth).to receive(:providers).and_return([]) - - expect(helper.provider_project_link('github', 'octocat/Hello-World')) - .to include('href="https://github.com/octocat/Hello-World"') - end - end - end - - context 'when provider is "gitea"' do - before do - assign(:gitea_host_url, 'https://try.gitea.io/') - end - - it 'uses given host' do - expect(helper.provider_project_link('gitea', 'octocat/Hello-World')) - .to include('href="https://try.gitea.io/octocat/Hello-World"') - end + it 'appends repo full path to provider host url' do + expect(helper.provider_project_link_url(host_url, full_path)).to match(host_url << full_path) end end end 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); + }); + }); +}); diff --git a/spec/routing/import_routing_spec.rb b/spec/routing/import_routing_spec.rb index 78ff9c6e6fd..106f92082e4 100644 --- a/spec/routing/import_routing_spec.rb +++ b/spec/routing/import_routing_spec.rb @@ -23,6 +23,11 @@ require 'spec_helper' # end shared_examples 'importer routing' do let(:except_actions) { [] } + let(:is_realtime) { false } + + before do + except_actions.push(is_realtime ? :jobs : :realtime_changes) + end it 'to #create' do expect(post("/import/#{provider}")).to route_to("import/#{provider}#create") unless except_actions.include?(:create) @@ -43,17 +48,22 @@ shared_examples 'importer routing' do it 'to #jobs' do expect(get("/import/#{provider}/jobs")).to route_to("import/#{provider}#jobs") unless except_actions.include?(:jobs) end + + it 'to #realtime_changes' do + expect(get("/import/#{provider}/realtime_changes")).to route_to("import/#{provider}#realtime_changes") unless except_actions.include?(:realtime_changes) + end end # personal_access_token_import_github POST /import/github/personal_access_token(.:format) import/github#personal_access_token # status_import_github GET /import/github/status(.:format) import/github#status # callback_import_github GET /import/github/callback(.:format) import/github#callback -# jobs_import_github GET /import/github/jobs(.:format) import/github#jobs +# realtime_changes_import_github GET /import/github/realtime_changes(.:format) import/github#jobs # import_github POST /import/github(.:format) import/github#create # new_import_github GET /import/github/new(.:format) import/github#new describe Import::GithubController, 'routing' do it_behaves_like 'importer routing' do let(:provider) { 'github' } + let(:is_realtime) { true } end it 'to #personal_access_token' do @@ -63,13 +73,14 @@ end # personal_access_token_import_gitea POST /import/gitea/personal_access_token(.:format) import/gitea#personal_access_token # status_import_gitea GET /import/gitea/status(.:format) import/gitea#status -# jobs_import_gitea GET /import/gitea/jobs(.:format) import/gitea#jobs +# realtime_changes_import_gitea GET /import/gitea/realtime_changes(.:format) import/gitea#jobs # import_gitea POST /import/gitea(.:format) import/gitea#create # new_import_gitea GET /import/gitea/new(.:format) import/gitea#new describe Import::GiteaController, 'routing' do it_behaves_like 'importer routing' do let(:except_actions) { [:callback] } let(:provider) { 'gitea' } + let(:is_realtime) { true } end it 'to #personal_access_token' do diff --git a/spec/serializers/namespace_basic_entity_spec.rb b/spec/serializers/namespace_basic_entity_spec.rb new file mode 100644 index 00000000000..f8b71ceb9f3 --- /dev/null +++ b/spec/serializers/namespace_basic_entity_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe NamespaceBasicEntity do + set(:group) { create(:group) } + let(:entity) do + described_class.represent(group) + end + + describe '#as_json' do + subject { entity.as_json } + + it 'includes required fields' do + expect(subject).to include :id, :full_path + end + end +end diff --git a/spec/serializers/namespace_serializer_spec.rb b/spec/serializers/namespace_serializer_spec.rb new file mode 100644 index 00000000000..6e5bdd8c52d --- /dev/null +++ b/spec/serializers/namespace_serializer_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe NamespaceSerializer do + it 'represents NamespaceBasicEntity entities' do + expect(described_class.entity_class).to eq(NamespaceBasicEntity) + end +end diff --git a/spec/serializers/project_import_entity_spec.rb b/spec/serializers/project_import_entity_spec.rb new file mode 100644 index 00000000000..e476da82729 --- /dev/null +++ b/spec/serializers/project_import_entity_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ProjectImportEntity do + include ImportHelper + + set(:project) { create(:project, import_status: :started, import_source: 'namespace/project') } + let(:provider_url) { 'https://provider.com' } + let(:entity) { described_class.represent(project, provider_url: provider_url) } + + describe '#as_json' do + subject { entity.as_json } + + it 'includes required fields' do + expect(subject[:import_source]).to eq(project.import_source) + expect(subject[:import_status]).to eq(project.import_status) + expect(subject[:human_import_status_name]).to eq(project.human_import_status_name) + expect(subject[:provider_link]).to eq(provider_project_link_url(provider_url, project[:import_source])) + end + end +end diff --git a/spec/serializers/project_serializer_spec.rb b/spec/serializers/project_serializer_spec.rb new file mode 100644 index 00000000000..22f958fc17f --- /dev/null +++ b/spec/serializers/project_serializer_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ProjectSerializer do + set(:project) { create(:project) } + let(:provider_url) { 'http://provider.com' } + + context 'when serializer option is :import' do + subject do + described_class.new.represent(project, serializer: :import, provider_url: provider_url) + end + + before do + allow(ProjectImportEntity).to receive(:represent) + end + + it 'represents with ProjectImportEntity' do + subject + + expect(ProjectImportEntity) + .to have_received(:represent) + .with(project, serializer: :import, provider_url: provider_url, request: an_instance_of(EntityRequest)) + end + end + + context 'when serializer option is omitted' do + subject do + described_class.new.represent(project) + end + + before do + allow(ProjectEntity).to receive(:represent) + end + + it 'represents with ProjectEntity' do + subject + + expect(ProjectEntity) + .to have_received(:represent) + .with(project, request: an_instance_of(EntityRequest)) + end + end +end diff --git a/spec/serializers/provider_repo_entity_spec.rb b/spec/serializers/provider_repo_entity_spec.rb new file mode 100644 index 00000000000..b67115bab10 --- /dev/null +++ b/spec/serializers/provider_repo_entity_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ProviderRepoEntity do + include ImportHelper + + let(:provider_repo) { { id: 1, full_name: 'full/name', name: 'name', owner: { login: 'owner' } } } + let(:provider) { :github } + let(:provider_url) { 'https://github.com' } + let(:entity) { described_class.represent(provider_repo, provider: provider, provider_url: provider_url) } + + describe '#as_json' do + subject { entity.as_json } + + it 'includes requried fields' do + expect(subject[:id]).to eq(provider_repo[:id]) + expect(subject[:full_name]).to eq(provider_repo[:full_name]) + expect(subject[:owner_name]).to eq(provider_repo[:owner][:login]) + expect(subject[:sanitized_name]).to eq(sanitize_project_name(provider_repo[:name])) + expect(subject[:provider_link]).to eq(provider_project_link_url(provider_url, provider_repo[:full_name])) + end + end +end diff --git a/spec/serializers/provider_repo_serializer_spec.rb b/spec/serializers/provider_repo_serializer_spec.rb new file mode 100644 index 00000000000..f2be30c36d9 --- /dev/null +++ b/spec/serializers/provider_repo_serializer_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ProviderRepoSerializer do + it 'represents ProviderRepoEntity entities' do + expect(described_class.entity_class).to eq(ProviderRepoEntity) + end +end diff --git a/spec/support/controllers/githubish_import_controller_shared_examples.rb b/spec/support/controllers/githubish_import_controller_shared_examples.rb index 697f999e4c4..6fae66b0ff6 100644 --- a/spec/support/controllers/githubish_import_controller_shared_examples.rb +++ b/spec/support/controllers/githubish_import_controller_shared_examples.rb @@ -58,36 +58,42 @@ end shared_examples 'a GitHub-ish import controller: GET status' do let(:new_import_url) { public_send("new_import_#{provider}_url") } let(:user) { create(:user) } - let(:repo) { OpenStruct.new(login: 'vim', full_name: 'asd/vim') } + let(:repo) { OpenStruct.new(login: 'vim', full_name: 'asd/vim', name: 'vim', owner: { login: 'owner' }) } let(:org) { OpenStruct.new(login: 'company') } - let(:org_repo) { OpenStruct.new(login: 'company', full_name: 'company/repo') } - let(:extra_assign_expectations) { {} } + let(:org_repo) { OpenStruct.new(login: 'company', full_name: 'company/repo', name: 'repo', owner: { login: 'owner' }) } before do assign_session_token(provider) end - it "assigns variables" do - project = create(:project, import_type: provider, namespace: user.namespace) + it "returns variables for json request" do + project = create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'example/repo') + group = create(:group) + group.add_owner(user) stub_client(repos: [repo, org_repo], orgs: [org], org_repos: [org_repo]) - get :status + get :status, format: :json - expect(assigns(:already_added_projects)).to eq([project]) - expect(assigns(:repos)).to eq([repo, org_repo]) - extra_assign_expectations.each do |key, value| - expect(assigns(key)).to eq(value) - end + expect(json_response["imported_projects"][0]["id"]).to eq(project.id) + expect(json_response["provider_repos"][0]["id"]).to eq(repo.id) + expect(json_response["provider_repos"][1]["id"]).to eq(org_repo.id) + expect(json_response["namespaces"][0]["id"]).to eq(group.id) end it "does not show already added project" do - project = create(:project, import_type: provider, namespace: user.namespace, import_source: 'asd/vim') + project = create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'asd/vim') stub_client(repos: [repo], orgs: []) - get :status + get :status, format: :json - expect(assigns(:already_added_projects)).to eq([project]) - expect(assigns(:repos)).to eq([]) + expect(json_response["imported_projects"][0]["id"]).to eq(project.id) + expect(json_response["provider_repos"]).to eq([]) + end + + it "requests provider repos list" do + expect(stub_client(repos: [], orgs: [])).to receive(:repos) + + get :status end it "handles an invalid access token" do @@ -100,13 +106,20 @@ shared_examples 'a GitHub-ish import controller: GET status' do expect(controller).to redirect_to(new_import_url) expect(flash[:alert]).to eq("Access denied to your #{Gitlab::ImportSources.title(provider.to_s)} account.") end + + it "avoids N+1 database queries" do + stub_client(repos: [repo], orgs: []) + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { get :status }.count + stub_client(repos: [repo, org_repo], orgs: []) + expect { get :status }.not_to exceed_all_query_limit(control_count) + end end shared_examples 'a GitHub-ish import controller: POST create' do let(:user) { create(:user) } - let(:project) { create(:project) } let(:provider_username) { user.username } let(:provider_user) { OpenStruct.new(login: provider_username) } + let(:project) { create(:project, import_type: provider, import_status: :finished, import_source: "#{provider_username}/vim") } let(:provider_repo) do OpenStruct.new( name: 'vim', @@ -351,7 +364,7 @@ shared_examples 'a GitHub-ish import controller: POST create' do it 'does not create a new namespace under the user namespace' do expect(Gitlab::LegacyGithubImport::ProjectCreator) .to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider) - .and_return(double(execute: build_stubbed(:project))) + .and_return(double(execute: project)) expect { post :create, params: { target_namespace: "#{user.namespace_path}/test_group", new_name: test_name }, format: :js } .not_to change { Namespace.count } @@ -365,7 +378,7 @@ shared_examples 'a GitHub-ish import controller: POST create' do it 'does not take the selected namespace and name' do expect(Gitlab::LegacyGithubImport::ProjectCreator) .to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider) - .and_return(double(execute: build_stubbed(:project))) + .and_return(double(execute: project)) post :create, params: { target_namespace: 'foo/foobar/bar', new_name: test_name }, format: :js end @@ -373,7 +386,7 @@ shared_examples 'a GitHub-ish import controller: POST create' do it 'does not create the namespaces' do allow(Gitlab::LegacyGithubImport::ProjectCreator) .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider) - .and_return(double(execute: build_stubbed(:project))) + .and_return(double(execute: project)) expect { post :create, params: { target_namespace: 'foo/foobar/bar', new_name: test_name }, format: :js } .not_to change { Namespace.count } @@ -390,7 +403,7 @@ shared_examples 'a GitHub-ish import controller: POST create' do expect(Gitlab::LegacyGithubImport::ProjectCreator) .to receive(:new).with(provider_repo, test_name, group, user, access_params, type: provider) - .and_return(double(execute: build_stubbed(:project))) + .and_return(double(execute: project)) post :create, params: { target_namespace: 'foo', new_name: test_name }, format: :js end @@ -407,3 +420,20 @@ shared_examples 'a GitHub-ish import controller: POST create' do end end end + +shared_examples 'a GitHub-ish import controller: GET realtime_changes' do + let(:user) { create(:user) } + + before do + assign_session_token(provider) + end + + it 'sets a Poll-Interval header' do + project = create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'example/repo') + + get :realtime_changes + + expect(JSON.parse(response.body, symbolize_names: true)).to eq([{ id: project.id, import_status: project.import_status }]) + expect(response.headers['Poll-Interval'].to_i).to be > -1 + end +end |