Commit da2f1b46 authored by Illya Klymov's avatar Illya Klymov Committed by Phil Hughes

View History of All Group Imports - Frontend

parent b52f21df
import { buildApiUrl } from '~/api/api_utils';
import axios from '~/lib/utils/axios_utils';
const BULK_IMPORT_ENTITIES_PATH = '/api/:version/bulk_imports/entities';
export const getBulkImportsHistory = (params) =>
axios.get(buildApiUrl(BULK_IMPORT_ENTITIES_PATH), { params });
<script>
import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
const DEFAULT_PAGE_SIZES = [20, 50, 100];
export default {
components: {
PaginationLinks,
GlDropdown,
GlDropdownItem,
GlIcon,
GlSprintf,
},
props: {
pageInfo: {
required: true,
type: Object,
},
pageSizes: {
required: false,
type: Array,
default: () => DEFAULT_PAGE_SIZES,
},
itemsCount: {
required: true,
type: Number,
},
},
computed: {
humanizedTotal() {
return this.pageInfo.total >= 1000 ? __('1000+') : this.pageInfo.total;
},
paginationInfo() {
const { page, perPage } = this.pageInfo;
const start = (page - 1) * perPage + 1;
const end = start + this.itemsCount - 1;
return { start, end };
},
},
methods: {
setPage(page) {
this.$emit('set-page', page);
},
},
};
</script>
<template>
<div class="gl-display-flex gl-align-items-center">
<pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" />
<gl-dropdown category="tertiary" class="gl-ml-auto">
<template #button-content>
<span class="gl-font-weight-bold">
<gl-sprintf :message="__('%{count} items per page')">
<template #count>
{{ pageInfo.perPage }}
</template>
</gl-sprintf>
</span>
<gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" />
</template>
<gl-dropdown-item v-for="size in pageSizes" :key="size" @click="$emit('set-page-size', size)">
<gl-sprintf :message="__('%{count} items per page')">
<template #count>
{{ size }}
</template>
</gl-sprintf>
</gl-dropdown-item>
</gl-dropdown>
<div class="gl-ml-2" data-testid="information">
<gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')">
<template #start>
{{ paginationInfo.start }}
</template>
<template #end>
{{ paginationInfo.end }}
</template>
<template #total>
{{ humanizedTotal }}
</template>
</gl-sprintf>
</div>
</div>
</template>
<script>
import { GlButton, GlEmptyState, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import createFlash from '~/flash';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { getBulkImportsHistory } from '~/rest_api';
import ImportStatus from '~/import_entities/components/import_status.vue';
import PaginationBar from '~/import_entities/components/pagination_bar.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { DEFAULT_ERROR } from '../utils/error_messages';
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,
GlLink,
GlLoadingIcon,
GlTable,
PaginationBar,
ImportStatus,
TimeAgo,
},
data() {
return {
loading: true,
historyItems: [],
paginationConfig: {
page: 1,
perPage: DEFAULT_PER_PAGE,
},
pageInfo: {},
};
},
fields: [
tableCell({
key: 'source_full_path',
label: s__('BulkImport|Source group'),
thClass: `${DEFAULT_TH_CLASSES} gl-w-30p`,
}),
tableCell({
key: 'destination_name',
label: s__('BulkImport|New group'),
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 getBulkImportsHistory({
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;
}
},
getDestinationUrl({ destination_name: name, destination_namespace: namespace }) {
return [namespace, name].filter(Boolean).join('/');
},
getFullDestinationUrl(params) {
return joinPaths(gon.relative_url_root || '', this.getDestinationUrl(params));
},
},
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|Group 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 groups 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(destination_name)="{ item }">
<gl-link :href="getFullDestinationUrl(item)" target="_blank">
{{ getDestinationUrl(item) }}
</gl-link>
</template>
<template #cell(created_at)="{ value }">
<time-ago :time="value" />
</template>
<template #cell(status)="{ value, item, toggleDetails, detailsShowing }">
<import-status :status="value" class="gl-display-inline-block gl-w-13" />
<gl-button
v-if="item.failures.length"
class="gl-ml-3"
:selected="detailsShowing"
@click="toggleDetails"
>{{ __('Details') }}</gl-button
>
</template>
<template #row-details="{ item }">
<pre>{{ item.failures }}</pre>
</template>
</gl-table>
<pagination-bar
:page-info="pageInfo"
:items-count="historyItems.length"
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 BulkImportHistoryApp from './components/bulk_imports_history_app.vue';
function mountImportHistoryApp(mountElement) {
if (!mountElement) return undefined;
return new Vue({
el: mountElement,
render(createElement) {
return createElement(BulkImportHistoryApp);
},
});
}
mountImportHistoryApp(document.querySelector('#import-history-mount-element'));
import { __ } from '~/locale';
export const DEFAULT_ERROR = __('Something went wrong on our end.');
......@@ -2,6 +2,7 @@ export * from './api/groups_api';
export * from './api/projects_api';
export * from './api/user_api';
export * from './api/markdown_api';
export * from './api/bulk_imports_api';
// Note: It's not possible to spy on methods imported from this file in
// Jest tests.
......
= form_with url: configure_import_bulk_imports_path, class: 'group-form gl-show-field-errors' do |f|
.gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5
%h4.gl-display-flex
= s_('GroupsNew|Import groups from another instance of GitLab')
%span.badge.badge-info.badge-pill.gl-badge.md.gl-ml-3
= _('Beta')
.gl-display-flex.gl-align-items-center
%h4.gl-display-inline-flex
= s_('GroupsNew|Import groups from another instance of GitLab')
%span.badge.badge-info.badge-pill.gl-badge.md.gl-ml-3
= _('Beta')
= link_to _('History'), history_import_bulk_imports_path, class: 'gl-link gl-ml-auto'
.gl-alert.gl-alert-warning{ role: 'alert' }
= sprite_icon('warning', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title')
.gl-alert-body
......
- add_to_breadcrumbs _('New group'), new_group_path
- add_to_breadcrumbs _('Import group'), new_group_path(anchor: 'import-group-pane')
- add_page_specific_style 'page_bundles/import'
- page_title _('Import history')
#import-history-mount-element
......@@ -70,6 +70,7 @@ namespace :import do
post :configure
get :status
get :realtime_changes
get :history
end
resource :manifest, only: [:create, :new], controller: :manifest do
......
......@@ -5860,6 +5860,9 @@ msgstr ""
msgid "BulkImport|From source group"
msgstr ""
msgid "BulkImport|Group import history"
msgstr ""
msgid "BulkImport|Import failed: Destination cannot be a subgroup of the source group. Change the destination and try again."
msgstr ""
......@@ -5878,6 +5881,12 @@ msgstr ""
msgid "BulkImport|Name already exists."
msgstr ""
msgid "BulkImport|New group"
msgstr ""
msgid "BulkImport|No history is available"
msgstr ""
msgid "BulkImport|No parent"
msgstr ""
......@@ -5890,6 +5899,9 @@ msgstr ""
msgid "BulkImport|Showing %{start}-%{end} of %{total} matching filter \"%{filter}\" from %{link}"
msgstr ""
msgid "BulkImport|Source group"
msgstr ""
msgid "BulkImport|To new group"
msgstr ""
......@@ -5899,6 +5911,9 @@ msgstr ""
msgid "BulkImport|You have no groups to import"
msgstr ""
msgid "BulkImport|Your imported groups will appear here."
msgstr ""
msgid "BulkImport|expected an associated Group but has an associated Project"
msgstr ""
......@@ -17208,12 +17223,18 @@ msgstr ""
msgid "Import from Jira"
msgstr ""
msgid "Import group"
msgstr ""
msgid "Import group from file"
msgstr ""
msgid "Import groups"
msgstr ""
msgid "Import history"
msgstr ""
msgid "Import in progress"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Import/Export - GitLab migration history', :js do
let_it_be(:user) { create(:user) }
let_it_be(:user_import_1) { create(:bulk_import, user: user) }
let_it_be(:finished_entity_1) { create(:bulk_import_entity, :finished, bulk_import: user_import_1) }
let_it_be(:user_import_2) { create(:bulk_import, user: user) }
let_it_be(:failed_entity_2) { create(:bulk_import_entity, :failed, bulk_import: user_import_2) }
before do
gitlab_sign_in(user)
visit new_group_path
click_link 'Import group'
end
it 'successfully displays import history' do
click_link 'History'
wait_for_requests
expect(page).to have_content 'Group import history'
expect(page.find('tbody')).to have_css('tr', count: 2)
end
end
import { GlPagination, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import PaginationBar from '~/import_entities/components/pagination_bar.vue';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
describe('Pagination bar', () => {
const DEFAULT_PROPS = {
pageInfo: {
total: 50,
page: 1,
perPage: 20,
},
itemsCount: 17,
};
let wrapper;
const createComponent = (propsData) => {
wrapper = mount(PaginationBar, {
propsData: {
...DEFAULT_PROPS,
...propsData,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('events', () => {
beforeEach(() => {
createComponent();
});
it('emits set-page event when page is selected', () => {
const NEXT_PAGE = 3;
// PaginationLinks uses prop instead of event for handling page change
// So we go one level deep to test this
wrapper
.findComponent(PaginationLinks)
.findComponent(GlPagination)
.vm.$emit('input', NEXT_PAGE);
expect(wrapper.emitted('set-page')).toEqual([[NEXT_PAGE]]);
});
it('emits set-page-size event when page size is selected', () => {
const firstItemInPageSizeDropdown = wrapper.findComponent(GlDropdownItem);
firstItemInPageSizeDropdown.vm.$emit('click');
const [emittedPageSizeChange] = wrapper.emitted('set-page-size')[0];
expect(firstItemInPageSizeDropdown.text()).toMatchInterpolatedText(
`${emittedPageSizeChange} items per page`,
);
});
});
it('renders current page size', () => {
const CURRENT_PAGE_SIZE = 40;
createComponent({
pageInfo: {
...DEFAULT_PROPS.pageInfo,
perPage: CURRENT_PAGE_SIZE,
},
});
expect(wrapper.find(GlDropdown).find('button').text()).toMatchInterpolatedText(
`${CURRENT_PAGE_SIZE} items per page`,
);
});
it('renders current page information', () => {
createComponent();
expect(wrapper.find('[data-testid="information"]').text()).toMatchInterpolatedText(
'Showing 1 - 17 of 50',
);
});
it('renders current page information when total count is over 1000', () => {
createComponent({
pageInfo: {
...DEFAULT_PROPS.pageInfo,
total: 1200,
},
});
expect(wrapper.find('[data-testid="information"]').text()).toMatchInterpolatedText(
'Showing 1 - 17 of 1000+',
);
});
});
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 PaginationBar from '~/import_entities/components/pagination_bar.vue';
import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
describe('BulkImportsHistoryApp', () => {
const API_URL = '/api/v4/bulk_imports/entities';
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,
bulk_import_id: 1,
status: 'finished',
source_full_path: 'top-level-group-12',
destination_name: 'top-level-group-12',
destination_namespace: 'h5bp',
created_at: '2021-07-08T10:03:44.743Z',
failures: [],
},
{
id: 2,
bulk_import_id: 2,
status: 'failed',
source_full_path: 'autodevops-demo',
destination_name: 'autodevops-demo',
destination_namespace: 'flightjs',
parent_id: null,
namespace_id: null,
project_id: null,
created_at: '2021-07-13T12:52:26.664Z',
updated_at: '2021-07-13T13:34:49.403Z',
failures: [
{
pipeline_class: 'BulkImports::Groups::Pipelines::GroupPipeline',
pipeline_step: 'loader',
exception_class: 'ActiveRecord::RecordNotUnique',
correlation_id_value: '01FAFYSYZ7XPF3P9NSMTS693SZ',
created_at: '2021-07-13T13:34:49.344Z',
},
],
},
];
let wrapper;
let mock;
function createComponent({ shallow = true } = {}) {
const mountFn = shallow ? shallowMount : mount;
wrapper = mountFn(BulkImportsHistoryApp);
}
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 failures', async () => {
expect(
extendedWrapper(wrapper.find('tbody').findAll('tr').at(1)).findByText('Details').exists(),
).toBe(true);
});
it('does not render details button if relevant item has no failures', () => {
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)
.find('pre');
expect(detailsRowContent.exists()).toBe(true);
expect(JSON.parse(detailsRowContent.text())).toStrictEqual(DUMMY_RESPONSE[1].failures);
});
});
});
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