Commit aaeffccc authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch...

Merge branch '341185-importing-group-as-a-top-level-group-targeting-an-existing-namespace-give-false-positive' into 'master'

Resolve "Importing group as a top level group targeting an existing namespace give false positive status"

See merge request gitlab-org/gitlab!71834
parents 4bef03b8 0c8e7f53
......@@ -3,10 +3,11 @@ import axios from '~/lib/utils/axios_utils';
const NAMESPACE_EXISTS_PATH = '/api/:version/namespaces/:id/exists';
export default function fetchGroupPathAvailability(groupPath, parentId) {
export function getGroupPathAvailability(groupPath, parentId, axiosOptions = {}) {
const url = buildApiUrl(NAMESPACE_EXISTS_PATH).replace(':id', encodeURIComponent(groupPath));
return axios.get(url, {
params: { parent_id: parentId },
params: { parent_id: parentId, ...axiosOptions.params },
...axiosOptions,
});
}
import createFlash from '~/flash';
import { __ } from '~/locale';
import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability';
import { getGroupPathAvailability } from '~/rest_api';
import { slugify } from './lib/utils/text_utility';
export default class Group {
......@@ -51,7 +51,7 @@ export default class Group {
const slug = this.groupPaths[0]?.value || slugify(value);
if (!slug) return;
fetchGroupPathAvailability(slug, this.parentId?.value)
getGroupPathAvailability(slug, this.parentId?.value)
.then(({ data }) => data)
.then(({ exists, suggests }) => {
if (exists && suggests.length) {
......
......@@ -19,7 +19,7 @@ export default {
computed: {
filteredNamespaces() {
return this.namespaces.filter((ns) =>
ns.toLowerCase().includes(this.searchTerm.toLowerCase()),
ns.fullPath.toLowerCase().includes(this.searchTerm.toLowerCase()),
);
},
},
......
<script>
import { GlButton, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import { isFinished, isInvalid, isAvailableForImport } from '../utils';
export default {
components: {
......@@ -12,32 +10,17 @@ export default {
GlTooltip,
},
props: {
group: {
type: Object,
isFinished: {
type: Boolean,
required: true,
},
groupPathRegex: {
type: RegExp,
isAvailableForImport: {
type: Boolean,
required: true,
},
},
computed: {
fullLastImportPath() {
return this.group.last_import_target
? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}`
: null;
},
absoluteLastImportPath() {
return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath);
},
isAvailableForImport() {
return isAvailableForImport(this.group);
},
isFinished() {
return isFinished(this.group);
},
isInvalid() {
return isInvalid(this.group, this.groupPathRegex);
isInvalid: {
type: Boolean,
required: true,
},
},
};
......@@ -56,7 +39,7 @@ export default {
{{ isFinished ? __('Re-import') : __('Import') }}
</gl-button>
<gl-icon
v-if="isFinished"
v-if="isAvailableForImport && isFinished"
v-gl-tooltip
:size="16"
name="information-o"
......
<script>
import { GlLink, GlSprintf, GlIcon } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import { isFinished } from '../utils';
export default {
components: {
......@@ -17,16 +16,13 @@ export default {
},
computed: {
fullLastImportPath() {
return this.group.last_import_target
? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}`
return this.group.lastImportTarget
? `${this.group.lastImportTarget.targetNamespace}/${this.group.lastImportTarget.newName}`
: null;
},
absoluteLastImportPath() {
return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath);
},
isFinished() {
return isFinished(this.group);
},
},
};
</script>
......@@ -34,13 +30,13 @@ export default {
<template>
<div>
<gl-link
:href="group.web_url"
:href="group.webUrl"
target="_blank"
class="gl-display-inline-flex gl-align-items-center gl-h-7"
>
{{ group.full_path }} <gl-icon name="external-link" />
{{ group.fullPath }} <gl-icon name="external-link" />
</gl-link>
<div v-if="isFinished && fullLastImportPath" class="gl-font-sm">
<div v-if="group.flags.isFinished && fullLastImportPath" class="gl-font-sm">
<gl-sprintf :message="s__('BulkImport|Last imported to %{link}')">
<template #link>
<gl-link :href="absoluteLastImportPath" class="gl-font-sm" target="_blank">{{
......
......@@ -12,18 +12,28 @@ import {
GlTable,
GlFormCheckbox,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { s__, __, n__ } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import { getGroupPathAvailability } from '~/rest_api';
import axios from '~/lib/utils/axios_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { STATUSES } from '../../constants';
import ImportStatusCell from '../../components/import_status.vue';
import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql';
import setImportTargetMutation from '../graphql/mutations/set_import_target.mutation.graphql';
import updateImportStatusMutation from '../graphql/mutations/update_import_status.mutation.graphql';
import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql';
import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql';
import { isInvalid, isFinished, isAvailableForImport } from '../utils';
import { NEW_NAME_FIELD, i18n } from '../constants';
import { StatusPoller } from '../services/status_poller';
import { isFinished, isAvailableForImport, isNameValid, isSameTarget } from '../utils';
import ImportActionsCell from './import_actions_cell.vue';
import ImportSourceCell from './import_source_cell.vue';
import ImportTargetCell from './import_target_cell.vue';
const VALIDATION_DEBOUNCE_TIME = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
const PAGE_SIZES = [20, 50, 100];
const DEFAULT_PAGE_SIZE = PAGE_SIZES[0];
const DEFAULT_TH_CLASSES =
......@@ -59,7 +69,7 @@ export default {
type: RegExp,
required: true,
},
groupUrlErrorMessage: {
jobsPath: {
type: String,
required: true,
},
......@@ -70,7 +80,9 @@ export default {
filter: '',
page: 1,
perPage: DEFAULT_PAGE_SIZE,
selectedGroups: [],
selectedGroupsIds: [],
pendingGroupsIds: [],
importTargets: {},
};
},
......@@ -94,14 +106,14 @@ export default {
tdClass: `${DEFAULT_TD_CLASSES} gl-pr-3!`,
},
{
key: 'web_url',
key: 'webUrl',
label: s__('BulkImport|From source group'),
thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! import-jobs-from-col`,
// eslint-disable-next-line @gitlab/require-i18n-strings
tdClass: `${DEFAULT_TD_CLASSES} gl-pl-0!`,
},
{
key: 'import_target',
key: 'importTarget',
label: s__('BulkImport|To new group'),
thClass: `${DEFAULT_TH_CLASSES} import-jobs-to-col`,
tdClass: DEFAULT_TD_CLASSES,
......@@ -126,16 +138,39 @@ export default {
return this.bulkImportSourceGroups?.nodes ?? [];
},
groupsTableData() {
return this.groups.map((group) => {
const importTarget = this.getImportTarget(group);
const status = this.getStatus(group);
const flags = {
isInvalid: importTarget.validationErrors?.length > 0,
isAvailableForImport: isAvailableForImport(group) && status !== STATUSES.SCHEDULING,
isFinished: isFinished(group),
};
return {
...group,
visibleStatus: status,
importTarget,
flags: {
...flags,
isUnselectable: !flags.isAvailableForImport || flags.isInvalid,
},
};
});
},
hasSelectedGroups() {
return this.selectedGroups.length > 0;
return this.selectedGroupsIds.length > 0;
},
hasAllAvailableGroupsSelected() {
return this.selectedGroups.length === this.availableGroupsForImport.length;
return this.selectedGroupsIds.length === this.availableGroupsForImport.length;
},
availableGroupsForImport() {
return this.groups.filter((g) => isAvailableForImport(g) && !this.isInvalid(g));
return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && g.flags.isInvalid);
},
humanizedTotal() {
......@@ -175,25 +210,43 @@ export default {
filter() {
this.page = 1;
},
groups() {
groupsTableData() {
const table = this.getTableRef();
this.groups.forEach((g, idx) => {
if (this.selectedGroups.includes(g)) {
const matches = new Set();
this.groupsTableData.forEach((g, idx) => {
if (this.selectedGroupsIds.includes(g.id)) {
matches.add(g.id);
this.$nextTick(() => {
table.selectRow(idx);
});
}
});
this.selectedGroups = [];
this.selectedGroupsIds = this.selectedGroupsIds.filter((id) => matches.has(id));
},
},
methods: {
isUnselectable(group) {
return !this.isAvailableForImport(group) || this.isInvalid(group);
},
mounted() {
this.statusPoller = new StatusPoller({
pollPath: this.jobsPath,
updateImportStatus: (update) => {
this.$apollo.mutate({
mutation: updateImportStatusMutation,
variables: { id: update.id, status: update.status_name },
});
},
});
rowClasses(group) {
this.statusPoller.startPolling();
},
beforeDestroy() {
this.statusPoller.stopPolling();
},
methods: {
rowClasses(groupTableItem) {
const DEFAULT_CLASSES = [
'gl-border-gray-200',
'gl-border-0',
......@@ -201,7 +254,7 @@ export default {
'gl-border-solid',
];
const result = [...DEFAULT_CLASSES];
if (this.isUnselectable(group)) {
if (groupTableItem.flags.isUnselectable) {
result.push('gl-cursor-default!');
}
return result;
......@@ -211,19 +264,13 @@ export default {
if (type === 'row') {
return {
'data-qa-selector': 'import_item',
'data-qa-source-group': group.full_path,
'data-qa-source-group': group.fullPath,
};
}
return {};
},
isAvailableForImport,
isFinished,
isInvalid(group) {
return isInvalid(group, this.groupPathRegex);
},
groupsCount(count) {
return n__('%d group', '%d groups', count);
},
......@@ -232,22 +279,64 @@ export default {
this.page = page;
},
updateImportTarget(sourceGroupId, targetNamespace, newName) {
this.$apollo.mutate({
mutation: setImportTargetMutation,
variables: { sourceGroupId, targetNamespace, newName },
});
getStatus(group) {
if (this.pendingGroupsIds.includes(group.id)) {
return STATUSES.SCHEDULING;
}
return group.progress?.status || STATUSES.NONE;
},
importGroups(sourceGroupIds) {
this.$apollo.mutate({
mutation: importGroupsMutation,
variables: { sourceGroupIds },
updateImportTarget(group, changes) {
const newImportTarget = {
...group.importTarget,
...changes,
};
this.$set(this.importTargets, group.id, newImportTarget);
this.validateImportTarget(newImportTarget);
},
async importGroups(importRequests) {
const newPendingGroupsIds = importRequests.map((request) => request.sourceGroupId);
newPendingGroupsIds.forEach((id) => {
this.importTargets[id].validationErrors = [
{ field: NEW_NAME_FIELD, message: i18n.ERROR_IMPORT_COMPLETED },
];
if (!this.pendingGroupsIds.includes(id)) {
this.pendingGroupsIds.push(id);
}
});
try {
await this.$apollo.mutate({
mutation: importGroupsMutation,
variables: { importRequests },
});
} catch (error) {
const message = error?.networkError?.response?.data?.error ?? i18n.ERROR_IMPORT;
createFlash({
message,
captureError: true,
error,
});
} finally {
this.pendingGroupsIds = this.pendingGroupsIds.filter(
(id) => !newPendingGroupsIds.includes(id),
);
}
},
importSelectedGroups() {
this.importGroups(this.selectedGroups.map((g) => g.id));
const importRequests = this.groupsTableData
.filter((group) => this.selectedGroupsIds.includes(group.id))
.map((group) => ({
sourceGroupId: group.id,
targetNamespace: group.importTarget.targetNamespace.fullPath,
newName: group.importTarget.newName,
}));
this.importGroups(importRequests);
},
setPageSize(size) {
......@@ -263,16 +352,115 @@ export default {
preventSelectingAlreadyImportedGroups(updatedSelection) {
if (updatedSelection) {
this.selectedGroups = updatedSelection;
this.selectedGroupsIds = updatedSelection.map((g) => g.id);
}
const table = this.getTableRef();
this.groups.forEach((group, idx) => {
if (table.isRowSelected(idx) && this.isUnselectable(group)) {
this.groupsTableData.forEach((group, idx) => {
if (table.isRowSelected(idx) && group.flags.isUnselectable) {
table.unselectRow(idx);
}
});
},
validateImportTarget: debounce(async function validate(importTarget) {
const newValidationErrors = [];
importTarget.cancellationToken?.cancel();
if (importTarget.newName === '') {
newValidationErrors.push({ field: NEW_NAME_FIELD, message: i18n.ERROR_REQUIRED });
} else if (!isNameValid(importTarget, this.groupPathRegex)) {
newValidationErrors.push({ field: NEW_NAME_FIELD, message: i18n.ERROR_INVALID_FORMAT });
} else if (Object.values(this.importTargets).find(isSameTarget(importTarget))) {
newValidationErrors.push({
field: NEW_NAME_FIELD,
message: i18n.ERROR_NAME_ALREADY_USED_IN_SUGGESTION,
});
} else {
try {
// eslint-disable-next-line no-param-reassign
importTarget.cancellationToken = axios.CancelToken.source();
const {
data: { exists },
} = await getGroupPathAvailability(
importTarget.newName,
importTarget.targetNamespace.id,
{
cancelToken: importTarget.cancellationToken?.token,
},
);
if (exists) {
newValidationErrors.push({
field: NEW_NAME_FIELD,
message: i18n.ERROR_NAME_ALREADY_EXISTS,
});
}
} catch (e) {
if (!axios.isCancel(e)) {
throw e;
}
}
}
// eslint-disable-next-line no-param-reassign
importTarget.validationErrors = newValidationErrors;
}, VALIDATION_DEBOUNCE_TIME),
getImportTarget(group) {
if (this.importTargets[group.id]) {
return this.importTargets[group.id];
}
const defaultTargetNamespace = this.availableNamespaces[0] ?? { fullPath: '', id: null };
let importTarget;
if (group.lastImportTarget) {
const targetNamespace = this.availableNamespaces.find(
(ns) => ns.fullPath === group.lastImportTarget.targetNamespace,
);
importTarget = {
targetNamespace: targetNamespace ?? defaultTargetNamespace,
newName: group.lastImportTarget.newName,
};
} else {
importTarget = {
targetNamespace: defaultTargetNamespace,
newName: group.fullPath,
};
}
const cancellationToken = axios.CancelToken.source();
this.$set(this.importTargets, group.id, {
...importTarget,
cancellationToken,
validationErrors: [],
});
getGroupPathAvailability(importTarget.newName, importTarget.targetNamespace.id, {
cancelToken: cancellationToken.token,
})
.then(({ data: { exists, suggests: suggestions } }) => {
if (!exists) return;
let currentSuggestion = suggestions[0] ?? importTarget.newName;
const existingTargets = Object.values(this.importTargets)
.filter((t) => t.targetNamespace.id === importTarget.targetNamespace.id)
.map((t) => t.newName.toLowerCase());
while (existingTargets.includes(currentSuggestion.toLowerCase())) {
currentSuggestion = `${currentSuggestion}-1`;
}
Object.assign(this.importTargets[group.id], {
targetNamespace: importTarget.targetNamespace,
newName: currentSuggestion,
});
})
.catch(() => {
// empty catch intended
});
return this.importTargets[group.id];
},
},
gitlabLogo: window.gon.gitlab_logo,
......@@ -337,7 +525,7 @@ export default {
>
<gl-sprintf :message="__('%{count} selected')">
<template #count>
{{ selectedGroups.length }}
{{ selectedGroupsIds.length }}
</template>
</gl-sprintf>
<gl-button
......@@ -355,7 +543,7 @@ export default {
data-qa-selector="import_table"
:tbody-tr-class="rowClasses"
:tbody-tr-attr="qaRowAttributes"
:items="groups"
:items="groupsTableData"
:fields="$options.fields"
selectable
select-mode="multi"
......@@ -364,7 +552,7 @@ export default {
>
<template #head(selected)="{ selectAllRows, clearSelected }">
<gl-form-checkbox
:key="`checkbox-${selectedGroups.length}`"
:key="`checkbox-${selectedGroupsIds.length}`"
class="gl-h-7 gl-pt-3"
:checked="hasSelectedGroups"
:indeterminate="hasSelectedGroups && !hasAllAvailableGroupsSelected"
......@@ -375,35 +563,39 @@ export default {
<gl-form-checkbox
class="gl-h-7 gl-pt-3"
:checked="rowSelected"
:disabled="!isAvailableForImport(group) || isInvalid(group)"
:disabled="group.flags.isUnselectable"
@change="rowSelected ? unselectRow() : selectRow()"
/>
</template>
<template #cell(web_url)="{ item: group }">
<template #cell(webUrl)="{ item: group }">
<import-source-cell :group="group" />
</template>
<template #cell(import_target)="{ item: group }">
<template #cell(importTarget)="{ item: group }">
<import-target-cell
:group="group"
:available-namespaces="availableNamespaces"
:group-path-regex="groupPathRegex"
:group-url-error-message="groupUrlErrorMessage"
@update-target-namespace="
updateImportTarget(group.id, $event, group.import_target.new_name)
"
@update-new-name="
updateImportTarget(group.id, group.import_target.target_namespace, $event)
"
@update-target-namespace="updateImportTarget(group, { targetNamespace: $event })"
@update-new-name="updateImportTarget(group, { newName: $event })"
/>
</template>
<template #cell(progress)="{ value: { status } }">
<import-status-cell :status="status" class="gl-line-height-32" />
<template #cell(progress)="{ item: group }">
<import-status-cell :status="group.visibleStatus" class="gl-line-height-32" />
</template>
<template #cell(actions)="{ item: group }">
<import-actions-cell
:group="group"
:group-path-regex="groupPathRegex"
@import-group="importGroups([group.id])"
:is-finished="group.flags.isFinished"
:is-available-for-import="group.flags.isAvailableForImport"
:is-invalid="group.flags.isInvalid"
@import-group="
importGroups([
{
sourceGroupId: group.id,
targetNamespace: group.importTarget.targetNamespace.fullPath,
newName: group.importTarget.newName,
},
])
"
/>
</template>
</gl-table>
......@@ -413,7 +605,7 @@ export default {
:page-info="bulkImportSourceGroups.pageInfo"
class="gl-m-0"
/>
<gl-dropdown category="tertiary" class="gl-ml-auto">
<gl-dropdown category="tertiary" :aria-label="__('Page size')" class="gl-ml-auto">
<template #button-content>
<span class="font-weight-bold">
<gl-sprintf :message="__('%{count} items per page')">
......
......@@ -7,12 +7,7 @@ import {
} from '@gitlab/ui';
import { s__ } from '~/locale';
import ImportGroupDropdown from '../../components/group_dropdown.vue';
import {
isInvalid,
getInvalidNameValidationMessage,
isNameValid,
isAvailableForImport,
} from '../utils';
import { getInvalidNameValidationMessage } from '../utils';
export default {
components: {
......@@ -31,44 +26,15 @@ export default {
type: Array,
required: true,
},
groupPathRegex: {
type: RegExp,
required: true,
},
groupUrlErrorMessage: {
type: String,
required: true,
},
},
computed: {
availableNamespaceNames() {
return this.availableNamespaces.map((ns) => ns.full_path);
},
importTarget() {
return this.group.import_target;
fullPath() {
return this.group.importTarget.targetNamespace.fullPath || s__('BulkImport|No parent');
},
invalidNameValidationMessage() {
return getInvalidNameValidationMessage(this.group);
return getInvalidNameValidationMessage(this.group.importTarget);
},
isInvalid() {
return isInvalid(this.group, this.groupPathRegex);
},
isNameValid() {
return isNameValid(this.group, this.groupPathRegex);
},
isAvailableForImport() {
return isAvailableForImport(this.group);
},
},
i18n: {
NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
},
};
</script>
......@@ -77,14 +43,14 @@ export default {
<div class="gl-display-flex gl-align-items-stretch">
<import-group-dropdown
#default="{ namespaces }"
:text="importTarget.target_namespace"
:disabled="!isAvailableForImport"
:namespaces="availableNamespaceNames"
:text="fullPath"
:disabled="!group.flags.isAvailableForImport"
:namespaces="availableNamespaces"
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
class="gl-h-7 gl-flex-grow-1"
data-qa-selector="target_namespace_selector_dropdown"
>
<gl-dropdown-item @click="$emit('update-target-namespace', '')">{{
<gl-dropdown-item @click="$emit('update-target-namespace', { fullPath: '', id: null })">{{
s__('BulkImport|No parent')
}}</gl-dropdown-item>
<template v-if="namespaces.length">
......@@ -94,20 +60,20 @@ export default {
</gl-dropdown-section-header>
<gl-dropdown-item
v-for="ns in namespaces"
:key="ns"
:key="ns.fullPath"
data-qa-selector="target_group_dropdown_item"
:data-qa-group-name="ns"
:data-qa-group-name="ns.fullPath"
@click="$emit('update-target-namespace', ns)"
>
{{ ns }}
{{ ns.fullPath }}
</gl-dropdown-item>
</template>
</import-group-dropdown>
<div
class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10"
:class="{
'gl-text-gray-400 gl-border-gray-100': !isAvailableForImport,
'gl-border-gray-200': isAvailableForImport,
'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport,
'gl-border-gray-200': group.flags.isAvailableForImport,
}"
>
/
......@@ -116,21 +82,21 @@ export default {
<gl-form-input
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:class="{
'gl-inset-border-1-gray-200!': isAvailableForImport,
'gl-inset-border-1-gray-100!': !isAvailableForImport,
'is-invalid': isInvalid && isAvailableForImport,
'gl-inset-border-1-gray-200!': group.flags.isAvailableForImport,
'gl-inset-border-1-gray-100!': !group.flags.isAvailableForImport,
'is-invalid': group.flags.isInvalid && group.flags.isAvailableForImport,
}"
:disabled="!isAvailableForImport"
:value="importTarget.new_name"
debounce="500"
:disabled="!group.flags.isAvailableForImport"
:value="group.importTarget.newName"
:aria-label="__('New name')"
@input="$emit('update-new-name', $event)"
/>
<p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2">
<template v-if="!isNameValid">
{{ groupUrlErrorMessage }}
</template>
<template v-else-if="invalidNameValidationMessage">
{{ invalidNameValidationMessage }}
</template>
<p
v-if="group.flags.isAvailableForImport && group.flags.isInvalid"
class="gl-text-red-500 gl-m-0 gl-mt-2"
>
{{ invalidNameValidationMessage }}
</p>
</div>
</div>
......
import { s__ } from '~/locale';
import { __, s__ } from '~/locale';
export const i18n = {
NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
ERROR_INVALID_FORMAT: s__(
'GroupSettings|Please choose a group URL with no special characters or spaces.',
),
ERROR_NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
ERROR_REQUIRED: __('This field is required.'),
ERROR_NAME_ALREADY_USED_IN_SUGGESTION: s__(
'BulkImport|Name already used as a target for another group.',
),
ERROR_IMPORT: s__('BulkImport|Importing the group failed.'),
ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'),
};
export const NEW_NAME_FIELD = 'new_name';
export const NEW_NAME_FIELD = 'newName';
import createFlash from '~/flash';
import createDefaultClient from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { STATUSES } from '../../constants';
import { i18n, NEW_NAME_FIELD } from '../constants';
import { isAvailableForImport } from '../utils';
import bulkImportSourceGroupItemFragment from './fragments/bulk_import_source_group_item.fragment.graphql';
import bulkImportSourceGroupProgressFragment from './fragments/bulk_import_source_group_progress.fragment.graphql';
import addValidationErrorMutation from './mutations/add_validation_error.mutation.graphql';
import removeValidationErrorMutation from './mutations/remove_validation_error.mutation.graphql';
import setImportProgressMutation from './mutations/set_import_progress.mutation.graphql';
import setImportTargetMutation from './mutations/set_import_target.mutation.graphql';
import updateImportStatusMutation from './mutations/update_import_status.mutation.graphql';
import availableNamespacesQuery from './queries/available_namespaces.query.graphql';
import bulkImportSourceGroupQuery from './queries/bulk_import_source_group.query.graphql';
import groupAndProjectQuery from './queries/group_and_project.query.graphql';
import { SourceGroupsManager } from './services/source_groups_manager';
import { StatusPoller } from './services/status_poller';
import { LocalStorageCache } from './services/local_storage_cache';
import typeDefs from './typedefs.graphql';
export const clientTypenames = {
......@@ -27,221 +14,99 @@ export const clientTypenames = {
BulkImportPageInfo: 'ClientBulkImportPageInfo',
BulkImportTarget: 'ClientBulkImportTarget',
BulkImportProgress: 'ClientBulkImportProgress',
BulkImportValidationError: 'ClientBulkImportValidationError',
};
function makeGroup(data) {
const result = {
__typename: clientTypenames.BulkImportSourceGroup,
function makeLastImportTarget(data) {
return {
__typename: clientTypenames.BulkImportTarget,
...data,
};
const NESTED_OBJECT_FIELDS = {
import_target: clientTypenames.BulkImportTarget,
last_import_target: clientTypenames.BulkImportTarget,
progress: clientTypenames.BulkImportProgress,
};
Object.entries(NESTED_OBJECT_FIELDS).forEach(([field, type]) => {
if (!data[field]) {
return;
}
result[field] = {
__typename: type,
...data[field],
};
});
return result;
}
async function checkImportTargetIsValid({ client, newName, targetNamespace, sourceGroupId }) {
const {
data: { existingGroup, existingProject },
} = await client.query({
query: groupAndProjectQuery,
fetchPolicy: 'no-cache',
variables: {
fullPath: `${targetNamespace}/${newName}`,
},
});
const variables = {
field: NEW_NAME_FIELD,
sourceGroupId,
function makeProgress(data) {
return {
__typename: clientTypenames.BulkImportProgress,
...data,
};
if (!existingGroup && !existingProject) {
client.mutate({
mutation: removeValidationErrorMutation,
variables,
});
} else {
client.mutate({
mutation: addValidationErrorMutation,
variables: {
...variables,
message: i18n.NAME_ALREADY_EXISTS,
},
});
}
}
const localProgressId = (id) => `not-started-${id}`;
const nextName = (name) => `${name}-1`;
function makeGroup(data) {
return {
__typename: clientTypenames.BulkImportSourceGroup,
...data,
progress: data.progress
? makeProgress({
id: `LOCAL-PROGRESS-${data.id}`,
...data.progress,
})
: null,
lastImportTarget: data.lastImportTarget
? makeLastImportTarget({
id: data.id,
...data.lastImportTarget,
})
: null,
};
}
export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) {
const groupsManager = new GroupsManager({
sourceUrl,
function getGroupFromCache({ client, id, getCacheKey }) {
return client.readFragment({
fragment: bulkImportSourceGroupItemFragment,
fragmentName: 'BulkImportSourceGroupItem',
id: getCacheKey({
__typename: clientTypenames.BulkImportSourceGroup,
id,
}),
});
}
let statusPoller;
export function createResolvers({ endpoints }) {
const localStorageCache = new LocalStorageCache();
return {
Query: {
async bulkImportSourceGroup(_, { id }, { client, getCacheKey }) {
return client.readFragment({
fragment: bulkImportSourceGroupItemFragment,
fragmentName: 'BulkImportSourceGroupItem',
id: getCacheKey({
__typename: clientTypenames.BulkImportSourceGroup,
id,
}),
async bulkImportSourceGroups(_, vars) {
const { headers, data } = await axios.get(endpoints.status, {
params: {
page: vars.page,
per_page: vars.perPage,
filter: vars.filter,
},
});
},
async bulkImportSourceGroups(_, vars, { client }) {
if (!statusPoller) {
statusPoller = new StatusPoller({
updateImportStatus: ({ id, status_name: status }) =>
client.mutate({
mutation: updateImportStatusMutation,
variables: { id, status },
}),
pollPath: endpoints.jobs,
});
statusPoller.startPolling();
}
return Promise.all([
axios.get(endpoints.status, {
params: {
page: vars.page,
per_page: vars.perPage,
filter: vars.filter,
},
}),
client.query({ query: availableNamespacesQuery }),
]).then(
([
{ headers, data },
{
data: { availableNamespaces },
},
]) => {
const pagination = parseIntPagination(normalizeHeaders(headers));
const response = {
__typename: clientTypenames.BulkImportSourceGroupConnection,
nodes: data.importable_data.map((group) => {
const { jobId, importState: cachedImportState } =
groupsManager.getImportStateFromStorageByGroupId(group.id) ?? {};
const status = cachedImportState?.status ?? STATUSES.NONE;
const importTarget =
status === STATUSES.FINISHED && cachedImportState.importTarget
? {
target_namespace: cachedImportState.importTarget.target_namespace,
new_name: nextName(cachedImportState.importTarget.new_name),
}
: cachedImportState?.importTarget ?? {
new_name: group.full_path,
target_namespace: availableNamespaces[0]?.full_path ?? '',
};
return makeGroup({
...group,
validation_errors: [],
progress: {
id: jobId ?? localProgressId(group.id),
status,
},
import_target: importTarget,
last_import_target: cachedImportState?.importTarget ?? null,
});
}),
pageInfo: {
__typename: clientTypenames.BulkImportPageInfo,
...pagination,
},
};
setTimeout(() => {
response.nodes.forEach((group) => {
if (isAvailableForImport(group)) {
checkImportTargetIsValid({
client,
newName: group.import_target.new_name,
targetNamespace: group.import_target.target_namespace,
sourceGroupId: group.id,
});
}
});
const pagination = parseIntPagination(normalizeHeaders(headers));
const response = {
__typename: clientTypenames.BulkImportSourceGroupConnection,
nodes: data.importable_data.map((group) => {
return makeGroup({
id: group.id,
webUrl: group.web_url,
fullPath: group.full_path,
fullName: group.full_name,
...group,
...localStorageCache.get(group.web_url),
});
return response;
}),
pageInfo: {
__typename: clientTypenames.BulkImportPageInfo,
...pagination,
},
);
};
return response;
},
availableNamespaces: () =>
axios.get(endpoints.availableNamespaces).then(({ data }) =>
data.map((namespace) => ({
__typename: clientTypenames.AvailableNamespace,
...namespace,
id: namespace.id,
fullPath: namespace.full_path,
})),
),
},
Mutation: {
setImportTarget(_, { targetNamespace, newName, sourceGroupId }, { client }) {
checkImportTargetIsValid({
client,
sourceGroupId,
targetNamespace,
newName,
});
return makeGroup({
id: sourceGroupId,
import_target: {
target_namespace: targetNamespace,
new_name: newName,
id: sourceGroupId,
},
});
},
async setImportProgress(_, { sourceGroupId, status, jobId, importTarget }) {
if (jobId) {
groupsManager.updateImportProgress(jobId, status);
}
return makeGroup({
id: sourceGroupId,
progress: {
id: jobId ?? localProgressId(sourceGroupId),
status,
},
last_import_target: {
__typename: clientTypenames.BulkImportTarget,
...importTarget,
},
});
},
async updateImportStatus(_, { id, status: newStatus }, { client, getCacheKey }) {
groupsManager.updateImportProgress(id, newStatus);
const progressItem = client.readFragment({
fragment: bulkImportSourceGroupProgressFragment,
fragmentName: 'BulkImportSourceGroupProgress',
......@@ -251,125 +116,58 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
}),
});
const isInProgress = Boolean(progressItem);
const { status: currentStatus } = progressItem ?? {};
if (newStatus === STATUSES.FINISHED && isInProgress && currentStatus !== newStatus) {
const groups = groupsManager.getImportedGroupsByJobId(id);
if (!progressItem) return null;
groups.forEach(async ({ id: groupId, importTarget }) => {
client.mutate({
mutation: setImportTargetMutation,
variables: {
sourceGroupId: groupId,
targetNamespace: importTarget.target_namespace,
newName: nextName(importTarget.new_name),
},
});
});
}
localStorageCache.updateStatusByJobId(id, newStatus);
return {
__typename: clientTypenames.BulkImportProgress,
...progressItem,
id,
status: newStatus,
};
},
async addValidationError(_, { sourceGroupId, field, message }, { client }) {
const {
data: {
bulkImportSourceGroup: { validation_errors: validationErrors, ...group },
},
} = await client.query({
query: bulkImportSourceGroupQuery,
variables: { id: sourceGroupId },
});
async importGroups(_, { importRequests }, { client, getCacheKey }) {
const importOperations = importRequests.map((importRequest) => {
const group = getGroupFromCache({
client,
getCacheKey,
id: importRequest.sourceGroupId,
});
return {
...group,
validation_errors: [
...validationErrors.filter(({ field: f }) => f !== field),
{
__typename: clientTypenames.BulkImportValidationError,
field,
message,
},
],
};
},
return {
group,
...importRequest,
};
});
async removeValidationError(_, { sourceGroupId, field }, { client }) {
const {
data: {
bulkImportSourceGroup: { validation_errors: validationErrors, ...group },
},
} = await client.query({
query: bulkImportSourceGroupQuery,
variables: { id: sourceGroupId },
data: { id: jobId },
} = await axios.post(endpoints.createBulkImport, {
bulk_import: importOperations.map((op) => ({
source_type: 'group_entity',
source_full_path: op.group.fullPath,
destination_namespace: op.targetNamespace,
destination_name: op.newName,
})),
});
return {
...group,
validation_errors: validationErrors.filter(({ field: f }) => f !== field),
};
},
async importGroups(_, { sourceGroupIds }, { client }) {
const groups = await Promise.all(
sourceGroupIds.map((id) =>
client
.query({
query: bulkImportSourceGroupQuery,
variables: { id },
})
.then(({ data }) => data.bulkImportSourceGroup),
),
);
return importOperations.map((op) => {
const lastImportTarget = {
targetNamespace: op.targetNamespace,
newName: op.newName,
};
const GROUPS_BEING_SCHEDULED = sourceGroupIds.map((sourceGroupId) =>
makeGroup({
id: sourceGroupId,
progress: {
id: localProgressId(sourceGroupId),
status: STATUSES.SCHEDULING,
},
}),
);
const defaultErrorMessage = s__('BulkImport|Importing the group failed');
axios
.post(endpoints.createBulkImport, {
bulk_import: groups.map((group) => ({
source_type: 'group_entity',
source_full_path: group.full_path,
destination_namespace: group.import_target.target_namespace,
destination_name: group.import_target.new_name,
})),
})
.then(({ data: { id: jobId } }) => {
groupsManager.createImportState(jobId, {
status: STATUSES.CREATED,
groups,
});
const progress = {
id: jobId,
status: STATUSES.CREATED,
};
return { status: STATUSES.CREATED, jobId };
})
.catch((e) => {
const message = e?.response?.data?.error ?? defaultErrorMessage;
createFlash({ message });
return { status: STATUSES.NONE };
})
.then((newStatus) =>
sourceGroupIds.forEach((sourceGroupId, idx) =>
client.mutate({
mutation: setImportProgressMutation,
variables: { sourceGroupId, ...newStatus, importTarget: groups[idx].import_target },
}),
),
)
.catch(() => createFlash({ message: defaultErrorMessage }));
localStorageCache.set(op.group.webUrl, { progress, lastImportTarget });
return GROUPS_BEING_SCHEDULED;
return makeGroup({ ...op.group, progress, lastImportTarget });
});
},
},
};
......
......@@ -2,22 +2,15 @@
fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup {
id
web_url
full_path
full_name
webUrl
fullPath
fullName
lastImportTarget {
id
targetNamespace
newName
}
progress {
...BulkImportSourceGroupProgress
}
import_target {
target_namespace
new_name
}
last_import_target {
target_namespace
new_name
}
validation_errors {
field
message
}
}
mutation addValidationError($sourceGroupId: String!, $field: String!, $message: String!) {
addValidationError(sourceGroupId: $sourceGroupId, field: $field, message: $message) @client {
id
validation_errors {
field
message
}
}
}
mutation importGroups($sourceGroupIds: [String!]!) {
importGroups(sourceGroupIds: $sourceGroupIds) @client {
mutation importGroups($importRequests: [ImportGroupInput!]!) {
importGroups(importRequests: $importRequests) @client {
id
lastImportTarget {
id
targetNamespace
newName
}
progress {
id
status
......
mutation removeValidationError($sourceGroupId: String!, $field: String!) {
removeValidationError(sourceGroupId: $sourceGroupId, field: $field) @client {
id
validation_errors {
field
message
}
}
}
mutation setImportProgress(
$status: String!
$sourceGroupId: String!
$jobId: String
$importTarget: ImportTargetInput!
) {
setImportProgress(
status: $status
sourceGroupId: $sourceGroupId
jobId: $jobId
importTarget: $importTarget
) @client {
id
progress {
id
status
}
last_import_target {
target_namespace
new_name
}
}
}
mutation setImportTarget($newName: String!, $targetNamespace: String!, $sourceGroupId: String!) {
setImportTarget(
newName: $newName
targetNamespace: $targetNamespace
sourceGroupId: $sourceGroupId
) @client {
id
import_target {
new_name
target_namespace
}
}
}
query availableNamespaces {
availableNamespaces @client {
id
full_path
fullPath
}
}
#import "../fragments/bulk_import_source_group_item.fragment.graphql"
query bulkImportSourceGroup($id: ID!) {
bulkImportSourceGroup(id: $id) @client {
...BulkImportSourceGroupItem
}
}
query groupAndProject($fullPath: ID!) {
existingGroup: group(fullPath: $fullPath) {
id
}
existingProject: project(fullPath: $fullPath) {
id
}
}
import { debounce, merge } from 'lodash';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
const OLD_KEY = 'gl-bulk-imports-import-state';
export const KEY = 'gl-bulk-imports-import-state-v2';
export const DEBOUNCE_INTERVAL = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
export class LocalStorageCache {
constructor({ storage = window.localStorage } = {}) {
this.storage = storage;
this.cache = this.loadCacheFromStorage();
try {
// remove old storage data
this.storage.removeItem(OLD_KEY);
} catch {
// empty catch intended
}
// cache for searching data by jobid
this.jobsLookupCache = {};
}
loadCacheFromStorage() {
try {
return JSON.parse(this.storage.getItem(KEY)) ?? {};
} catch {
return {};
}
}
set(webUrl, data) {
this.cache[webUrl] = data;
this.saveCacheToStorage();
// There are changes to jobIds, drop cache
this.jobsLookupCache = {};
}
get(webUrl) {
return this.cache[webUrl];
}
getCacheKeysByJobId(jobId) {
// this is invoked by polling, so we would like to cache results
if (!this.jobsLookupCache[jobId]) {
this.jobsLookupCache[jobId] = Object.keys(this.cache).filter(
(url) => this.cache[url]?.progress.id === jobId,
);
}
return this.jobsLookupCache[jobId];
}
updateStatusByJobId(jobId, status) {
this.getCacheKeysByJobId(jobId).forEach((webUrl) =>
this.set(webUrl, {
...(this.get(webUrl) ?? {}),
progress: {
id: jobId,
status,
},
}),
);
this.saveCacheToStorage();
}
saveCacheToStorage = debounce(() => {
try {
// storage might be changed in other tab so fetch first
this.storage.setItem(KEY, JSON.stringify(merge({}, this.loadCacheFromStorage(), this.cache)));
} catch {
// empty catch intentional: storage might be unavailable or full
}
}, DEBOUNCE_INTERVAL);
}
import { debounce, merge } from 'lodash';
export const KEY = 'gl-bulk-imports-import-state';
export const DEBOUNCE_INTERVAL = 200;
export class SourceGroupsManager {
constructor({ sourceUrl, storage = window.localStorage }) {
this.sourceUrl = sourceUrl;
this.storage = storage;
this.importStates = this.loadImportStatesFromStorage();
}
loadImportStatesFromStorage() {
try {
return Object.fromEntries(
Object.entries(JSON.parse(this.storage.getItem(KEY)) ?? {}).map(([jobId, config]) => {
// new format of storage
if (config.groups) {
return [jobId, config];
}
return [
jobId,
{
status: config.status,
groups: [{ id: config.id, importTarget: config.importTarget }],
},
];
}),
);
} catch {
return {};
}
}
createImportState(importId, jobConfig) {
this.importStates[importId] = {
status: jobConfig.status,
groups: jobConfig.groups.map((g) => ({
importTarget: { ...g.import_target },
id: g.id,
})),
};
this.saveImportStatesToStorage();
}
updateImportProgress(importId, status) {
const currentState = this.importStates[importId];
if (!currentState) {
return;
}
currentState.status = status;
this.saveImportStatesToStorage();
}
getImportedGroupsByJobId(jobId) {
return this.importStates[jobId]?.groups ?? [];
}
getImportStateFromStorageByGroupId(groupId) {
const [jobId, importState] =
Object.entries(this.importStates)
.reverse()
.find(([, state]) => state.groups.some((g) => g.id === groupId)) ?? [];
if (!jobId) {
return null;
}
const group = importState.groups.find((g) => g.id === groupId);
return { jobId, importState: { ...group, status: importState.status } };
}
saveImportStatesToStorage = debounce(() => {
try {
// storage might be changed in other tab so fetch first
this.storage.setItem(
KEY,
JSON.stringify(merge({}, this.loadImportStatesFromStorage(), this.importStates)),
);
} catch {
// empty catch intentional: storage might be unavailable or full
}
}, DEBOUNCE_INTERVAL);
}
type ClientBulkImportAvailableNamespace {
id: ID!
full_path: String!
fullPath: String!
}
type ClientBulkImportTarget {
target_namespace: String!
new_name: String!
targetNamespace: String!
newName: String!
}
type ClientBulkImportSourceGroupConnection {
......@@ -14,7 +14,7 @@ type ClientBulkImportSourceGroupConnection {
}
type ClientBulkImportProgress {
id: ID
id: ID!
status: String!
}
......@@ -25,13 +25,11 @@ type ClientBulkImportValidationError {
type ClientBulkImportSourceGroup {
id: ID!
web_url: String!
full_path: String!
full_name: String!
progress: ClientBulkImportProgress!
import_target: ClientBulkImportTarget!
last_import_target: ClientBulkImportTarget
validation_errors: [ClientBulkImportValidationError!]!
webUrl: String!
fullPath: String!
fullName: String!
lastImportTarget: ClientBulkImportTarget
progress: ClientBulkImportProgress
}
type ClientBulkImportPageInfo {
......@@ -41,8 +39,13 @@ type ClientBulkImportPageInfo {
totalPages: Int!
}
type ClientBulkImportNamespaceSuggestion {
id: ID!
exists: Boolean!
suggestions: [String!]!
}
extend type Query {
bulkImportSourceGroup(id: ID!): ClientBulkImportSourceGroup
bulkImportSourceGroups(
page: Int!
perPage: Int!
......@@ -51,26 +54,13 @@ extend type Query {
availableNamespaces: [ClientBulkImportAvailableNamespace!]!
}
input InputTargetInput {
target_namespace: String!
new_name: String!
input ImportRequestInput {
sourceGroupId: ID!
targetNamespace: String!
newName: String!
}
extend type Mutation {
setNewName(newName: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
importGroups(sourceGroupIds: [ID!]!): [ClientBulkImportSourceGroup!]!
setImportProgress(
id: ID
status: String!
jobId: String
importTarget: ImportTargetInput!
): ClientBulkImportSourceGroup!
updateImportProgress(id: ID, status: String!): ClientBulkImportProgress
addValidationError(
sourceGroupId: ID!
field: String!
message: String!
): ClientBulkImportSourceGroup!
removeValidationError(sourceGroupId: ID!, field: String!): ClientBulkImportSourceGroup!
importGroups(importRequests: [ImportRequestInput!]!): [ClientBulkImportSourceGroup!]!
updateImportStatus(id: ID, status: String!): ClientBulkImportProgress
}
......@@ -17,7 +17,6 @@ export function mountImportGroupsApp(mountElement) {
jobsPath,
sourceUrl,
groupPathRegex,
groupUrlErrorMessage,
} = mountElement.dataset;
const apolloProvider = new VueApollo({
defaultClient: createApolloClient({
......@@ -26,7 +25,6 @@ export function mountImportGroupsApp(mountElement) {
status: statusPath,
availableNamespaces: availableNamespacesPath,
createBulkImport: createBulkImportPath,
jobs: jobsPath,
},
}),
});
......@@ -38,8 +36,8 @@ export function mountImportGroupsApp(mountElement) {
return createElement(ImportTable, {
props: {
sourceUrl,
jobsPath,
groupPathRegex: new RegExp(`^(${groupPathRegex})$`),
groupUrlErrorMessage,
},
});
},
......
......@@ -32,4 +32,8 @@ export class StatusPoller {
startPolling() {
this.eTagPoll.makeRequest();
}
stopPolling() {
this.eTagPoll.stop();
}
}
import { STATUSES } from '../constants';
import { NEW_NAME_FIELD } from './constants';
export function isNameValid(group, validationRegex) {
return validationRegex.test(group.import_target[NEW_NAME_FIELD]);
export function isNameValid(importTarget, validationRegex) {
return validationRegex.test(importTarget[NEW_NAME_FIELD]);
}
export function getInvalidNameValidationMessage(group) {
return group.validation_errors.find(({ field }) => field === NEW_NAME_FIELD)?.message;
}
export function isInvalid(group, validationRegex) {
return Boolean(!isNameValid(group, validationRegex) || getInvalidNameValidationMessage(group));
export function getInvalidNameValidationMessage(importTarget) {
return importTarget.validationErrors?.find(({ field }) => field === NEW_NAME_FIELD)?.message;
}
export function isFinished(group) {
return group.progress.status === STATUSES.FINISHED;
return [STATUSES.FINISHED, STATUSES.FAILED].includes(group.progress?.status);
}
export function isAvailableForImport(group) {
return [STATUSES.NONE, STATUSES.FINISHED].some((status) => group.progress.status === status);
return !group.progress || isFinished(group);
}
export function isSameTarget(importTarget) {
return (target) =>
target !== importTarget &&
target.newName.toLowerCase() === importTarget.newName.toLowerCase() &&
target.targetNamespace.id === importTarget.targetNamespace.id;
}
......@@ -46,10 +46,6 @@ export default {
return `${this.filter}-${this.repositories.length}-${this.pageInfo.page}`;
},
availableNamespaces() {
return this.namespaces.map(({ fullPath }) => fullPath);
},
importAllButtonText() {
if (this.isImportingAnyRepo) {
return n__('Importing %d repository', 'Importing %d repositories', this.importingRepoCount);
......@@ -167,7 +163,7 @@ export default {
<provider-repo-table-row
:key="repo.importSource.providerLink"
:repo="repo"
:available-namespaces="availableNamespaces"
:available-namespaces="namespaces"
:user-namespace="defaultTargetNamespace"
/>
</template>
......
......@@ -128,17 +128,17 @@ export default {
<gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="ns in namespaces"
:key="ns"
:key="ns.fullPath"
data-qa-selector="target_group_dropdown_item"
:data-qa-group-name="ns"
@click="updateImportTarget({ targetNamespace: ns })"
:data-qa-group-name="ns.fullPath"
@click="updateImportTarget({ targetNamespace: ns.fullPath })"
>
{{ ns }}
{{ ns.fullPath }}
</gl-dropdown-item>
<gl-dropdown-divider />
</template>
<gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
<gl-dropdown-item @click="updateImportTarget({ targetNamespace: ns })">{{
<gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{
userNamespace
}}</gl-dropdown-item>
</import-group-dropdown>
......
......@@ -3,7 +3,7 @@ import { debounce } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
import InputValidator from '~/validators/input_validator';
import fetchGroupPathAvailability from './fetch_group_path_availability';
import { getGroupPathAvailability } from '~/rest_api';
const debounceTimeoutDuration = 1000;
const invalidInputClass = 'gl-field-error-outline';
......@@ -45,7 +45,7 @@ export default class GroupPathValidator extends InputValidator {
if (inputDomElement.checkValidity() && groupPath.length > 1) {
GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector);
fetchGroupPathAvailability(groupPath, parentId)
getGroupPathAvailability(groupPath, parentId)
.then(({ data }) => data)
.then((data) => {
GroupPathValidator.setInputState(inputDomElement, !data.exists);
......
......@@ -3,6 +3,7 @@ export * from './api/projects_api';
export * from './api/user_api';
export * from './api/markdown_api';
export * from './api/bulk_imports_api';
export * from './api/namespaces_api';
// Note: It's not possible to spy on methods imported from this file in
// Jest tests.
......
......@@ -5930,10 +5930,13 @@ msgstr ""
msgid "BulkImport|Import groups from GitLab"
msgstr ""
msgid "BulkImport|Import is finished. Pick another name for re-import"
msgstr ""
msgid "BulkImport|Import selected"
msgstr ""
msgid "BulkImport|Importing the group failed"
msgid "BulkImport|Importing the group failed."
msgstr ""
msgid "BulkImport|Last imported to %{link}"
......@@ -5942,6 +5945,9 @@ msgstr ""
msgid "BulkImport|Name already exists."
msgstr ""
msgid "BulkImport|Name already used as a target for another group."
msgstr ""
msgid "BulkImport|New group"
msgstr ""
......@@ -23027,6 +23033,9 @@ msgstr ""
msgid "New milestone"
msgstr ""
msgid "New name"
msgstr ""
msgid "New password"
msgstr ""
......@@ -24721,6 +24730,9 @@ msgstr ""
msgid "Page settings"
msgstr ""
msgid "Page size"
msgstr ""
msgid "PagerDutySettings|Active"
msgstr ""
......
......@@ -24,14 +24,21 @@ describe('Import entities group dropdown component', () => {
});
it('passes namespaces from props to default slot', () => {
const namespaces = ['ns1', 'ns2'];
const namespaces = [
{ id: 1, fullPath: 'ns1' },
{ id: 2, fullPath: 'ns2' },
];
createComponent({ namespaces });
expect(namespacesTracker).toHaveBeenCalledWith({ namespaces });
});
it('filters namespaces based on user input', async () => {
const namespaces = ['match1', 'some unrelated', 'match2'];
const namespaces = [
{ id: 1, fullPath: 'match1' },
{ id: 2, fullPath: 'some unrelated' },
{ id: 3, fullPath: 'match2' },
];
createComponent({ namespaces });
namespacesTracker.mockReset();
......@@ -39,6 +46,11 @@ describe('Import entities group dropdown component', () => {
await nextTick();
expect(namespacesTracker).toHaveBeenCalledWith({ namespaces: ['match1', 'match2'] });
expect(namespacesTracker).toHaveBeenCalledWith({
namespaces: [
{ id: 1, fullPath: 'match1' },
{ id: 3, fullPath: 'match2' },
],
});
});
});
import { GlButton, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { STATUSES } from '~/import_entities/constants';
import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue';
import { generateFakeEntry } from '../graphql/fixtures';
describe('import actions cell', () => {
let wrapper;
......@@ -10,7 +8,9 @@ describe('import actions cell', () => {
const createComponent = (props) => {
wrapper = shallowMount(ImportActionsCell, {
propsData: {
groupPathRegex: /^[a-zA-Z]+$/,
isFinished: false,
isAvailableForImport: false,
isInvalid: false,
...props,
},
});
......@@ -20,10 +20,9 @@ describe('import actions cell', () => {
wrapper.destroy();
});
describe('when import status is NONE', () => {
describe('when group is available for import', () => {
beforeEach(() => {
const group = generateFakeEntry({ id: 1, status: STATUSES.NONE });
createComponent({ group });
createComponent({ isAvailableForImport: true });
});
it('renders import button', () => {
......@@ -37,10 +36,9 @@ describe('import actions cell', () => {
});
});
describe('when import status is FINISHED', () => {
describe('when group is finished', () => {
beforeEach(() => {
const group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED });
createComponent({ group });
createComponent({ isAvailableForImport: true, isFinished: true });
});
it('renders re-import button', () => {
......@@ -58,29 +56,22 @@ describe('import actions cell', () => {
});
});
it('does not render import button when group import is in progress', () => {
const group = generateFakeEntry({ id: 1, status: STATUSES.STARTED });
createComponent({ group });
it('does not render import button when group is not available for import', () => {
createComponent({ isAvailableForImport: false });
const button = wrapper.findComponent(GlButton);
expect(button.exists()).toBe(false);
});
it('renders import button as disabled when there are validation errors', () => {
const group = generateFakeEntry({
id: 1,
status: STATUSES.NONE,
validation_errors: [{ field: 'new_name', message: 'something ' }],
});
createComponent({ group });
it('renders import button as disabled when group is invalid', () => {
createComponent({ isInvalid: true, isAvailableForImport: true });
const button = wrapper.findComponent(GlButton);
expect(button.props().disabled).toBe(true);
});
it('emits import-group event when import button is clicked', () => {
const group = generateFakeEntry({ id: 1, status: STATUSES.NONE });
createComponent({ group });
createComponent({ isAvailableForImport: true });
const button = wrapper.findComponent(GlButton);
button.vm.$emit('click');
......
......@@ -4,6 +4,11 @@ import { STATUSES } from '~/import_entities/constants';
import ImportSourceCell from '~/import_entities/import_groups/components/import_source_cell.vue';
import { generateFakeEntry } from '../graphql/fixtures';
const generateFakeTableEntry = ({ flags = {}, ...entry }) => ({
...generateFakeEntry(entry),
flags,
});
describe('import source cell', () => {
let wrapper;
let group;
......@@ -23,14 +28,14 @@ describe('import source cell', () => {
describe('when group status is NONE', () => {
beforeEach(() => {
group = generateFakeEntry({ id: 1, status: STATUSES.NONE });
group = generateFakeTableEntry({ id: 1, status: STATUSES.NONE });
createComponent({ group });
});
it('renders link to a group', () => {
const link = wrapper.findComponent(GlLink);
expect(link.attributes().href).toBe(group.web_url);
expect(link.text()).toContain(group.full_path);
expect(link.attributes().href).toBe(group.webUrl);
expect(link.text()).toContain(group.fullPath);
});
it('does not render last imported line', () => {
......@@ -40,20 +45,24 @@ describe('import source cell', () => {
describe('when group status is FINISHED', () => {
beforeEach(() => {
group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED });
group = generateFakeTableEntry({
id: 1,
status: STATUSES.FINISHED,
flags: {
isFinished: true,
},
});
createComponent({ group });
});
it('renders link to a group', () => {
const link = wrapper.findComponent(GlLink);
expect(link.attributes().href).toBe(group.web_url);
expect(link.text()).toContain(group.full_path);
expect(link.attributes().href).toBe(group.webUrl);
expect(link.text()).toContain(group.fullPath);
});
it('renders last imported line', () => {
expect(wrapper.text()).toMatchInterpolatedText(
'fake_group_1 Last imported to root/last-group1',
);
expect(wrapper.text()).toMatchInterpolatedText('fake_group_1 Last imported to root/group1');
});
});
});
import {
GlButton,
GlEmptyState,
GlLoadingIcon,
GlSearchBoxByClick,
GlDropdown,
GlDropdownItem,
GlTable,
} from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import MockAdapter from 'axios-mock-adapter';
import createMockApollo from 'helpers/mock_apollo_helper';
import stubChildren from 'helpers/stub_children';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import httpStatus from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import { STATUSES } from '~/import_entities/constants';
import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue';
import { i18n } from '~/import_entities/import_groups/constants';
import ImportTable from '~/import_entities/import_groups/components/import_table.vue';
import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue';
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
import setImportTargetMutation from '~/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtures';
const localVue = createLocalVue();
localVue.use(VueApollo);
jest.mock('~/flash');
jest.mock('~/import_entities/import_groups/services/status_poller');
const GlDropdownStub = stubComponent(GlDropdown, {
template: '<div><h1 ref="text"><slot name="button-content"></slot></h1><slot></slot></div>',
});
Vue.use(VueApollo);
describe('import table', () => {
let wrapper;
let apolloProvider;
let axiosMock;
const SOURCE_URL = 'https://demo.host';
const FAKE_GROUP = generateFakeEntry({ id: 1, status: STATUSES.NONE });
......@@ -44,76 +35,81 @@ describe('import table', () => {
const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 };
const findImportSelectedButton = () =>
wrapper.findAllComponents(GlButton).wrappers.find((w) => w.text() === 'Import selected');
const findPaginationDropdown = () => wrapper.findComponent(GlDropdown);
const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text();
wrapper.findAll('button').wrappers.find((w) => w.text() === 'Import selected');
const findImportButtons = () =>
wrapper.findAll('button').wrappers.filter((w) => w.text() === 'Import');
const findPaginationDropdown = () => wrapper.find('[aria-label="Page size"]');
const findPaginationDropdownText = () => findPaginationDropdown().find('button').text();
// TODO: remove this ugly approach when
// issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531
const findTable = () => wrapper.vm.getTableRef();
const selectRow = (idx) =>
wrapper.findAll('tbody td input[type=checkbox]').at(idx).trigger('click');
const createComponent = ({ bulkImportSourceGroups }) => {
const createComponent = ({ bulkImportSourceGroups, importGroups }) => {
apolloProvider = createMockApollo([], {
Query: {
availableNamespaces: () => availableNamespacesFixture,
bulkImportSourceGroups,
},
Mutation: {
setTargetNamespace: jest.fn(),
setNewName: jest.fn(),
importGroup: jest.fn(),
importGroups,
},
});
wrapper = mount(ImportTable, {
propsData: {
groupPathRegex: /.*/,
jobsPath: '/fake_job_path',
sourceUrl: SOURCE_URL,
groupUrlErrorMessage: 'Please choose a group URL with no special characters or spaces.',
},
stubs: {
...stubChildren(ImportTable),
GlSprintf: false,
GlDropdown: GlDropdownStub,
GlTable: false,
},
localVue,
apolloProvider,
});
};
beforeAll(() => {
gon.api_version = 'v4';
});
beforeEach(() => {
axiosMock = new MockAdapter(axios);
axiosMock.onGet(/.*\/exists$/, () => []).reply(200);
});
afterEach(() => {
wrapper.destroy();
});
it('renders loading icon while performing request', async () => {
createComponent({
bulkImportSourceGroups: () => new Promise(() => {}),
describe('loading state', () => {
it('renders loading icon while performing request', async () => {
createComponent({
bulkImportSourceGroups: () => new Promise(() => {}),
});
await waitForPromises();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
await waitForPromises();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('does not renders loading icon when request is completed', async () => {
createComponent({
bulkImportSourceGroups: () => [],
});
await waitForPromises();
it('does not renders loading icon when request is completed', async () => {
createComponent({
bulkImportSourceGroups: () => [],
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
await waitForPromises();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('renders message about empty state when no groups are available for import', async () => {
createComponent({
bulkImportSourceGroups: () => ({
nodes: [],
pageInfo: FAKE_PAGE_INFO,
}),
});
await waitForPromises();
describe('empty state', () => {
it('renders message about empty state when no groups are available for import', async () => {
createComponent({
bulkImportSourceGroups: () => ({
nodes: [],
pageInfo: FAKE_PAGE_INFO,
}),
});
await waitForPromises();
expect(wrapper.find(GlEmptyState).props().title).toBe('You have no groups to import');
expect(wrapper.find(GlEmptyState).props().title).toBe('You have no groups to import');
});
});
it('renders import row for each group in response', async () => {
......@@ -140,40 +136,51 @@ describe('import table', () => {
expect(wrapper.text()).not.toContain('Showing 1-0');
});
describe('converts row events to mutation invocations', () => {
beforeEach(() => {
createComponent({
bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }),
});
return waitForPromises();
it('invokes importGroups mutation when row button is clicked', async () => {
createComponent({
bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }),
});
it.each`
event | payload | mutation | variables
${'update-target-namespace'} | ${'new-namespace'} | ${setImportTargetMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace', newName: 'group1' }}
${'update-new-name'} | ${'new-name'} | ${setImportTargetMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'root', newName: 'new-name' }}
`('correctly maps $event to mutation', async ({ event, payload, mutation, variables }) => {
jest.spyOn(apolloProvider.defaultClient, 'mutate');
wrapper.find(ImportTargetCell).vm.$emit(event, payload);
await waitForPromises();
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation,
variables,
});
});
jest.spyOn(apolloProvider.defaultClient, 'mutate');
it('invokes importGroups mutation when row button is clicked', async () => {
jest.spyOn(apolloProvider.defaultClient, 'mutate');
await waitForPromises();
wrapper.findComponent(ImportActionsCell).vm.$emit('import-group');
await waitForPromises();
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation: importGroupsMutation,
variables: { sourceGroupIds: [FAKE_GROUP.id] },
});
await findImportButtons()[0].trigger('click');
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation: importGroupsMutation,
variables: {
importRequests: [
{
newName: FAKE_GROUP.lastImportTarget.newName,
sourceGroupId: FAKE_GROUP.id,
targetNamespace: availableNamespacesFixture[0].fullPath,
},
],
},
});
});
it('displays error if importing group fails', async () => {
createComponent({
bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }),
importGroups: () => {
throw new Error();
},
});
axiosMock.onPost('/import/bulk_imports.json').reply(httpStatus.BAD_REQUEST);
await waitForPromises();
await findImportButtons()[0].trigger('click');
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith(
expect.objectContaining({
message: i18n.ERROR_IMPORT,
}),
);
});
describe('pagination', () => {
const bulkImportSourceGroupsQueryMock = jest
.fn()
......@@ -195,10 +202,10 @@ describe('import table', () => {
});
it('updates page size when selected in Dropdown', async () => {
const otherOption = wrapper.findAllComponents(GlDropdownItem).at(1);
const otherOption = findPaginationDropdown().findAll('li p').at(1);
expect(otherOption.text()).toMatchInterpolatedText('50 items per page');
otherOption.vm.$emit('click');
await otherOption.trigger('click');
await waitForPromises();
expect(findPaginationDropdownText()).toMatchInterpolatedText('50 items per page');
......@@ -247,7 +254,11 @@ describe('import table', () => {
return waitForPromises();
});
const findFilterInput = () => wrapper.find(GlSearchBoxByClick);
const setFilter = (value) => {
const input = wrapper.find('input[placeholder="Filter by source group"]');
input.setValue(value);
return input.trigger('keydown.enter');
};
it('properly passes filter to graphql query when search box is submitted', async () => {
createComponent({
......@@ -256,7 +267,7 @@ describe('import table', () => {
await waitForPromises();
const FILTER_VALUE = 'foo';
findFilterInput().vm.$emit('submit', FILTER_VALUE);
await setFilter(FILTER_VALUE);
await waitForPromises();
expect(bulkImportSourceGroupsQueryMock).toHaveBeenCalledWith(
......@@ -274,7 +285,7 @@ describe('import table', () => {
await waitForPromises();
const FILTER_VALUE = 'foo';
findFilterInput().vm.$emit('submit', FILTER_VALUE);
await setFilter(FILTER_VALUE);
await waitForPromises();
expect(wrapper.text()).toContain('Showing 1-1 of 40 groups matching filter "foo" from');
......@@ -282,12 +293,14 @@ describe('import table', () => {
it('properly resets filter in graphql query when search box is cleared', async () => {
const FILTER_VALUE = 'foo';
findFilterInput().vm.$emit('submit', FILTER_VALUE);
await setFilter(FILTER_VALUE);
await waitForPromises();
bulkImportSourceGroupsQueryMock.mockClear();
await apolloProvider.defaultClient.resetStore();
findFilterInput().vm.$emit('clear');
await setFilter('');
await waitForPromises();
expect(bulkImportSourceGroupsQueryMock).toHaveBeenCalledWith(
......@@ -320,8 +333,8 @@ describe('import table', () => {
}),
});
await waitForPromises();
wrapper.find(GlTable).vm.$emit('row-selected', [FAKE_GROUPS[0]]);
await nextTick();
await selectRow(0);
expect(findImportSelectedButton().props().disabled).toBe(false);
});
......@@ -337,7 +350,7 @@ describe('import table', () => {
});
await waitForPromises();
findTable().selectRow(0);
await selectRow(0);
await nextTick();
expect(findImportSelectedButton().props().disabled).toBe(true);
......@@ -348,7 +361,6 @@ describe('import table', () => {
generateFakeEntry({
id: 2,
status: STATUSES.NONE,
validation_errors: [{ field: 'new_name', message: 'FAKE_VALIDATION_ERROR' }],
}),
];
......@@ -360,9 +372,9 @@ describe('import table', () => {
});
await waitForPromises();
// TODO: remove this ugly approach when
// issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531
findTable().selectRow(0);
await wrapper.find('tbody input[aria-label="New name"]').setValue('');
jest.runOnlyPendingTimers();
await selectRow(0);
await nextTick();
expect(findImportSelectedButton().props().disabled).toBe(true);
......@@ -384,15 +396,28 @@ describe('import table', () => {
jest.spyOn(apolloProvider.defaultClient, 'mutate');
await waitForPromises();
findTable().selectRow(0);
findTable().selectRow(1);
await selectRow(0);
await selectRow(1);
await nextTick();
findImportSelectedButton().vm.$emit('click');
await findImportSelectedButton().trigger('click');
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation: importGroupsMutation,
variables: { sourceGroupIds: [NEW_GROUPS[0].id, NEW_GROUPS[1].id] },
variables: {
importRequests: [
{
targetNamespace: availableNamespacesFixture[0].fullPath,
newName: NEW_GROUPS[0].lastImportTarget.newName,
sourceGroupId: NEW_GROUPS[0].id,
},
{
targetNamespace: availableNamespacesFixture[0].fullPath,
newName: NEW_GROUPS[1].lastImportTarget.newName,
sourceGroupId: NEW_GROUPS[1].id,
},
],
},
});
});
});
......
......@@ -3,20 +3,20 @@ import { shallowMount } from '@vue/test-utils';
import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue';
import { STATUSES } from '~/import_entities/constants';
import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue';
import { availableNamespacesFixture } from '../graphql/fixtures';
const getFakeGroup = (status) => ({
web_url: 'https://fake.host/',
full_path: 'fake_group_1',
full_name: 'fake_name_1',
import_target: {
target_namespace: 'root',
new_name: 'group1',
},
id: 1,
validation_errors: [],
progress: { status },
});
import { generateFakeEntry, availableNamespacesFixture } from '../graphql/fixtures';
const generateFakeTableEntry = ({ flags = {}, ...config }) => {
const entry = generateFakeEntry(config);
return {
...entry,
importTarget: {
targetNamespace: availableNamespacesFixture[0],
newName: entry.lastImportTarget.newName,
},
flags,
};
};
describe('import target cell', () => {
let wrapper;
......@@ -31,7 +31,6 @@ describe('import target cell', () => {
propsData: {
availableNamespaces: availableNamespacesFixture,
groupPathRegex: /.*/,
groupUrlErrorMessage: 'Please choose a group URL with no special characters or spaces.',
...props,
},
});
......@@ -44,11 +43,11 @@ describe('import target cell', () => {
describe('events', () => {
beforeEach(() => {
group = getFakeGroup(STATUSES.NONE);
group = generateFakeTableEntry({ id: 1, status: STATUSES.NONE });
createComponent({ group });
});
it('invokes $event', () => {
it('emits update-new-name when input value is changed', () => {
findNameInput().vm.$emit('input', 'demo');
expect(wrapper.emitted('update-new-name')).toBeDefined();
expect(wrapper.emitted('update-new-name')[0][0]).toBe('demo');
......@@ -56,18 +55,23 @@ describe('import target cell', () => {
it('emits update-target-namespace when dropdown option is clicked', () => {
const dropdownItem = findNamespaceDropdown().findAllComponents(GlDropdownItem).at(2);
const dropdownItemText = dropdownItem.text();
dropdownItem.vm.$emit('click');
expect(wrapper.emitted('update-target-namespace')).toBeDefined();
expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(dropdownItemText);
expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(availableNamespacesFixture[1]);
});
});
describe('when entity status is NONE', () => {
beforeEach(() => {
group = getFakeGroup(STATUSES.NONE);
group = generateFakeTableEntry({
id: 1,
status: STATUSES.NONE,
flags: {
isAvailableForImport: true,
},
});
createComponent({ group });
});
......@@ -78,7 +82,7 @@ describe('import target cell', () => {
it('renders only no parent option if available namespaces list is empty', () => {
createComponent({
group: getFakeGroup(STATUSES.NONE),
group: generateFakeTableEntry({ id: 1, status: STATUSES.NONE }),
availableNamespaces: [],
});
......@@ -92,7 +96,7 @@ describe('import target cell', () => {
it('renders both no parent option and available namespaces list when available namespaces list is not empty', () => {
createComponent({
group: getFakeGroup(STATUSES.NONE),
group: generateFakeTableEntry({ id: 1, status: STATUSES.NONE }),
availableNamespaces: availableNamespacesFixture,
});
......@@ -104,9 +108,12 @@ describe('import target cell', () => {
expect(rest).toHaveLength(availableNamespacesFixture.length);
});
describe('when entity status is SCHEDULING', () => {
describe('when entity is not available for import', () => {
beforeEach(() => {
group = getFakeGroup(STATUSES.SCHEDULING);
group = generateFakeTableEntry({
id: 1,
flags: { isAvailableForImport: false },
});
createComponent({ group });
});
......@@ -115,9 +122,9 @@ describe('import target cell', () => {
});
});
describe('when entity status is FINISHED', () => {
describe('when entity is available for import', () => {
beforeEach(() => {
group = getFakeGroup(STATUSES.FINISHED);
group = generateFakeTableEntry({ id: 1, flags: { isAvailableForImport: true } });
createComponent({ group });
});
......@@ -125,41 +132,4 @@ describe('import target cell', () => {
expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined);
});
});
describe('validations', () => {
it('reports invalid group name when name is not matching regex', () => {
createComponent({
group: {
...getFakeGroup(STATUSES.NONE),
import_target: {
target_namespace: 'root',
new_name: 'very`bad`name',
},
},
groupPathRegex: /^[a-zA-Z]+$/,
});
expect(wrapper.text()).toContain(
'Please choose a group URL with no special characters or spaces.',
);
});
it('reports invalid group name if relevant validation error exists', async () => {
const FAKE_ERROR_MESSAGE = 'fake error';
createComponent({
group: {
...getFakeGroup(STATUSES.NONE),
validation_errors: [
{
field: 'new_name',
message: FAKE_ERROR_MESSAGE,
},
],
},
});
expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE);
});
});
});
......@@ -2,32 +2,27 @@ import { InMemoryCache } from 'apollo-cache-inmemory';
import MockAdapter from 'axios-mock-adapter';
import { createMockClient } from 'mock-apollo-client';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { STATUSES } from '~/import_entities/constants';
import {
clientTypenames,
createResolvers,
} from '~/import_entities/import_groups/graphql/client_factory';
import addValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql';
import { LocalStorageCache } from '~/import_entities/import_groups/graphql/services/local_storage_cache';
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
import removeValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql';
import setImportProgressMutation from '~/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql';
import setImportTargetMutation from '~/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql';
import updateImportStatusMutation from '~/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql';
import availableNamespacesQuery from '~/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql';
import bulkImportSourceGroupQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql';
import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql';
import groupAndProjectQuery from '~/import_entities/import_groups/graphql/queries/group_and_project.query.graphql';
import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import { statusEndpointFixture, availableNamespacesFixture } from './fixtures';
jest.mock('~/flash');
jest.mock('~/import_entities/import_groups/graphql/services/status_poller', () => ({
StatusPoller: jest.fn().mockImplementation(function mock() {
this.startPolling = jest.fn();
jest.mock('~/import_entities/import_groups/graphql/services/local_storage_cache', () => ({
LocalStorageCache: jest.fn().mockImplementation(function mock() {
this.get = jest.fn();
this.set = jest.fn();
this.updateStatusByJobId = jest.fn();
}),
}));
......@@ -38,13 +33,6 @@ const FAKE_ENDPOINTS = {
jobs: '/fake_jobs',
};
const FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER = jest.fn().mockResolvedValue({
data: {
existingGroup: null,
existingProject: null,
},
});
describe('Bulk import resolvers', () => {
let axiosMockAdapter;
let client;
......@@ -58,14 +46,28 @@ describe('Bulk import resolvers', () => {
resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS, ...extraResolverArgs }),
});
mockedClient.setRequestHandler(groupAndProjectQuery, FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER);
return mockedClient;
};
beforeEach(() => {
let results;
beforeEach(async () => {
axiosMockAdapter = new MockAdapter(axios);
client = createClient();
axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture);
axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply(
httpStatus.OK,
availableNamespacesFixture.map((ns) => ({
id: ns.id,
full_path: ns.fullPath,
})),
);
client.watchQuery({ query: bulkImportSourceGroupsQuery }).subscribe(({ data }) => {
results = data.bulkImportSourceGroups.nodes;
});
return waitForPromises();
});
afterEach(() => {
......@@ -74,104 +76,41 @@ describe('Bulk import resolvers', () => {
describe('queries', () => {
describe('availableNamespaces', () => {
let results;
let namespacesResults;
beforeEach(async () => {
axiosMockAdapter
.onGet(FAKE_ENDPOINTS.availableNamespaces)
.reply(httpStatus.OK, availableNamespacesFixture);
const response = await client.query({ query: availableNamespacesQuery });
results = response.data.availableNamespaces;
namespacesResults = response.data.availableNamespaces;
});
it('mirrors REST endpoint response fields', () => {
const extractRelevantFields = (obj) => ({ id: obj.id, full_path: obj.full_path });
expect(results.map(extractRelevantFields)).toStrictEqual(
expect(namespacesResults.map(extractRelevantFields)).toStrictEqual(
availableNamespacesFixture.map(extractRelevantFields),
);
});
});
describe('bulkImportSourceGroup', () => {
beforeEach(async () => {
axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture);
axiosMockAdapter
.onGet(FAKE_ENDPOINTS.availableNamespaces)
.reply(httpStatus.OK, availableNamespacesFixture);
return client.query({
query: bulkImportSourceGroupsQuery,
});
});
it('returns group', async () => {
const { id } = statusEndpointFixture.importable_data[0];
const {
data: { bulkImportSourceGroup: group },
} = await client.query({
query: bulkImportSourceGroupQuery,
variables: { id: id.toString() },
});
expect(group).toMatchObject(statusEndpointFixture.importable_data[0]);
});
});
describe('bulkImportSourceGroups', () => {
let results;
beforeEach(async () => {
axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture);
axiosMockAdapter
.onGet(FAKE_ENDPOINTS.availableNamespaces)
.reply(httpStatus.OK, availableNamespacesFixture);
});
it('respects cached import state when provided by group manager', async () => {
const FAKE_JOB_ID = '1';
const FAKE_STATUS = 'DEMO_STATUS';
const FAKE_IMPORT_TARGET = {
new_name: 'test-name',
target_namespace: 'test-namespace',
const [localStorageCache] = LocalStorageCache.mock.instances;
const CACHED_DATA = {
progress: {
id: 'DEMO',
status: 'cached',
},
};
const TARGET_INDEX = 0;
localStorageCache.get.mockReturnValueOnce(CACHED_DATA);
const clientWithMockedManager = createClient({
GroupsManager: jest.fn().mockImplementation(() => ({
getImportStateFromStorageByGroupId(groupId) {
if (groupId === statusEndpointFixture.importable_data[TARGET_INDEX].id) {
return {
jobId: FAKE_JOB_ID,
importState: {
status: FAKE_STATUS,
importTarget: FAKE_IMPORT_TARGET,
},
};
}
return null;
},
})),
});
const clientResponse = await clientWithMockedManager.query({
const updatedResults = await client.query({
query: bulkImportSourceGroupsQuery,
fetchPolicy: 'no-cache',
});
const clientResults = clientResponse.data.bulkImportSourceGroups.nodes;
expect(clientResults[TARGET_INDEX].import_target).toStrictEqual(FAKE_IMPORT_TARGET);
expect(clientResults[TARGET_INDEX].progress.status).toBe(FAKE_STATUS);
});
it('populates each result instance with empty import_target when there are no available namespaces', async () => {
axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply(httpStatus.OK, []);
const response = await client.query({ query: bulkImportSourceGroupsQuery });
results = response.data.bulkImportSourceGroups.nodes;
expect(results.every((r) => r.import_target.target_namespace === '')).toBe(true);
expect(updatedResults.data.bulkImportSourceGroups.nodes[0].progress).toStrictEqual({
__typename: clientTypenames.BulkImportProgress,
...CACHED_DATA.progress,
});
});
describe('when called', () => {
......@@ -181,37 +120,23 @@ describe('Bulk import resolvers', () => {
});
it('mirrors REST endpoint response fields', () => {
const MIRRORED_FIELDS = ['id', 'full_name', 'full_path', 'web_url'];
const MIRRORED_FIELDS = [
{ from: 'id', to: 'id' },
{ from: 'full_name', to: 'fullName' },
{ from: 'full_path', to: 'fullPath' },
{ from: 'web_url', to: 'webUrl' },
];
expect(
results.every((r, idx) =>
MIRRORED_FIELDS.every(
(field) => r[field] === statusEndpointFixture.importable_data[idx][field],
(field) => r[field.to] === statusEndpointFixture.importable_data[idx][field.from],
),
),
).toBe(true);
});
it('populates each result instance with status default to none', () => {
expect(results.every((r) => r.progress.status === STATUSES.NONE)).toBe(true);
});
it('populates each result instance with import_target defaulted to first available namespace', () => {
expect(
results.every(
(r) => r.import_target.target_namespace === availableNamespacesFixture[0].full_path,
),
).toBe(true);
});
it('starts polling when request completes', async () => {
const [statusPoller] = StatusPoller.mock.instances;
expect(statusPoller.startPolling).toHaveBeenCalled();
});
it('requests validation status when request completes', async () => {
expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).not.toHaveBeenCalled();
jest.runOnlyPendingTimers();
expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).toHaveBeenCalled();
it('populates each result instance with empty status', () => {
expect(results.every((r) => r.progress === null)).toBe(true);
});
});
......@@ -223,6 +148,7 @@ describe('Bulk import resolvers', () => {
`(
'properly passes GraphQL variable $variable as REST $queryParam query parameter',
async ({ variable, queryParam, value }) => {
axiosMockAdapter.resetHistory();
await client.query({
query: bulkImportSourceGroupsQuery,
variables: { [variable]: value },
......@@ -237,275 +163,61 @@ describe('Bulk import resolvers', () => {
});
describe('mutations', () => {
const GROUP_ID = 1;
beforeEach(() => {
client.writeQuery({
query: bulkImportSourceGroupsQuery,
data: {
bulkImportSourceGroups: {
nodes: [
{
__typename: clientTypenames.BulkImportSourceGroup,
id: GROUP_ID,
progress: {
id: `test-${GROUP_ID}`,
status: STATUSES.NONE,
},
web_url: 'https://fake.host/1',
full_path: 'fake_group_1',
full_name: 'fake_name_1',
import_target: {
target_namespace: 'root',
new_name: 'group1',
},
last_import_target: {
target_namespace: 'root',
new_name: 'group1',
},
validation_errors: [],
},
],
pageInfo: {
page: 1,
perPage: 20,
total: 37,
totalPages: 2,
},
},
},
});
axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 });
});
describe('setImportTarget', () => {
it('updates group target namespace and name', async () => {
const NEW_TARGET_NAMESPACE = 'target';
const NEW_NAME = 'new';
const {
data: {
setImportTarget: {
id: idInResponse,
import_target: { target_namespace: namespaceInResponse, new_name: newNameInResponse },
},
},
} = await client.mutate({
mutation: setImportTargetMutation,
variables: {
sourceGroupId: GROUP_ID,
targetNamespace: NEW_TARGET_NAMESPACE,
newName: NEW_NAME,
},
});
expect(idInResponse).toBe(GROUP_ID);
expect(namespaceInResponse).toBe(NEW_TARGET_NAMESPACE);
expect(newNameInResponse).toBe(NEW_NAME);
});
it('invokes validation', async () => {
const NEW_TARGET_NAMESPACE = 'target';
const NEW_NAME = 'new';
describe('importGroup', () => {
it('sets import status to CREATED when request completes', async () => {
await client.mutate({
mutation: setImportTargetMutation,
mutation: importGroupsMutation,
variables: {
sourceGroupId: GROUP_ID,
targetNamespace: NEW_TARGET_NAMESPACE,
newName: NEW_NAME,
importRequests: [
{
sourceGroupId: statusEndpointFixture.importable_data[0].id,
newName: 'test',
targetNamespace: 'root',
},
],
},
});
expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).toHaveBeenCalledWith({
fullPath: `${NEW_TARGET_NAMESPACE}/${NEW_NAME}`,
});
});
});
describe('importGroup', () => {
it('sets status to SCHEDULING when request initiates', async () => {
axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(() => new Promise(() => {}));
client.mutate({
mutation: importGroupsMutation,
variables: { sourceGroupIds: [GROUP_ID] },
});
await waitForPromises();
const {
bulkImportSourceGroups: { nodes: intermediateResults },
} = client.readQuery({
query: bulkImportSourceGroupsQuery,
});
expect(intermediateResults[0].progress.status).toBe(STATUSES.SCHEDULING);
});
describe('when request completes', () => {
let results;
beforeEach(() => {
client
.watchQuery({
query: bulkImportSourceGroupsQuery,
fetchPolicy: 'cache-only',
})
.subscribe(({ data }) => {
results = data.bulkImportSourceGroups.nodes;
});
});
it('sets import status to CREATED when request completes', async () => {
axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 });
await client.mutate({
mutation: importGroupsMutation,
variables: { sourceGroupIds: [GROUP_ID] },
});
await waitForPromises();
expect(results[0].progress.status).toBe(STATUSES.CREATED);
});
it('resets status to NONE if request fails', async () => {
axiosMockAdapter
.onPost(FAKE_ENDPOINTS.createBulkImport)
.reply(httpStatus.INTERNAL_SERVER_ERROR);
client
.mutate({
mutation: [importGroupsMutation],
variables: { sourceGroupIds: [GROUP_ID] },
})
.catch(() => {});
await waitForPromises();
expect(results[0].progress.status).toBe(STATUSES.NONE);
});
});
it('shows default error message when server error is not provided', async () => {
axiosMockAdapter
.onPost(FAKE_ENDPOINTS.createBulkImport)
.reply(httpStatus.INTERNAL_SERVER_ERROR);
client
.mutate({
mutation: importGroupsMutation,
variables: { sourceGroupIds: [GROUP_ID] },
})
.catch(() => {});
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({ message: 'Importing the group failed' });
});
it('shows provided error message when error is included in backend response', async () => {
const CUSTOM_MESSAGE = 'custom message';
axiosMockAdapter
.onPost(FAKE_ENDPOINTS.createBulkImport)
.reply(httpStatus.INTERNAL_SERVER_ERROR, { error: CUSTOM_MESSAGE });
client
.mutate({
mutation: importGroupsMutation,
variables: { sourceGroupIds: [GROUP_ID] },
})
.catch(() => {});
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({ message: CUSTOM_MESSAGE });
await axios.waitForAll();
expect(results[0].progress.status).toBe(STATUSES.CREATED);
});
});
it('setImportProgress updates group progress and sets import target', async () => {
it('updateImportStatus updates status', async () => {
const NEW_STATUS = 'dummy';
const FAKE_JOB_ID = 5;
const IMPORT_TARGET = {
__typename: 'ClientBulkImportTarget',
new_name: 'fake_name',
target_namespace: 'fake_target',
};
const {
data: {
setImportProgress: { progress, last_import_target: lastImportTarget },
},
} = await client.mutate({
mutation: setImportProgressMutation,
await client.mutate({
mutation: importGroupsMutation,
variables: {
sourceGroupId: GROUP_ID,
status: NEW_STATUS,
jobId: FAKE_JOB_ID,
importTarget: IMPORT_TARGET,
importRequests: [
{
sourceGroupId: statusEndpointFixture.importable_data[0].id,
newName: 'test',
targetNamespace: 'root',
},
],
},
});
await axios.waitForAll();
await waitForPromises();
expect(lastImportTarget).toStrictEqual(IMPORT_TARGET);
expect(progress).toStrictEqual({
__typename: clientTypenames.BulkImportProgress,
id: FAKE_JOB_ID,
status: NEW_STATUS,
});
});
const { id } = results[0].progress;
it('updateImportStatus returns new status', async () => {
const NEW_STATUS = 'dummy';
const FAKE_JOB_ID = 5;
const {
data: { updateImportStatus: statusInResponse },
} = await client.mutate({
mutation: updateImportStatusMutation,
variables: { id: FAKE_JOB_ID, status: NEW_STATUS },
variables: { id, status: NEW_STATUS },
});
expect(statusInResponse).toStrictEqual({
__typename: clientTypenames.BulkImportProgress,
id: FAKE_JOB_ID,
id,
status: NEW_STATUS,
});
});
it('addValidationError adds error to group', async () => {
const FAKE_FIELD = 'some-field';
const FAKE_MESSAGE = 'some-message';
const {
data: {
addValidationError: { validation_errors: validationErrors },
},
} = await client.mutate({
mutation: addValidationErrorMutation,
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE },
});
expect(validationErrors).toStrictEqual([
{
__typename: clientTypenames.BulkImportValidationError,
field: FAKE_FIELD,
message: FAKE_MESSAGE,
},
]);
});
it('removeValidationError removes error from group', async () => {
const FAKE_FIELD = 'some-field';
const FAKE_MESSAGE = 'some-message';
await client.mutate({
mutation: addValidationErrorMutation,
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE },
});
const {
data: {
removeValidationError: { validation_errors: validationErrors },
},
} = await client.mutate({
mutation: removeValidationErrorMutation,
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD },
});
expect(validationErrors).toStrictEqual([]);
});
});
});
import { STATUSES } from '~/import_entities/constants';
import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory';
export const generateFakeEntry = ({ id, status, ...rest }) => ({
__typename: clientTypenames.BulkImportSourceGroup,
web_url: `https://fake.host/${id}`,
full_path: `fake_group_${id}`,
full_name: `fake_name_${id}`,
import_target: {
target_namespace: 'root',
new_name: `group${id}`,
},
last_import_target: {
target_namespace: 'root',
new_name: `last-group${id}`,
webUrl: `https://fake.host/${id}`,
fullPath: `fake_group_${id}`,
fullName: `fake_name_${id}`,
lastImportTarget: {
id,
targetNamespace: 'root',
newName: `group${id}`,
},
id,
progress: {
id: `test-${id}`,
status,
},
validation_errors: [],
progress:
status === STATUSES.NONE || status === STATUSES.PENDING
? null
: {
id,
status,
},
...rest,
});
......@@ -51,9 +51,9 @@ export const statusEndpointFixture = {
],
};
export const availableNamespacesFixture = [
{ id: 24, full_path: 'Commit451' },
{ id: 22, full_path: 'gitlab-org' },
{ id: 23, full_path: 'gnuwget' },
{ id: 25, full_path: 'jashkenas' },
];
export const availableNamespacesFixture = Object.freeze([
{ id: 24, fullPath: 'Commit451' },
{ id: 22, fullPath: 'gitlab-org' },
{ id: 23, fullPath: 'gnuwget' },
{ id: 25, fullPath: 'jashkenas' },
]);
import {
KEY,
LocalStorageCache,
} from '~/import_entities/import_groups/graphql/services/local_storage_cache';
describe('Local storage cache', () => {
let cache;
let storage;
beforeEach(() => {
storage = {
getItem: jest.fn(),
setItem: jest.fn(),
};
cache = new LocalStorageCache({ storage });
});
describe('storage management', () => {
const IMPORT_URL = 'http://fake.url';
it('loads state from storage on creation', () => {
expect(storage.getItem).toHaveBeenCalledWith(KEY);
});
it('saves to storage when set is called', () => {
const STORAGE_CONTENT = { fake: 'content ' };
cache.set(IMPORT_URL, STORAGE_CONTENT);
expect(storage.setItem).toHaveBeenCalledWith(
KEY,
JSON.stringify({ [IMPORT_URL]: STORAGE_CONTENT }),
);
});
it('updates status by job id', () => {
const CHANGED_STATUS = 'changed';
const JOB_ID = 2;
cache.set(IMPORT_URL, {
progress: {
id: JOB_ID,
status: 'original',
},
});
cache.updateStatusByJobId(JOB_ID, CHANGED_STATUS);
expect(storage.setItem).toHaveBeenCalledWith(
KEY,
JSON.stringify({
[IMPORT_URL]: {
progress: {
id: JOB_ID,
status: CHANGED_STATUS,
},
},
}),
);
});
});
});
import {
KEY,
SourceGroupsManager,
} from '~/import_entities/import_groups/graphql/services/source_groups_manager';
const FAKE_SOURCE_URL = 'http://demo.host';
describe('SourceGroupsManager', () => {
let manager;
let storage;
beforeEach(() => {
storage = {
getItem: jest.fn(),
setItem: jest.fn(),
};
manager = new SourceGroupsManager({ storage, sourceUrl: FAKE_SOURCE_URL });
});
describe('storage management', () => {
const IMPORT_ID = 1;
const IMPORT_TARGET = { new_name: 'demo', target_namespace: 'foo' };
const STATUS = 'FAKE_STATUS';
const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS };
it('loads state from storage on creation', () => {
expect(storage.getItem).toHaveBeenCalledWith(KEY);
});
it('saves to storage when createImportState is called', () => {
const FAKE_STATUS = 'fake;';
manager.createImportState(IMPORT_ID, { status: FAKE_STATUS, groups: [FAKE_GROUP] });
const storedObject = JSON.parse(storage.setItem.mock.calls[0][1]);
expect(Object.values(storedObject)[0]).toStrictEqual({
status: FAKE_STATUS,
groups: [
{
id: FAKE_GROUP.id,
importTarget: IMPORT_TARGET,
},
],
});
});
it('updates storage when previous state is available', () => {
const CHANGED_STATUS = 'changed';
manager.createImportState(IMPORT_ID, { status: STATUS, groups: [FAKE_GROUP] });
manager.updateImportProgress(IMPORT_ID, CHANGED_STATUS);
const storedObject = JSON.parse(storage.setItem.mock.calls[1][1]);
expect(Object.values(storedObject)[0]).toStrictEqual({
status: CHANGED_STATUS,
groups: [
{
id: FAKE_GROUP.id,
importTarget: IMPORT_TARGET,
},
],
});
});
});
});
......@@ -2,19 +2,13 @@ import MockAdapter from 'axios-mock-adapter';
import Visibility from 'visibilityjs';
import createFlash from '~/flash';
import { STATUSES } from '~/import_entities/constants';
import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller';
import { StatusPoller } from '~/import_entities/import_groups/services/status_poller';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
jest.mock('visibilityjs');
jest.mock('~/flash');
jest.mock('~/lib/utils/poll');
jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manager', () => ({
SourceGroupsManager: jest.fn().mockImplementation(function mock() {
this.setImportStatus = jest.fn();
this.findByImportId = jest.fn();
}),
}));
const FAKE_POLL_PATH = '/fake/poll/path';
......@@ -81,6 +75,7 @@ describe('Bulk import status poller', () => {
const [pollInstance] = Poll.mock.instances;
poller.startPolling();
await Promise.resolve();
expect(pollInstance.makeRequest).toHaveBeenCalled();
});
......
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