Commit a4bb4fbf authored by Phil Hughes's avatar Phil Hughes

Merge branch 'import-go-to-project-cta-nibble-frontend' into...

Merge branch 'import-go-to-project-cta-nibble-frontend' into 'import-go-to-project-cta-nibble-backend'

FE Improve the GitHub and Gitea import feature table interface

See merge request gitlab-org/gitlab-ce!24608
parents 534a6117 af989df0
<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>
<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>
<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>
<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>
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;
import Vue from 'vue';
export default new Vue();
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 } });
},
});
}
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 () => {};
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;
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,
});
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';
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);
});
},
};
export default () => ({
reposPath: '',
importPath: '',
jobsPath: '',
currentProjectId: '',
provider: '',
currentUsername: '',
importedProjects: [],
providerRepos: [],
namespaces: [],
reposBeingImported: [],
isLoadingRepos: false,
canSelectNamespace: false,
ciCdOnly: false,
});
import mountImportProjectsTable from '~/import_projects';
document.addEventListener('DOMContentLoaded', () => {
const mountElement = document.getElementById('import-projects-mount-element');
mountImportProjectsTable(mountElement);
});
import mountImportProjectsTable from '~/import_projects';
document.addEventListener('DOMContentLoaded', () => {
const mountElement = document.getElementById('import-projects-mount-element');
mountImportProjectsTable(mountElement);
});
......@@ -46,6 +46,11 @@ export default {
required: false,
default: false,
},
cssClasses: {
type: String,
required: false,
default: '',
},
},
computed: {
cssClass() {
......@@ -59,5 +64,5 @@ export default {
};
</script>
<template>
<span :class="cssClass"> <icon :name="icon" :size="size" /> </span>
<span :class="cssClass"> <icon :name="icon" :size="size" :css-classes="cssClasses" /> </span>
</template>
<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>
.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;
}
......@@ -44,10 +44,6 @@ module ImportHelper
_('Please wait while we import the repository for you. Refresh at will.')
end
def import_github_title
_('Import repositories from GitHub')
end
def import_github_authorize_message
_('To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:')
end
......@@ -71,12 +67,4 @@ module ImportHelper
_('Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link }
end
end
def import_githubish_choose_repository_message
_('Choose which repositories you want to import.')
end
def import_all_githubish_repositories_button_label
_('Import all repositories')
end
end
- provider = local_assigns.fetch(:provider)
- provider_title = Gitlab::ImportSources.title(provider)
%p.light
= import_githubish_choose_repository_message
%hr
%p
= button_tag class: "btn btn-import btn-success js-import-all" do
= import_all_githubish_repositories_button_label
= icon("spinner spin", class: "loading-icon")
.table-responsive
%table.table.import-jobs
%colgroup.import-jobs-from-col
%colgroup.import-jobs-to-col
%colgroup.import-jobs-status-col
%thead
%tr
%th= _('From %{provider_title}') % { provider_title: provider_title }
%th= _('To GitLab')
%th= _('Status')
%tbody
- @already_added_projects.each do |project|
%tr{ id: "project_#{project.id}", class: project_status_css_class(project.import_status) }
%td
= provider_project_link(provider, project.import_source)
%td
= link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
= render 'import/project_status', project: project
- @repos.each do |repo|
%tr{ id: "repo_#{repo.id}", data: { qa: { repo_path: repo.full_name } } }
%td
= provider_project_link(provider, repo.full_name)
%td.import-target
%fieldset.row
.input-group
.project-path.input-group-prepend
- if current_user.can_select_namespace?
- selected = params[:namespace_id] || :current_user
- opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner.login, path: repo.owner.login) } : {}
= select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace qa-project-namespace-select', tabindex: 1 }
- else
= text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
%span.input-group-prepend
.input-group-text /
= text_field_tag :path, sanitize_project_name(repo.name), class: "input-mini form-control", tabindex: 2, autofocus: true, required: true
%td.import-actions.job-status
= button_tag class: "btn btn-import js-add-to-import" do
= has_ci_cd_only_params? ? _('Connect') : _('Import')
= icon("spinner spin", class: "loading-icon")
.js-importer-status{ data: { jobs_import_path: url_for([:jobs, :import, provider]),
import_path: url_for([:import, provider]),
ci_cd_only: has_ci_cd_only_params?.to_s } }
#import-projects-mount-element{ data: { provider: provider, provider_title: provider_title,
can_select_namespace: current_user.can_select_namespace?.to_s,
ci_cd_only: has_ci_cd_only_params?.to_s,
repos_path: url_for([:status, :import, provider, format: :json]),
jobs_path: url_for([:realtime_changes, :import, provider, format: :json]),
import_path: url_for([:import, provider, format: :json]) } }
......@@ -4,7 +4,7 @@
- header_title _("Projects"), root_path
%h3.page-title
= icon 'github', text: import_github_title
= icon 'github', text: _('Import repositories from GitHub')
- if github_import_configured?
%p
......
......@@ -2,7 +2,7 @@
- page_title title
- breadcrumb_title title
- header_title _("Projects"), root_path
%h3.page-title
= icon 'github', text: import_github_title
%h3.page-title.mb-0
= icon 'github', class: 'fa-2x', text: _('Import repositories from GitHub')
= render 'import/githubish_status', provider: 'github'
......@@ -7,7 +7,7 @@
%p
= button_tag class: "btn btn-import btn-success js-import-all" do
= import_all_githubish_repositories_button_label
= _('Import all repositories')
= icon("spinner spin", class: "loading-icon")
.table-responsive
......
......@@ -1389,9 +1389,6 @@ msgstr ""
msgid "Choose the top-level group for your repository imports."
msgstr ""
msgid "Choose which repositories you want to import."
msgstr ""
msgid "CiStatusLabel|canceled"
msgstr ""
......@@ -3459,7 +3456,7 @@ msgstr ""
msgid "Found errors in your .gitlab-ci.yml:"
msgstr ""
msgid "From %{provider_title}"
msgid "From %{providerTitle}"
msgstr ""
msgid "From Bitbucket"
......@@ -3582,6 +3579,9 @@ msgstr ""
msgid "Go to %{link_to_google_takeout}."
msgstr ""
msgid "Go to project"
msgstr ""
msgid "Google Code import"
msgstr ""
......@@ -3953,6 +3953,18 @@ msgstr ""
msgid "Import timed out. Import took longer than %{import_jobs_expiration} seconds"
msgstr ""
msgid "ImportProjects|Importing the project failed"
msgstr ""
msgid "ImportProjects|Requesting your %{provider} repositories failed"
msgstr ""
msgid "ImportProjects|Select the projects you want to import"
msgstr ""
msgid "ImportProjects|Updating the imported projects failed"
msgstr ""
msgid "In order to enable instance-level analytics, please ask an admin to enable %{usage_ping_link_start}usage ping%{usage_ping_link_end}."
msgstr ""
......@@ -4862,6 +4874,9 @@ msgstr ""
msgid "No"
msgstr ""
msgid "No %{providerTitle} repositories available to import"
msgstr ""
msgid "No activities found"
msgstr ""
......@@ -4970,6 +4985,9 @@ msgstr ""
msgid "Not now"
msgstr ""
msgid "Not started"
msgstr ""
msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
msgstr ""
......@@ -6320,6 +6338,9 @@ msgstr ""
msgid "Running"
msgstr ""
msgid "Running…"
msgstr ""
msgid "SSH Keys"
msgstr ""
......@@ -6362,6 +6383,9 @@ msgstr ""
msgid "Schedules"
msgstr ""
msgid "Scheduling"
msgstr ""
msgid "Scheduling Pipelines"
msgstr ""
......
......@@ -10,12 +10,11 @@ module QA
element :list_repos_button, "submit_tag _('List your GitHub repositories')" # rubocop:disable QA/ElementWithPattern
end
view 'app/views/import/_githubish_status.html.haml' do
element :project_import_row, 'data: { qa: { repo_path: repo.full_name } }' # rubocop:disable QA/ElementWithPattern
view 'app/assets/javascripts/import_projects/components/provider_repo_table_row.vue' do
element :project_import_row
element :project_namespace_select
element :project_namespace_field, 'select_tag :namespace_id' # rubocop:disable QA/ElementWithPattern
element :project_path_field, 'text_field_tag :path, sanitize_project_name(repo.name)' # rubocop:disable QA/ElementWithPattern
element :import_button, "_('Import')" # rubocop:disable QA/ElementWithPattern
element :project_path_field
element :import_button
end
def add_personal_access_token(personal_access_token)
......
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());
});
});
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);
});
});
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);
});
});
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,
);
});
});
});
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);
});
});
});
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);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment