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