diff options
Diffstat (limited to 'app/assets')
18 files changed, 728 insertions, 11 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; +} |