Commit af989df0 authored by Luke Bennett's avatar Luke Bennett

Improve the GitHub and Gitea import feature table interface

These are frontend changes.
Use Vue for the import feature UI for "githubish"
providers (GitHub and Gitea).
Add "Go to project" button after a successful import.
Use CI-style status icons and improve spacing of the
table and its component.
Adds ETag polling to the github and gitea import
jobs endpoint.
parent 534a6117
Pipeline #47265370 passed with stages
in 54 minutes and 57 seconds
<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,