Commit c07eba6c authored by Illya Klymov's avatar Illya Klymov

Implement history for project imports

* Add new history page

Changelog: added
parent a9b4272b
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import API from '~/api';
import { createAlert } from '~/flash';
import { DEFAULT_ERROR } from '../utils/error_messages';
export default {
components: {
GlLoadingIcon,
},
props: {
id: {
type: Number,
required: true,
},
},
data() {
return {
loading: true,
error: null,
};
},
async mounted() {
try {
const {
data: { import_error: importError },
} = await API.project(this.id);
this.error = importError;
} catch (e) {
createAlert({ message: DEFAULT_ERROR });
this.error = null;
} finally {
this.loading = false;
}
},
};
</script>
<template>
<gl-loading-icon v-if="loading" size="md" />
<pre v-else>{{ error || s__('BulkImport|No additional information provided.') }}</pre>
</template>
<script>
import { GlButton, GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import createFlash from '~/flash';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { getProjects } from '~/rest_api';
import ImportStatus from '~/import_entities/components/import_status.vue';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { DEFAULT_ERROR } from '../utils/error_messages';
import ImportErrorDetails from './import_error_details.vue';
const DEFAULT_PER_PAGE = 20;
const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!';
const tableCell = (config) => ({
thClass: `${DEFAULT_TH_CLASSES}`,
tdClass: (value, key, item) => {
return {
// eslint-disable-next-line no-underscore-dangle
'gl-border-b-0!': item._showDetails,
};
},
...config,
});
export default {
components: {
GlButton,
GlEmptyState,
GlIcon,
GlLink,
GlLoadingIcon,
GlTable,
PaginationBar,
ImportStatus,
ImportErrorDetails,
TimeAgo,
},
data() {
return {
loading: true,
historyItems: [],
paginationConfig: {
page: 1,
perPage: DEFAULT_PER_PAGE,
},
pageInfo: {},
};
},
fields: [
tableCell({
key: 'source',
label: s__('BulkImport|Source'),
thClass: `${DEFAULT_TH_CLASSES} gl-w-30p`,
}),
tableCell({
key: 'destination',
label: s__('BulkImport|Destination'),
thClass: `${DEFAULT_TH_CLASSES} gl-w-40p`,
}),
tableCell({
key: 'created_at',
label: __('Date'),
}),
tableCell({
key: 'status',
label: __('Status'),
tdAttr: { 'data-qa-selector': 'import_status_indicator' },
}),
],
computed: {
hasHistoryItems() {
return this.historyItems.length > 0;
},
},
watch: {
paginationConfig: {
handler() {
this.loadHistoryItems();
},
deep: true,
immediate: true,
},
},
methods: {
async loadHistoryItems() {
try {
this.loading = true;
const { data: historyItems, headers } = await getProjects(undefined, {
imported: true,
simple: false,
page: this.paginationConfig.page,
per_page: this.paginationConfig.perPage,
});
this.pageInfo = parseIntPagination(normalizeHeaders(headers));
this.historyItems = historyItems;
} catch (e) {
createFlash({ message: DEFAULT_ERROR, captureError: true, error: e });
} finally {
this.loading = false;
}
},
isHTTP(url) {
try {
const parsedUrl = new URL(url);
return ['http:', 'https:'].includes(parsedUrl.protocol);
} catch (e) {
return false;
}
},
},
gitlabLogo: window.gon.gitlab_logo,
};
</script>
<template>
<div>
<div
class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex gl-align-items-center"
>
<h1 class="gl-my-0 gl-py-4 gl-font-size-h1">
<img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" />
{{ s__('BulkImport|Project import history') }}
</h1>
</div>
<gl-loading-icon v-if="loading" size="md" class="gl-mt-5" />
<gl-empty-state
v-else-if="!hasHistoryItems"
:title="s__('BulkImport|No history is available')"
:description="s__('BulkImport|Your imported projects will appear here.')"
/>
<template v-else>
<gl-table
:fields="$options.fields"
:items="historyItems"
data-qa-selector="import_history_table"
class="gl-w-full"
>
<template #cell(source)="{ item }">
<template v-if="item.import_url">
<gl-link v-if="isHTTP(item.import_url)" :href="item.import_url" target="_blank">
{{ item.import_url }}
<gl-icon name="external-link" class="gl-vertical-align-middle" />
</gl-link>
<span v-else>{{ item.import_url }}</span>
</template>
<span v-else>{{
s__('BulkImport|Template / File-based import / GitLab Migration')
}}</span>
</template>
<template #cell(destination)="{ item }">
<gl-link :href="item.http_url_to_repo">
{{ item.path_with_namespace }}
</gl-link>
</template>
<template #cell(created_at)="{ value }">
<time-ago :time="value" />
</template>
<template #cell(status)="{ item, toggleDetails, detailsShowing }">
<import-status :status="item.import_status" class="gl-display-inline-block gl-w-13" />
<gl-button
v-if="item.import_status === 'failed'"
class="gl-ml-3"
:selected="detailsShowing"
@click="toggleDetails"
>{{ __('Details') }}</gl-button
>
</template>
<template #row-details="{ item }">
<import-error-details :id="item.id" />
</template>
</gl-table>
<pagination-bar
:page-info="pageInfo"
class="gl-m-0 gl-mt-3"
@set-page="paginationConfig.page = $event"
@set-page-size="paginationConfig.perPage = $event"
/>
</template>
</div>
</template>
import Vue from 'vue';
import ImportHistoryApp from './components/import_history_app.vue';
function mountImportHistoryApp(mountElement) {
if (!mountElement) return undefined;
return new Vue({
el: mountElement,
render(createElement) {
return createElement(ImportHistoryApp);
},
});
}
mountImportHistoryApp(document.querySelector('#import-history-mount-element'));
import { __ } from '~/locale';
export const DEFAULT_ERROR = __('Something went wrong on our end.');
# frozen_string_literal: true
class Import::HistoryController < ApplicationController
feature_category :importers
end
- add_to_breadcrumbs _('Create a new project'), new_project_path
- page_title _('Import history')
#import-history-mount-element
......@@ -3,8 +3,9 @@
.project-import
.form-group.import-btn-container.clearfix
%h5
%h5.gl-display-flex
= _("Import project from")
= link_to _('History'), import_history_index_path, class: 'gl-link gl-ml-auto gl-font-weight-normal'
.import-buttons
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body', qa_selector: 'gitlab_import_button' } }
......
......@@ -10,6 +10,8 @@ Devise.omniauth_providers.map(&:downcase).each do |provider|
end
namespace :import do
resources :history, only: [:index], controller: :history
resources :available_namespaces, only: [:index], controller: :available_namespaces
namespace :url do
......
......@@ -6321,6 +6321,9 @@ msgstr ""
msgid "BulkImport|%{feature} (require v%{version})"
msgstr ""
msgid "BulkImport|Destination"
msgstr ""
msgid "BulkImport|Existing groups"
msgstr ""
......@@ -6363,12 +6366,18 @@ msgstr ""
msgid "BulkImport|New group"
msgstr ""
msgid "BulkImport|No additional information provided."
msgstr ""
msgid "BulkImport|No history is available"
msgstr ""
msgid "BulkImport|No parent"
msgstr ""
msgid "BulkImport|Project import history"
msgstr ""
msgid "BulkImport|Re-import creates a new group. It does not sync with the existing group."
msgstr ""
......@@ -6381,9 +6390,15 @@ msgstr ""
msgid "BulkImport|Showing %{start}-%{end} of %{total} matching filter \"%{filter}\" from %{link}"
msgstr ""
msgid "BulkImport|Source"
msgstr ""
msgid "BulkImport|Source group"
msgstr ""
msgid "BulkImport|Template / File-based import / GitLab Migration"
msgstr ""
msgid "BulkImport|To new group"
msgstr ""
......@@ -6396,6 +6411,9 @@ msgstr ""
msgid "BulkImport|Your imported groups will appear here."
msgstr ""
msgid "BulkImport|Your imported projects will appear here."
msgstr ""
msgid "BulkImport|expected an associated Group but has an associated Project"
msgstr ""
......
import { GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import ImportErrorDetails from '~/pages/import/history/components/import_error_details.vue';
describe('ImportErrorDetails', () => {
const FAKE_ID = 5;
const API_URL = `/api/v4/projects/${FAKE_ID}`;
let wrapper;
let mock;
function createComponent({ shallow = true } = {}) {
const mountFn = shallow ? shallowMount : mount;
wrapper = mountFn(ImportErrorDetails, {
propsData: {
id: FAKE_ID,
},
});
}
const originalApiVersion = gon.api_version;
beforeAll(() => {
gon.api_version = 'v4';
});
afterAll(() => {
gon.api_version = originalApiVersion;
});
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
describe('general behavior', () => {
it('renders loading state when loading', () => {
createComponent();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('renders import_error if it is available', async () => {
const FAKE_IMPORT_ERROR = 'IMPORT ERROR';
mock.onGet(API_URL).reply(200, { import_error: FAKE_IMPORT_ERROR });
createComponent();
await axios.waitForAll();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.find('pre').text()).toBe(FAKE_IMPORT_ERROR);
});
it('renders default text if error is not available', async () => {
mock.onGet(API_URL).reply(200, { import_error: null });
createComponent();
await axios.waitForAll();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.find('pre').text()).toBe('No additional information provided.');
});
});
});
import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import ImportErrorDetails from '~/pages/import/history/components/import_error_details.vue';
import ImportHistoryApp from '~/pages/import/history/components/import_history_app.vue';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
describe('ImportHistoryApp', () => {
const API_URL = '/api/v4/projects.json';
const DEFAULT_HEADERS = {
'x-page': 1,
'x-per-page': 20,
'x-next-page': 2,
'x-total': 22,
'x-total-pages': 2,
'x-prev-page': null,
};
const DUMMY_RESPONSE = [
{
id: 1,
path_with_namespace: 'root/imported',
created_at: '2022-03-10T15:10:03.172Z',
import_url: null,
import_type: 'gitlab_project',
import_status: 'finished',
},
{
id: 2,
name_with_namespace: 'Administrator / Dummy',
path_with_namespace: 'root/dummy',
created_at: '2022-03-09T11:23:04.974Z',
import_url: 'https://dummy.github/url',
import_type: 'github',
import_status: 'failed',
},
{
id: 2,
name_with_namespace: 'Administrator / Dummy',
path_with_namespace: 'root/dummy',
created_at: '2022-03-09T11:23:04.974Z',
import_url: 'git://non-http.url',
import_type: 'gi',
import_status: 'finished',
},
];
let wrapper;
let mock;
function createComponent({ shallow = true } = {}) {
const mountFn = shallow ? shallowMount : mount;
wrapper = mountFn(ImportHistoryApp);
}
const originalApiVersion = gon.api_version;
beforeAll(() => {
gon.api_version = 'v4';
});
afterAll(() => {
gon.api_version = originalApiVersion;
});
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
describe('general behavior', () => {
it('renders loading state when loading', () => {
createComponent();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('renders empty state when no data is available', async () => {
mock.onGet(API_URL).reply(200, [], DEFAULT_HEADERS);
createComponent();
await axios.waitForAll();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.find(GlEmptyState).exists()).toBe(true);
});
it('renders table with data when history is available', async () => {
mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
createComponent();
await axios.waitForAll();
const table = wrapper.find(GlTable);
expect(table.exists()).toBe(true);
// can't use .props() or .attributes() here
expect(table.vm.$attrs.items).toHaveLength(DUMMY_RESPONSE.length);
});
it('changes page when requested by pagination bar', async () => {
const NEW_PAGE = 4;
mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
createComponent();
await axios.waitForAll();
mock.resetHistory();
wrapper.findComponent(PaginationBar).vm.$emit('set-page', NEW_PAGE);
await axios.waitForAll();
expect(mock.history.get.length).toBe(1);
expect(mock.history.get[0].params).toStrictEqual(expect.objectContaining({ page: NEW_PAGE }));
});
});
it('changes page size when requested by pagination bar', async () => {
const NEW_PAGE_SIZE = 4;
mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
createComponent();
await axios.waitForAll();
mock.resetHistory();
wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE);
await axios.waitForAll();
expect(mock.history.get.length).toBe(1);
expect(mock.history.get[0].params).toStrictEqual(
expect.objectContaining({ per_page: NEW_PAGE_SIZE }),
);
});
describe('details button', () => {
beforeEach(() => {
mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
createComponent({ shallow: false });
return axios.waitForAll();
});
it('renders details button if relevant item has failed', async () => {
expect(
extendedWrapper(wrapper.find('tbody').findAll('tr').at(1)).findByText('Details').exists(),
).toBe(true);
});
it('does not render details button if relevant item does not failed', () => {
expect(
extendedWrapper(wrapper.find('tbody').findAll('tr').at(0)).findByText('Details').exists(),
).toBe(false);
});
it('expands details when details button is clicked', async () => {
const ORIGINAL_ROW_INDEX = 1;
await extendedWrapper(wrapper.find('tbody').findAll('tr').at(ORIGINAL_ROW_INDEX))
.findByText('Details')
.trigger('click');
const detailsRowContent = wrapper
.find('tbody')
.findAll('tr')
.at(ORIGINAL_ROW_INDEX + 1)
.findComponent(ImportErrorDetails);
expect(detailsRowContent.exists()).toBe(true);
expect(detailsRowContent.props().id).toBe(DUMMY_RESPONSE[1].id);
});
});
});
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