Commit f693a63b authored by Illya Klymov's avatar Illya Klymov Committed by Natalia Tepluhina

Implement detailed github status reporting

* expose github stats data to user

Changelog: added
parent cbc35aaa
<script>
import { GlIcon } from '@gitlab/ui';
import STATUS_MAP from '../constants';
import { GlAccordion, GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __, s__ } from '~/locale';
import { STATUSES } from '../constants';
const STATISTIC_ITEMS = {
diff_note: __('Diff notes'),
issue: __('Issues'),
label: __('Labels'),
milestone: __('Milestones'),
note: __('Notes'),
pull_request: s__('GithubImporter|Pull requests'),
pull_request_merged_by: s__('GithubImporter|PR mergers'),
pull_request_review: s__('GithubImporter|PR reviews'),
release: __('Releases'),
};
// support both camel case and snake case versions
Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS));
const SCHEDULED_STATUS = {
icon: 'status-scheduled',
text: __('Pending'),
variant: 'muted',
};
const STATUS_MAP = {
[STATUSES.NONE]: {
icon: 'status-waiting',
text: __('Not started'),
variant: 'muted',
},
[STATUSES.SCHEDULING]: SCHEDULED_STATUS,
[STATUSES.SCHEDULED]: SCHEDULED_STATUS,
[STATUSES.CREATED]: SCHEDULED_STATUS,
[STATUSES.STARTED]: {
icon: 'status-running',
text: __('Importing...'),
variant: 'info',
},
[STATUSES.FAILED]: {
icon: 'status-failed',
text: __('Failed'),
variant: 'danger',
},
[STATUSES.CANCELLED]: {
icon: 'status-stopped',
text: __('Cancelled'),
variant: 'neutral',
},
};
function isIncompleteImport(stats) {
return Object.keys(stats.fetched).some((key) => stats.fetched[key] !== stats.imported[key]);
}
export default {
name: 'ImportStatus',
components: {
GlAccordion,
GlAccordionItem,
GlBadge,
GlIcon,
},
props: {
......@@ -12,19 +68,88 @@ export default {
type: String,
required: true,
},
stats: {
type: Object,
required: false,
default: () => ({ fetched: {}, imported: {} }),
},
},
computed: {
knownStats() {
const knownStatisticKeys = Object.keys(STATISTIC_ITEMS);
return Object.keys(this.stats.fetched).filter((key) => knownStatisticKeys.includes(key));
},
hasStats() {
return this.stats && this.knownStats.length > 0;
},
mappedStatus() {
if (this.status === STATUSES.FINISHED) {
const isIncomplete = this.stats && isIncompleteImport(this.stats);
return {
icon: 'status-success',
...(isIncomplete
? {
text: __('Partial import'),
variant: 'warning',
}
: {
text: __('Complete'),
variant: 'success',
}),
};
}
return STATUS_MAP[this.status];
},
},
methods: {
getStatisticIconProps(key) {
const fetched = this.stats.fetched[key];
const imported = this.stats.imported[key];
if (fetched === imported) {
return { name: 'status-success', class: 'gl-text-green-400' };
} else if (imported === 0) {
return { name: 'status-scheduled', class: 'gl-text-gray-400' };
}
return { name: 'status-running', class: 'gl-text-blue-400' };
},
},
STATISTIC_ITEMS,
};
</script>
<template>
<div>
<gl-icon :name="mappedStatus.icon" :class="mappedStatus.iconClass" :size="12" class="gl-mr-2" />
<span>{{ mappedStatus.text }}</span>
<div class="gl-display-inline-block gl-w-13">
<gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" class="gl-mr-2">
{{ mappedStatus.text }}
</gl-badge>
</div>
<gl-accordion v-if="hasStats" :header-level="3">
<gl-accordion-item :title="__('Details')">
<ul class="gl-p-0 gl-list-style-none gl-font-sm">
<li v-for="key in knownStats" :key="key">
<div class="gl-display-flex gl-w-20 gl-align-items-center">
<gl-icon
:size="12"
class="gl-mr-3 gl-flex-shrink-0"
v-bind="getStatisticIconProps(key)"
/>
<span class="">{{ $options.STATISTIC_ITEMS[key] }}</span>
<span class="gl-ml-auto">
{{ stats.imported[key] || 0 }}/{{ stats.fetched[key] }}
</span>
</div>
</li>
</ul>
</gl-accordion-item>
</gl-accordion>
</div>
</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.
......@@ -13,42 +11,3 @@ export const STATUSES = {
SCHEDULING: 'scheduling',
CANCELLED: 'cancelled',
};
const SCHEDULED_STATUS = {
icon: 'status-scheduled',
text: __('Pending'),
iconClass: 'gl-text-orange-400',
};
const STATUS_MAP = {
[STATUSES.NONE]: {
icon: 'status-waiting',
text: __('Not started'),
iconClass: 'gl-text-gray-400',
},
[STATUSES.SCHEDULING]: SCHEDULED_STATUS,
[STATUSES.SCHEDULED]: SCHEDULED_STATUS,
[STATUSES.CREATED]: SCHEDULED_STATUS,
[STATUSES.STARTED]: {
icon: 'status-running',
text: __('Importing...'),
iconClass: 'gl-text-blue-400',
},
[STATUSES.FINISHED]: {
icon: 'status-success',
text: __('Complete'),
iconClass: 'gl-text-green-400',
},
[STATUSES.FAILED]: {
icon: 'status-failed',
text: __('Failed'),
iconClass: 'gl-text-red-600',
},
[STATUSES.CANCELLED]: {
icon: 'status-stopped',
text: __('Cancelled'),
iconClass: 'gl-text-red-600',
},
};
export default STATUS_MAP;
......@@ -69,6 +69,10 @@ export default {
return getImportStatus(this.repo);
},
stats() {
return this.repo.importedProject?.stats;
},
importTarget() {
return this.getImportTarget(this.repo.importSource.id);
},
......@@ -101,11 +105,11 @@ export default {
<template>
<tr
class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11"
class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11 gl-vertical-align-top"
data-qa-selector="project_import_row"
:data-qa-source-project="repo.importSource.fullName"
>
<td class="gl-p-4">
<td class="gl-p-4 gl-vertical-align-top">
<gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink"
>{{ repo.importSource.fullName }}
<gl-icon v-if="repo.importSource.providerLink" name="external-link" />
......@@ -156,10 +160,10 @@ export default {
</template>
<template v-else-if="repo.importedProject">{{ displayFullPath }}</template>
</td>
<td class="gl-p-4" data-qa-selector="import_status_indicator">
<import-status :status="importStatus" />
<td class="gl-p-4 gl-vertical-align-top" data-qa-selector="import_status_indicator">
<import-status :status="importStatus" :stats="stats" />
</td>
<td data-testid="actions">
<td data-testid="actions" class="gl-vertical-align-top gl-pt-4">
<gl-button
v-if="isFinished"
class="btn btn-default"
......
......@@ -113,7 +113,11 @@ export default {
updatedProjects.forEach((updatedProject) => {
const repo = state.repositories.find((p) => p.importedProject?.id === updatedProject.id);
if (repo?.importedProject) {
repo.importedProject.importStatus = updatedProject.importStatus;
repo.importedProject = {
...repo.importedProject,
stats: updatedProject.stats,
importStatus: updatedProject.importStatus,
};
}
});
},
......
@import 'mixins_and_variables_and_functions';
.import-jobs-from-col {
width: 37%;
}
.import-jobs-to-col {
width: 39%;
width: 37%;
}
.import-jobs-status-col {
width: 15%;
width: 25%;
}
.import-jobs-cta-col {
width: 1%;
}
.import-entities-target-select {
&.disabled {
.import-entities-target-select-separator {
......
......@@ -13016,6 +13016,9 @@ msgstr ""
msgid "Diff limits"
msgstr ""
msgid "Diff notes"
msgstr ""
msgid "Difference between start date and now"
msgstr ""
......@@ -17085,6 +17088,15 @@ msgstr ""
msgid "Gitea Import"
msgstr ""
msgid "GithubImporter|PR mergers"
msgstr ""
msgid "GithubImporter|PR reviews"
msgstr ""
msgid "GithubImporter|Pull requests"
msgstr ""
msgid "GithubIntegration|Create a %{token_link_start}personal access token%{token_link_end} with %{status_html} access granted and paste it here."
msgstr ""
......@@ -25642,6 +25654,9 @@ msgstr ""
msgid "NoteForm|Note"
msgstr ""
msgid "Notes"
msgstr ""
msgid "Notes rate limit"
msgstr ""
......@@ -27010,6 +27025,9 @@ msgstr ""
msgid "Part of merge request changes"
msgstr ""
msgid "Partial import"
msgstr ""
msgid "Participants"
msgstr ""
......
import { GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ImportStatus from '~/import_entities/components/import_status.vue';
import { STATUSES } from '~/import_entities/constants';
describe('Import entities status component', () => {
let wrapper;
const createComponent = (propsData) => {
wrapper = shallowMount(ImportStatus, {
propsData,
});
};
afterEach(() => {
wrapper.destroy();
});
describe('success status', () => {
const getStatusText = () => wrapper.findComponent(GlBadge).text();
it('displays finished status as complete when no stats are provided', () => {
createComponent({
status: STATUSES.FINISHED,
});
expect(getStatusText()).toBe('Complete');
});
it('displays finished status as complete when all stats items were processed', () => {
const statItems = { label: 100, note: 200 };
createComponent({
status: STATUSES.FINISHED,
stats: {
fetched: { ...statItems },
imported: { ...statItems },
},
});
expect(getStatusText()).toBe('Complete');
});
it('displays finished status as partial when all stats items were processed', () => {
const statItems = { label: 100, note: 200 };
createComponent({
status: STATUSES.FINISHED,
stats: {
fetched: { ...statItems },
imported: { ...statItems, label: 50 },
},
});
expect(getStatusText()).toBe('Partial import');
});
});
describe('details drawer', () => {
const findDetailsDrawer = () => wrapper.findComponent(GlAccordionItem);
it('renders details drawer to be present when stats are provided', () => {
createComponent({
status: 'created',
stats: { fetched: { label: 1 }, imported: { label: 0 } },
});
expect(findDetailsDrawer().exists()).toBe(true);
});
it('does not render details drawer when no stats are provided', () => {
createComponent({
status: 'created',
});
expect(findDetailsDrawer().exists()).toBe(false);
});
it('does not render details drawer when stats are empty', () => {
createComponent({
status: 'created',
stats: { fetched: {}, imported: {} },
});
expect(findDetailsDrawer().exists()).toBe(false);
});
it('does not render details drawer when no known stats are provided', () => {
createComponent({
status: 'created',
stats: {
fetched: {
UNKNOWN_STAT: 100,
},
imported: {
UNKNOWN_STAT: 0,
},
},
});
expect(findDetailsDrawer().exists()).toBe(false);
});
});
describe('stats display', () => {
const getStatusIcon = () =>
wrapper.findComponent(GlAccordionItem).findComponent(GlIcon).props().name;
const createComponentWithStats = ({ fetched, imported }) => {
createComponent({
status: 'created',
stats: {
fetched: { label: fetched },
imported: { label: imported },
},
});
};
it('displays scheduled status when imported is 0', () => {
createComponentWithStats({
fetched: 100,
imported: 0,
});
expect(getStatusIcon()).toBe('status-scheduled');
});
it('displays running status when imported is not equal to fetched', () => {
createComponentWithStats({
fetched: 100,
imported: 10,
});
expect(getStatusIcon()).toBe('status-running');
});
it('displays success status when imported is equal to fetched', () => {
createComponentWithStats({
fetched: 100,
imported: 100,
});
expect(getStatusIcon()).toBe('status-success');
});
});
});
......@@ -98,6 +98,8 @@ describe('ProviderRepoTableRow', () => {
});
describe('when rendering imported project', () => {
const FAKE_STATS = {};
const repo = {
importSource: {
id: 'remote-1',
......@@ -109,6 +111,7 @@ describe('ProviderRepoTableRow', () => {
fullPath: 'fullPath',
importSource: 'importSource',
importStatus: STATUSES.FINISHED,
stats: FAKE_STATS,
},
};
......@@ -134,6 +137,10 @@ describe('ProviderRepoTableRow', () => {
it('does not render import button', () => {
expect(findImportButton().exists()).toBe(false);
});
it('passes stats to import status component', () => {
expect(wrapper.find(ImportStatus).props().stats).toBe(FAKE_STATS);
});
});
describe('when rendering incompatible project', () => {
......
......@@ -232,6 +232,35 @@ describe('import_projects store mutations', () => {
updatedProjects[0].importStatus,
);
});
it('updates import stats of project', () => {
const repoId = 1;
state = {
repositories: [
{ importedProject: { id: repoId, stats: {} }, importStatus: STATUSES.STARTED },
],
};
const newStats = {
fetched: {
label: 10,
},
imported: {
label: 1,
},
};
const updatedProjects = [
{
id: repoId,
importStatus: STATUSES.FINISHED,
stats: newStats,
},
];
mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects);
expect(state.repositories[0].importedProject.stats).toStrictEqual(newStats);
});
});
describe(`${types.REQUEST_NAMESPACES}`, () => {
......
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