Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
14c866bb2f
commit
f3189d2a01
|
@ -360,6 +360,7 @@ db:check-migrations:
|
|||
- .db-job-base
|
||||
- .rails:rules:ee-and-foss-mr-with-migration
|
||||
script:
|
||||
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME:$CI_MERGE_REQUEST_TARGET_BRANCH_NAME --depth 20
|
||||
- scripts/validate_migration_schema
|
||||
allow_failure: true
|
||||
|
||||
|
|
|
@ -3,10 +3,11 @@ import axios from '~/lib/utils/axios_utils';
|
|||
|
||||
const NAMESPACE_EXISTS_PATH = '/api/:version/namespaces/:id/exists';
|
||||
|
||||
export default function fetchGroupPathAvailability(groupPath, parentId) {
|
||||
export function getGroupPathAvailability(groupPath, parentId, axiosOptions = {}) {
|
||||
const url = buildApiUrl(NAMESPACE_EXISTS_PATH).replace(':id', encodeURIComponent(groupPath));
|
||||
|
||||
return axios.get(url, {
|
||||
params: { parent_id: parentId },
|
||||
params: { parent_id: parentId, ...axiosOptions.params },
|
||||
...axiosOptions,
|
||||
});
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import createFlash from '~/flash';
|
||||
import { __ } from '~/locale';
|
||||
import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability';
|
||||
import { getGroupPathAvailability } from '~/rest_api';
|
||||
import { slugify } from './lib/utils/text_utility';
|
||||
|
||||
export default class Group {
|
||||
|
@ -51,7 +51,7 @@ export default class Group {
|
|||
const slug = this.groupPaths[0]?.value || slugify(value);
|
||||
if (!slug) return;
|
||||
|
||||
fetchGroupPathAvailability(slug, this.parentId?.value)
|
||||
getGroupPathAvailability(slug, this.parentId?.value)
|
||||
.then(({ data }) => data)
|
||||
.then(({ exists, suggests }) => {
|
||||
if (exists && suggests.length) {
|
||||
|
|
|
@ -19,7 +19,7 @@ export default {
|
|||
computed: {
|
||||
filteredNamespaces() {
|
||||
return this.namespaces.filter((ns) =>
|
||||
ns.toLowerCase().includes(this.searchTerm.toLowerCase()),
|
||||
ns.fullPath.toLowerCase().includes(this.searchTerm.toLowerCase()),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
<script>
|
||||
import { GlButton, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
|
||||
import { joinPaths } from '~/lib/utils/url_utility';
|
||||
import { isFinished, isInvalid, isAvailableForImport } from '../utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -12,32 +10,17 @@ export default {
|
|||
GlTooltip,
|
||||
},
|
||||
props: {
|
||||
group: {
|
||||
type: Object,
|
||||
isFinished: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
groupPathRegex: {
|
||||
type: RegExp,
|
||||
isAvailableForImport: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
fullLastImportPath() {
|
||||
return this.group.last_import_target
|
||||
? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}`
|
||||
: null;
|
||||
},
|
||||
absoluteLastImportPath() {
|
||||
return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath);
|
||||
},
|
||||
isAvailableForImport() {
|
||||
return isAvailableForImport(this.group);
|
||||
},
|
||||
isFinished() {
|
||||
return isFinished(this.group);
|
||||
},
|
||||
isInvalid() {
|
||||
return isInvalid(this.group, this.groupPathRegex);
|
||||
isInvalid: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -56,7 +39,7 @@ export default {
|
|||
{{ isFinished ? __('Re-import') : __('Import') }}
|
||||
</gl-button>
|
||||
<gl-icon
|
||||
v-if="isFinished"
|
||||
v-if="isAvailableForImport && isFinished"
|
||||
v-gl-tooltip
|
||||
:size="16"
|
||||
name="information-o"
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<script>
|
||||
import { GlLink, GlSprintf, GlIcon } from '@gitlab/ui';
|
||||
import { joinPaths } from '~/lib/utils/url_utility';
|
||||
import { isFinished } from '../utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -17,16 +16,13 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
fullLastImportPath() {
|
||||
return this.group.last_import_target
|
||||
? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}`
|
||||
return this.group.lastImportTarget
|
||||
? `${this.group.lastImportTarget.targetNamespace}/${this.group.lastImportTarget.newName}`
|
||||
: null;
|
||||
},
|
||||
absoluteLastImportPath() {
|
||||
return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath);
|
||||
},
|
||||
isFinished() {
|
||||
return isFinished(this.group);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -34,13 +30,13 @@ export default {
|
|||
<template>
|
||||
<div>
|
||||
<gl-link
|
||||
:href="group.web_url"
|
||||
:href="group.webUrl"
|
||||
target="_blank"
|
||||
class="gl-display-inline-flex gl-align-items-center gl-h-7"
|
||||
>
|
||||
{{ group.full_path }} <gl-icon name="external-link" />
|
||||
{{ group.fullPath }} <gl-icon name="external-link" />
|
||||
</gl-link>
|
||||
<div v-if="isFinished && fullLastImportPath" class="gl-font-sm">
|
||||
<div v-if="group.flags.isFinished && fullLastImportPath" class="gl-font-sm">
|
||||
<gl-sprintf :message="s__('BulkImport|Last imported to %{link}')">
|
||||
<template #link>
|
||||
<gl-link :href="absoluteLastImportPath" class="gl-font-sm" target="_blank">{{
|
||||
|
|
|
@ -12,18 +12,28 @@ import {
|
|||
GlTable,
|
||||
GlFormCheckbox,
|
||||
} from '@gitlab/ui';
|
||||
import { debounce } from 'lodash';
|
||||
import createFlash from '~/flash';
|
||||
import { s__, __, n__ } from '~/locale';
|
||||
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
|
||||
import { getGroupPathAvailability } from '~/rest_api';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
|
||||
|
||||
import { STATUSES } from '../../constants';
|
||||
import ImportStatusCell from '../../components/import_status.vue';
|
||||
import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql';
|
||||
import setImportTargetMutation from '../graphql/mutations/set_import_target.mutation.graphql';
|
||||
import updateImportStatusMutation from '../graphql/mutations/update_import_status.mutation.graphql';
|
||||
import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql';
|
||||
import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql';
|
||||
import { isInvalid, isFinished, isAvailableForImport } from '../utils';
|
||||
import { NEW_NAME_FIELD, i18n } from '../constants';
|
||||
import { StatusPoller } from '../services/status_poller';
|
||||
import { isFinished, isAvailableForImport, isNameValid, isSameTarget } from '../utils';
|
||||
import ImportActionsCell from './import_actions_cell.vue';
|
||||
import ImportSourceCell from './import_source_cell.vue';
|
||||
import ImportTargetCell from './import_target_cell.vue';
|
||||
|
||||
const VALIDATION_DEBOUNCE_TIME = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
|
||||
const PAGE_SIZES = [20, 50, 100];
|
||||
const DEFAULT_PAGE_SIZE = PAGE_SIZES[0];
|
||||
const DEFAULT_TH_CLASSES =
|
||||
|
@ -59,7 +69,7 @@ export default {
|
|||
type: RegExp,
|
||||
required: true,
|
||||
},
|
||||
groupUrlErrorMessage: {
|
||||
jobsPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
@ -70,7 +80,9 @@ export default {
|
|||
filter: '',
|
||||
page: 1,
|
||||
perPage: DEFAULT_PAGE_SIZE,
|
||||
selectedGroups: [],
|
||||
selectedGroupsIds: [],
|
||||
pendingGroupsIds: [],
|
||||
importTargets: {},
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -94,14 +106,14 @@ export default {
|
|||
tdClass: `${DEFAULT_TD_CLASSES} gl-pr-3!`,
|
||||
},
|
||||
{
|
||||
key: 'web_url',
|
||||
key: 'webUrl',
|
||||
label: s__('BulkImport|From source group'),
|
||||
thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! import-jobs-from-col`,
|
||||
// eslint-disable-next-line @gitlab/require-i18n-strings
|
||||
tdClass: `${DEFAULT_TD_CLASSES} gl-pl-0!`,
|
||||
},
|
||||
{
|
||||
key: 'import_target',
|
||||
key: 'importTarget',
|
||||
label: s__('BulkImport|To new group'),
|
||||
thClass: `${DEFAULT_TH_CLASSES} import-jobs-to-col`,
|
||||
tdClass: DEFAULT_TD_CLASSES,
|
||||
|
@ -126,16 +138,39 @@ export default {
|
|||
return this.bulkImportSourceGroups?.nodes ?? [];
|
||||
},
|
||||
|
||||
groupsTableData() {
|
||||
return this.groups.map((group) => {
|
||||
const importTarget = this.getImportTarget(group);
|
||||
const status = this.getStatus(group);
|
||||
|
||||
const flags = {
|
||||
isInvalid: importTarget.validationErrors?.length > 0,
|
||||
isAvailableForImport: isAvailableForImport(group) && status !== STATUSES.SCHEDULING,
|
||||
isFinished: isFinished(group),
|
||||
};
|
||||
|
||||
return {
|
||||
...group,
|
||||
visibleStatus: status,
|
||||
importTarget,
|
||||
flags: {
|
||||
...flags,
|
||||
isUnselectable: !flags.isAvailableForImport || flags.isInvalid,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
hasSelectedGroups() {
|
||||
return this.selectedGroups.length > 0;
|
||||
return this.selectedGroupsIds.length > 0;
|
||||
},
|
||||
|
||||
hasAllAvailableGroupsSelected() {
|
||||
return this.selectedGroups.length === this.availableGroupsForImport.length;
|
||||
return this.selectedGroupsIds.length === this.availableGroupsForImport.length;
|
||||
},
|
||||
|
||||
availableGroupsForImport() {
|
||||
return this.groups.filter((g) => isAvailableForImport(g) && !this.isInvalid(g));
|
||||
return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && g.flags.isInvalid);
|
||||
},
|
||||
|
||||
humanizedTotal() {
|
||||
|
@ -175,25 +210,43 @@ export default {
|
|||
filter() {
|
||||
this.page = 1;
|
||||
},
|
||||
groups() {
|
||||
|
||||
groupsTableData() {
|
||||
const table = this.getTableRef();
|
||||
this.groups.forEach((g, idx) => {
|
||||
if (this.selectedGroups.includes(g)) {
|
||||
const matches = new Set();
|
||||
this.groupsTableData.forEach((g, idx) => {
|
||||
if (this.selectedGroupsIds.includes(g.id)) {
|
||||
matches.add(g.id);
|
||||
this.$nextTick(() => {
|
||||
table.selectRow(idx);
|
||||
});
|
||||
}
|
||||
});
|
||||
this.selectedGroups = [];
|
||||
|
||||
this.selectedGroupsIds = this.selectedGroupsIds.filter((id) => matches.has(id));
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
isUnselectable(group) {
|
||||
return !this.isAvailableForImport(group) || this.isInvalid(group);
|
||||
},
|
||||
mounted() {
|
||||
this.statusPoller = new StatusPoller({
|
||||
pollPath: this.jobsPath,
|
||||
updateImportStatus: (update) => {
|
||||
this.$apollo.mutate({
|
||||
mutation: updateImportStatusMutation,
|
||||
variables: { id: update.id, status: update.status_name },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
rowClasses(group) {
|
||||
this.statusPoller.startPolling();
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.statusPoller.stopPolling();
|
||||
},
|
||||
|
||||
methods: {
|
||||
rowClasses(groupTableItem) {
|
||||
const DEFAULT_CLASSES = [
|
||||
'gl-border-gray-200',
|
||||
'gl-border-0',
|
||||
|
@ -201,7 +254,7 @@ export default {
|
|||
'gl-border-solid',
|
||||
];
|
||||
const result = [...DEFAULT_CLASSES];
|
||||
if (this.isUnselectable(group)) {
|
||||
if (groupTableItem.flags.isUnselectable) {
|
||||
result.push('gl-cursor-default!');
|
||||
}
|
||||
return result;
|
||||
|
@ -211,19 +264,13 @@ export default {
|
|||
if (type === 'row') {
|
||||
return {
|
||||
'data-qa-selector': 'import_item',
|
||||
'data-qa-source-group': group.full_path,
|
||||
'data-qa-source-group': group.fullPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
|
||||
isAvailableForImport,
|
||||
isFinished,
|
||||
isInvalid(group) {
|
||||
return isInvalid(group, this.groupPathRegex);
|
||||
},
|
||||
|
||||
groupsCount(count) {
|
||||
return n__('%d group', '%d groups', count);
|
||||
},
|
||||
|
@ -232,22 +279,64 @@ export default {
|
|||
this.page = page;
|
||||
},
|
||||
|
||||
updateImportTarget(sourceGroupId, targetNamespace, newName) {
|
||||
this.$apollo.mutate({
|
||||
mutation: setImportTargetMutation,
|
||||
variables: { sourceGroupId, targetNamespace, newName },
|
||||
});
|
||||
getStatus(group) {
|
||||
if (this.pendingGroupsIds.includes(group.id)) {
|
||||
return STATUSES.SCHEDULING;
|
||||
}
|
||||
|
||||
return group.progress?.status || STATUSES.NONE;
|
||||
},
|
||||
|
||||
importGroups(sourceGroupIds) {
|
||||
this.$apollo.mutate({
|
||||
mutation: importGroupsMutation,
|
||||
variables: { sourceGroupIds },
|
||||
updateImportTarget(group, changes) {
|
||||
const newImportTarget = {
|
||||
...group.importTarget,
|
||||
...changes,
|
||||
};
|
||||
this.$set(this.importTargets, group.id, newImportTarget);
|
||||
this.validateImportTarget(newImportTarget);
|
||||
},
|
||||
|
||||
async importGroups(importRequests) {
|
||||
const newPendingGroupsIds = importRequests.map((request) => request.sourceGroupId);
|
||||
newPendingGroupsIds.forEach((id) => {
|
||||
this.importTargets[id].validationErrors = [
|
||||
{ field: NEW_NAME_FIELD, message: i18n.ERROR_IMPORT_COMPLETED },
|
||||
];
|
||||
|
||||
if (!this.pendingGroupsIds.includes(id)) {
|
||||
this.pendingGroupsIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: importGroupsMutation,
|
||||
variables: { importRequests },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error?.networkError?.response?.data?.error ?? i18n.ERROR_IMPORT;
|
||||
createFlash({
|
||||
message,
|
||||
captureError: true,
|
||||
error,
|
||||
});
|
||||
} finally {
|
||||
this.pendingGroupsIds = this.pendingGroupsIds.filter(
|
||||
(id) => !newPendingGroupsIds.includes(id),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
importSelectedGroups() {
|
||||
this.importGroups(this.selectedGroups.map((g) => g.id));
|
||||
const importRequests = this.groupsTableData
|
||||
.filter((group) => this.selectedGroupsIds.includes(group.id))
|
||||
.map((group) => ({
|
||||
sourceGroupId: group.id,
|
||||
targetNamespace: group.importTarget.targetNamespace.fullPath,
|
||||
newName: group.importTarget.newName,
|
||||
}));
|
||||
|
||||
this.importGroups(importRequests);
|
||||
},
|
||||
|
||||
setPageSize(size) {
|
||||
|
@ -263,16 +352,115 @@ export default {
|
|||
|
||||
preventSelectingAlreadyImportedGroups(updatedSelection) {
|
||||
if (updatedSelection) {
|
||||
this.selectedGroups = updatedSelection;
|
||||
this.selectedGroupsIds = updatedSelection.map((g) => g.id);
|
||||
}
|
||||
|
||||
const table = this.getTableRef();
|
||||
this.groups.forEach((group, idx) => {
|
||||
if (table.isRowSelected(idx) && this.isUnselectable(group)) {
|
||||
this.groupsTableData.forEach((group, idx) => {
|
||||
if (table.isRowSelected(idx) && group.flags.isUnselectable) {
|
||||
table.unselectRow(idx);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
validateImportTarget: debounce(async function validate(importTarget) {
|
||||
const newValidationErrors = [];
|
||||
importTarget.cancellationToken?.cancel();
|
||||
if (importTarget.newName === '') {
|
||||
newValidationErrors.push({ field: NEW_NAME_FIELD, message: i18n.ERROR_REQUIRED });
|
||||
} else if (!isNameValid(importTarget, this.groupPathRegex)) {
|
||||
newValidationErrors.push({ field: NEW_NAME_FIELD, message: i18n.ERROR_INVALID_FORMAT });
|
||||
} else if (Object.values(this.importTargets).find(isSameTarget(importTarget))) {
|
||||
newValidationErrors.push({
|
||||
field: NEW_NAME_FIELD,
|
||||
message: i18n.ERROR_NAME_ALREADY_USED_IN_SUGGESTION,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
importTarget.cancellationToken = axios.CancelToken.source();
|
||||
const {
|
||||
data: { exists },
|
||||
} = await getGroupPathAvailability(
|
||||
importTarget.newName,
|
||||
importTarget.targetNamespace.id,
|
||||
{
|
||||
cancelToken: importTarget.cancellationToken?.token,
|
||||
},
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
newValidationErrors.push({
|
||||
field: NEW_NAME_FIELD,
|
||||
message: i18n.ERROR_NAME_ALREADY_EXISTS,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!axios.isCancel(e)) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
importTarget.validationErrors = newValidationErrors;
|
||||
}, VALIDATION_DEBOUNCE_TIME),
|
||||
|
||||
getImportTarget(group) {
|
||||
if (this.importTargets[group.id]) {
|
||||
return this.importTargets[group.id];
|
||||
}
|
||||
|
||||
const defaultTargetNamespace = this.availableNamespaces[0] ?? { fullPath: '', id: null };
|
||||
let importTarget;
|
||||
if (group.lastImportTarget) {
|
||||
const targetNamespace = this.availableNamespaces.find(
|
||||
(ns) => ns.fullPath === group.lastImportTarget.targetNamespace,
|
||||
);
|
||||
|
||||
importTarget = {
|
||||
targetNamespace: targetNamespace ?? defaultTargetNamespace,
|
||||
newName: group.lastImportTarget.newName,
|
||||
};
|
||||
} else {
|
||||
importTarget = {
|
||||
targetNamespace: defaultTargetNamespace,
|
||||
newName: group.fullPath,
|
||||
};
|
||||
}
|
||||
|
||||
const cancellationToken = axios.CancelToken.source();
|
||||
this.$set(this.importTargets, group.id, {
|
||||
...importTarget,
|
||||
cancellationToken,
|
||||
validationErrors: [],
|
||||
});
|
||||
|
||||
getGroupPathAvailability(importTarget.newName, importTarget.targetNamespace.id, {
|
||||
cancelToken: cancellationToken.token,
|
||||
})
|
||||
.then(({ data: { exists, suggests: suggestions } }) => {
|
||||
if (!exists) return;
|
||||
|
||||
let currentSuggestion = suggestions[0] ?? importTarget.newName;
|
||||
const existingTargets = Object.values(this.importTargets)
|
||||
.filter((t) => t.targetNamespace.id === importTarget.targetNamespace.id)
|
||||
.map((t) => t.newName.toLowerCase());
|
||||
|
||||
while (existingTargets.includes(currentSuggestion.toLowerCase())) {
|
||||
currentSuggestion = `${currentSuggestion}-1`;
|
||||
}
|
||||
|
||||
Object.assign(this.importTargets[group.id], {
|
||||
targetNamespace: importTarget.targetNamespace,
|
||||
newName: currentSuggestion,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// empty catch intended
|
||||
});
|
||||
return this.importTargets[group.id];
|
||||
},
|
||||
},
|
||||
|
||||
gitlabLogo: window.gon.gitlab_logo,
|
||||
|
@ -337,7 +525,7 @@ export default {
|
|||
>
|
||||
<gl-sprintf :message="__('%{count} selected')">
|
||||
<template #count>
|
||||
{{ selectedGroups.length }}
|
||||
{{ selectedGroupsIds.length }}
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
<gl-button
|
||||
|
@ -355,7 +543,7 @@ export default {
|
|||
data-qa-selector="import_table"
|
||||
:tbody-tr-class="rowClasses"
|
||||
:tbody-tr-attr="qaRowAttributes"
|
||||
:items="groups"
|
||||
:items="groupsTableData"
|
||||
:fields="$options.fields"
|
||||
selectable
|
||||
select-mode="multi"
|
||||
|
@ -364,7 +552,7 @@ export default {
|
|||
>
|
||||
<template #head(selected)="{ selectAllRows, clearSelected }">
|
||||
<gl-form-checkbox
|
||||
:key="`checkbox-${selectedGroups.length}`"
|
||||
:key="`checkbox-${selectedGroupsIds.length}`"
|
||||
class="gl-h-7 gl-pt-3"
|
||||
:checked="hasSelectedGroups"
|
||||
:indeterminate="hasSelectedGroups && !hasAllAvailableGroupsSelected"
|
||||
|
@ -375,35 +563,39 @@ export default {
|
|||
<gl-form-checkbox
|
||||
class="gl-h-7 gl-pt-3"
|
||||
:checked="rowSelected"
|
||||
:disabled="!isAvailableForImport(group) || isInvalid(group)"
|
||||
:disabled="group.flags.isUnselectable"
|
||||
@change="rowSelected ? unselectRow() : selectRow()"
|
||||
/>
|
||||
</template>
|
||||
<template #cell(web_url)="{ item: group }">
|
||||
<template #cell(webUrl)="{ item: group }">
|
||||
<import-source-cell :group="group" />
|
||||
</template>
|
||||
<template #cell(import_target)="{ item: group }">
|
||||
<template #cell(importTarget)="{ item: group }">
|
||||
<import-target-cell
|
||||
:group="group"
|
||||
:available-namespaces="availableNamespaces"
|
||||
:group-path-regex="groupPathRegex"
|
||||
:group-url-error-message="groupUrlErrorMessage"
|
||||
@update-target-namespace="
|
||||
updateImportTarget(group.id, $event, group.import_target.new_name)
|
||||
"
|
||||
@update-new-name="
|
||||
updateImportTarget(group.id, group.import_target.target_namespace, $event)
|
||||
"
|
||||
@update-target-namespace="updateImportTarget(group, { targetNamespace: $event })"
|
||||
@update-new-name="updateImportTarget(group, { newName: $event })"
|
||||
/>
|
||||
</template>
|
||||
<template #cell(progress)="{ value: { status } }">
|
||||
<import-status-cell :status="status" class="gl-line-height-32" />
|
||||
<template #cell(progress)="{ item: group }">
|
||||
<import-status-cell :status="group.visibleStatus" class="gl-line-height-32" />
|
||||
</template>
|
||||
<template #cell(actions)="{ item: group }">
|
||||
<import-actions-cell
|
||||
:group="group"
|
||||
:group-path-regex="groupPathRegex"
|
||||
@import-group="importGroups([group.id])"
|
||||
:is-finished="group.flags.isFinished"
|
||||
:is-available-for-import="group.flags.isAvailableForImport"
|
||||
:is-invalid="group.flags.isInvalid"
|
||||
@import-group="
|
||||
importGroups([
|
||||
{
|
||||
sourceGroupId: group.id,
|
||||
targetNamespace: group.importTarget.targetNamespace.fullPath,
|
||||
newName: group.importTarget.newName,
|
||||
},
|
||||
])
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</gl-table>
|
||||
|
@ -413,7 +605,7 @@ export default {
|
|||
:page-info="bulkImportSourceGroups.pageInfo"
|
||||
class="gl-m-0"
|
||||
/>
|
||||
<gl-dropdown category="tertiary" class="gl-ml-auto">
|
||||
<gl-dropdown category="tertiary" :aria-label="__('Page size')" class="gl-ml-auto">
|
||||
<template #button-content>
|
||||
<span class="font-weight-bold">
|
||||
<gl-sprintf :message="__('%{count} items per page')">
|
||||
|
|
|
@ -7,12 +7,7 @@ import {
|
|||
} from '@gitlab/ui';
|
||||
import { s__ } from '~/locale';
|
||||
import ImportGroupDropdown from '../../components/group_dropdown.vue';
|
||||
import {
|
||||
isInvalid,
|
||||
getInvalidNameValidationMessage,
|
||||
isNameValid,
|
||||
isAvailableForImport,
|
||||
} from '../utils';
|
||||
import { getInvalidNameValidationMessage } from '../utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -31,44 +26,15 @@ export default {
|
|||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
groupPathRegex: {
|
||||
type: RegExp,
|
||||
required: true,
|
||||
},
|
||||
groupUrlErrorMessage: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
availableNamespaceNames() {
|
||||
return this.availableNamespaces.map((ns) => ns.full_path);
|
||||
fullPath() {
|
||||
return this.group.importTarget.targetNamespace.fullPath || s__('BulkImport|No parent');
|
||||
},
|
||||
|
||||
importTarget() {
|
||||
return this.group.import_target;
|
||||
},
|
||||
|
||||
invalidNameValidationMessage() {
|
||||
return getInvalidNameValidationMessage(this.group);
|
||||
return getInvalidNameValidationMessage(this.group.importTarget);
|
||||
},
|
||||
|
||||
isInvalid() {
|
||||
return isInvalid(this.group, this.groupPathRegex);
|
||||
},
|
||||
|
||||
isNameValid() {
|
||||
return isNameValid(this.group, this.groupPathRegex);
|
||||
},
|
||||
|
||||
isAvailableForImport() {
|
||||
return isAvailableForImport(this.group);
|
||||
},
|
||||
},
|
||||
|
||||
i18n: {
|
||||
NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -77,14 +43,14 @@ export default {
|
|||
<div class="gl-display-flex gl-align-items-stretch">
|
||||
<import-group-dropdown
|
||||
#default="{ namespaces }"
|
||||
:text="importTarget.target_namespace"
|
||||
:disabled="!isAvailableForImport"
|
||||
:namespaces="availableNamespaceNames"
|
||||
:text="fullPath"
|
||||
:disabled="!group.flags.isAvailableForImport"
|
||||
:namespaces="availableNamespaces"
|
||||
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
|
||||
class="gl-h-7 gl-flex-grow-1"
|
||||
data-qa-selector="target_namespace_selector_dropdown"
|
||||
>
|
||||
<gl-dropdown-item @click="$emit('update-target-namespace', '')">{{
|
||||
<gl-dropdown-item @click="$emit('update-target-namespace', { fullPath: '', id: null })">{{
|
||||
s__('BulkImport|No parent')
|
||||
}}</gl-dropdown-item>
|
||||
<template v-if="namespaces.length">
|
||||
|
@ -94,20 +60,20 @@ export default {
|
|||
</gl-dropdown-section-header>
|
||||
<gl-dropdown-item
|
||||
v-for="ns in namespaces"
|
||||
:key="ns"
|
||||
:key="ns.fullPath"
|
||||
data-qa-selector="target_group_dropdown_item"
|
||||
:data-qa-group-name="ns"
|
||||
:data-qa-group-name="ns.fullPath"
|
||||
@click="$emit('update-target-namespace', ns)"
|
||||
>
|
||||
{{ ns }}
|
||||
{{ ns.fullPath }}
|
||||
</gl-dropdown-item>
|
||||
</template>
|
||||
</import-group-dropdown>
|
||||
<div
|
||||
class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10"
|
||||
:class="{
|
||||
'gl-text-gray-400 gl-border-gray-100': !isAvailableForImport,
|
||||
'gl-border-gray-200': isAvailableForImport,
|
||||
'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport,
|
||||
'gl-border-gray-200': group.flags.isAvailableForImport,
|
||||
}"
|
||||
>
|
||||
/
|
||||
|
@ -116,21 +82,21 @@ export default {
|
|||
<gl-form-input
|
||||
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
|
||||
:class="{
|
||||
'gl-inset-border-1-gray-200!': isAvailableForImport,
|
||||
'gl-inset-border-1-gray-100!': !isAvailableForImport,
|
||||
'is-invalid': isInvalid && isAvailableForImport,
|
||||
'gl-inset-border-1-gray-200!': group.flags.isAvailableForImport,
|
||||
'gl-inset-border-1-gray-100!': !group.flags.isAvailableForImport,
|
||||
'is-invalid': group.flags.isInvalid && group.flags.isAvailableForImport,
|
||||
}"
|
||||
:disabled="!isAvailableForImport"
|
||||
:value="importTarget.new_name"
|
||||
debounce="500"
|
||||
:disabled="!group.flags.isAvailableForImport"
|
||||
:value="group.importTarget.newName"
|
||||
:aria-label="__('New name')"
|
||||
@input="$emit('update-new-name', $event)"
|
||||
/>
|
||||
<p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2">
|
||||
<template v-if="!isNameValid">
|
||||
{{ groupUrlErrorMessage }}
|
||||
</template>
|
||||
<template v-else-if="invalidNameValidationMessage">
|
||||
{{ invalidNameValidationMessage }}
|
||||
</template>
|
||||
<p
|
||||
v-if="group.flags.isAvailableForImport && group.flags.isInvalid"
|
||||
class="gl-text-red-500 gl-m-0 gl-mt-2"
|
||||
>
|
||||
{{ invalidNameValidationMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,16 @@
|
|||
import { s__ } from '~/locale';
|
||||
import { __, s__ } from '~/locale';
|
||||
|
||||
export const i18n = {
|
||||
NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
|
||||
ERROR_INVALID_FORMAT: s__(
|
||||
'GroupSettings|Please choose a group URL with no special characters or spaces.',
|
||||
),
|
||||
ERROR_NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
|
||||
ERROR_REQUIRED: __('This field is required.'),
|
||||
ERROR_NAME_ALREADY_USED_IN_SUGGESTION: s__(
|
||||
'BulkImport|Name already used as a target for another group.',
|
||||
),
|
||||
ERROR_IMPORT: s__('BulkImport|Importing the group failed.'),
|
||||
ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'),
|
||||
};
|
||||
|
||||
export const NEW_NAME_FIELD = 'new_name';
|
||||
export const NEW_NAME_FIELD = 'newName';
|
||||
|
|
|
@ -1,23 +1,10 @@
|
|||
import createFlash from '~/flash';
|
||||
import createDefaultClient from '~/lib/graphql';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
|
||||
import { s__ } from '~/locale';
|
||||
import { STATUSES } from '../../constants';
|
||||
import { i18n, NEW_NAME_FIELD } from '../constants';
|
||||
import { isAvailableForImport } from '../utils';
|
||||
import bulkImportSourceGroupItemFragment from './fragments/bulk_import_source_group_item.fragment.graphql';
|
||||
import bulkImportSourceGroupProgressFragment from './fragments/bulk_import_source_group_progress.fragment.graphql';
|
||||
import addValidationErrorMutation from './mutations/add_validation_error.mutation.graphql';
|
||||
import removeValidationErrorMutation from './mutations/remove_validation_error.mutation.graphql';
|
||||
import setImportProgressMutation from './mutations/set_import_progress.mutation.graphql';
|
||||
import setImportTargetMutation from './mutations/set_import_target.mutation.graphql';
|
||||
import updateImportStatusMutation from './mutations/update_import_status.mutation.graphql';
|
||||
import availableNamespacesQuery from './queries/available_namespaces.query.graphql';
|
||||
import bulkImportSourceGroupQuery from './queries/bulk_import_source_group.query.graphql';
|
||||
import groupAndProjectQuery from './queries/group_and_project.query.graphql';
|
||||
import { SourceGroupsManager } from './services/source_groups_manager';
|
||||
import { StatusPoller } from './services/status_poller';
|
||||
import { LocalStorageCache } from './services/local_storage_cache';
|
||||
import typeDefs from './typedefs.graphql';
|
||||
|
||||
export const clientTypenames = {
|
||||
|
@ -27,221 +14,99 @@ export const clientTypenames = {
|
|||
BulkImportPageInfo: 'ClientBulkImportPageInfo',
|
||||
BulkImportTarget: 'ClientBulkImportTarget',
|
||||
BulkImportProgress: 'ClientBulkImportProgress',
|
||||
BulkImportValidationError: 'ClientBulkImportValidationError',
|
||||
};
|
||||
|
||||
function makeGroup(data) {
|
||||
const result = {
|
||||
__typename: clientTypenames.BulkImportSourceGroup,
|
||||
function makeLastImportTarget(data) {
|
||||
return {
|
||||
__typename: clientTypenames.BulkImportTarget,
|
||||
...data,
|
||||
};
|
||||
const NESTED_OBJECT_FIELDS = {
|
||||
import_target: clientTypenames.BulkImportTarget,
|
||||
last_import_target: clientTypenames.BulkImportTarget,
|
||||
progress: clientTypenames.BulkImportProgress,
|
||||
};
|
||||
|
||||
Object.entries(NESTED_OBJECT_FIELDS).forEach(([field, type]) => {
|
||||
if (!data[field]) {
|
||||
return;
|
||||
}
|
||||
result[field] = {
|
||||
__typename: type,
|
||||
...data[field],
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function checkImportTargetIsValid({ client, newName, targetNamespace, sourceGroupId }) {
|
||||
const {
|
||||
data: { existingGroup, existingProject },
|
||||
} = await client.query({
|
||||
query: groupAndProjectQuery,
|
||||
fetchPolicy: 'no-cache',
|
||||
variables: {
|
||||
fullPath: `${targetNamespace}/${newName}`,
|
||||
},
|
||||
});
|
||||
|
||||
const variables = {
|
||||
field: NEW_NAME_FIELD,
|
||||
sourceGroupId,
|
||||
function makeProgress(data) {
|
||||
return {
|
||||
__typename: clientTypenames.BulkImportProgress,
|
||||
...data,
|
||||
};
|
||||
|
||||
if (!existingGroup && !existingProject) {
|
||||
client.mutate({
|
||||
mutation: removeValidationErrorMutation,
|
||||
variables,
|
||||
});
|
||||
} else {
|
||||
client.mutate({
|
||||
mutation: addValidationErrorMutation,
|
||||
variables: {
|
||||
...variables,
|
||||
message: i18n.NAME_ALREADY_EXISTS,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const localProgressId = (id) => `not-started-${id}`;
|
||||
const nextName = (name) => `${name}-1`;
|
||||
function makeGroup(data) {
|
||||
return {
|
||||
__typename: clientTypenames.BulkImportSourceGroup,
|
||||
...data,
|
||||
progress: data.progress
|
||||
? makeProgress({
|
||||
id: `LOCAL-PROGRESS-${data.id}`,
|
||||
...data.progress,
|
||||
})
|
||||
: null,
|
||||
lastImportTarget: data.lastImportTarget
|
||||
? makeLastImportTarget({
|
||||
id: data.id,
|
||||
...data.lastImportTarget,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) {
|
||||
const groupsManager = new GroupsManager({
|
||||
sourceUrl,
|
||||
function getGroupFromCache({ client, id, getCacheKey }) {
|
||||
return client.readFragment({
|
||||
fragment: bulkImportSourceGroupItemFragment,
|
||||
fragmentName: 'BulkImportSourceGroupItem',
|
||||
id: getCacheKey({
|
||||
__typename: clientTypenames.BulkImportSourceGroup,
|
||||
id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
let statusPoller;
|
||||
export function createResolvers({ endpoints }) {
|
||||
const localStorageCache = new LocalStorageCache();
|
||||
|
||||
return {
|
||||
Query: {
|
||||
async bulkImportSourceGroup(_, { id }, { client, getCacheKey }) {
|
||||
return client.readFragment({
|
||||
fragment: bulkImportSourceGroupItemFragment,
|
||||
fragmentName: 'BulkImportSourceGroupItem',
|
||||
id: getCacheKey({
|
||||
__typename: clientTypenames.BulkImportSourceGroup,
|
||||
id,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async bulkImportSourceGroups(_, vars, { client }) {
|
||||
if (!statusPoller) {
|
||||
statusPoller = new StatusPoller({
|
||||
updateImportStatus: ({ id, status_name: status }) =>
|
||||
client.mutate({
|
||||
mutation: updateImportStatusMutation,
|
||||
variables: { id, status },
|
||||
}),
|
||||
pollPath: endpoints.jobs,
|
||||
});
|
||||
statusPoller.startPolling();
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
axios.get(endpoints.status, {
|
||||
params: {
|
||||
page: vars.page,
|
||||
per_page: vars.perPage,
|
||||
filter: vars.filter,
|
||||
},
|
||||
}),
|
||||
client.query({ query: availableNamespacesQuery }),
|
||||
]).then(
|
||||
([
|
||||
{ headers, data },
|
||||
{
|
||||
data: { availableNamespaces },
|
||||
},
|
||||
]) => {
|
||||
const pagination = parseIntPagination(normalizeHeaders(headers));
|
||||
|
||||
const response = {
|
||||
__typename: clientTypenames.BulkImportSourceGroupConnection,
|
||||
nodes: data.importable_data.map((group) => {
|
||||
const { jobId, importState: cachedImportState } =
|
||||
groupsManager.getImportStateFromStorageByGroupId(group.id) ?? {};
|
||||
|
||||
const status = cachedImportState?.status ?? STATUSES.NONE;
|
||||
|
||||
const importTarget =
|
||||
status === STATUSES.FINISHED && cachedImportState.importTarget
|
||||
? {
|
||||
target_namespace: cachedImportState.importTarget.target_namespace,
|
||||
new_name: nextName(cachedImportState.importTarget.new_name),
|
||||
}
|
||||
: cachedImportState?.importTarget ?? {
|
||||
new_name: group.full_path,
|
||||
target_namespace: availableNamespaces[0]?.full_path ?? '',
|
||||
};
|
||||
|
||||
return makeGroup({
|
||||
...group,
|
||||
validation_errors: [],
|
||||
progress: {
|
||||
id: jobId ?? localProgressId(group.id),
|
||||
status,
|
||||
},
|
||||
import_target: importTarget,
|
||||
last_import_target: cachedImportState?.importTarget ?? null,
|
||||
});
|
||||
}),
|
||||
pageInfo: {
|
||||
__typename: clientTypenames.BulkImportPageInfo,
|
||||
...pagination,
|
||||
},
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
response.nodes.forEach((group) => {
|
||||
if (isAvailableForImport(group)) {
|
||||
checkImportTargetIsValid({
|
||||
client,
|
||||
newName: group.import_target.new_name,
|
||||
targetNamespace: group.import_target.target_namespace,
|
||||
sourceGroupId: group.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return response;
|
||||
async bulkImportSourceGroups(_, vars) {
|
||||
const { headers, data } = await axios.get(endpoints.status, {
|
||||
params: {
|
||||
page: vars.page,
|
||||
per_page: vars.perPage,
|
||||
filter: vars.filter,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const pagination = parseIntPagination(normalizeHeaders(headers));
|
||||
|
||||
const response = {
|
||||
__typename: clientTypenames.BulkImportSourceGroupConnection,
|
||||
nodes: data.importable_data.map((group) => {
|
||||
return makeGroup({
|
||||
id: group.id,
|
||||
webUrl: group.web_url,
|
||||
fullPath: group.full_path,
|
||||
fullName: group.full_name,
|
||||
...group,
|
||||
...localStorageCache.get(group.web_url),
|
||||
});
|
||||
}),
|
||||
pageInfo: {
|
||||
__typename: clientTypenames.BulkImportPageInfo,
|
||||
...pagination,
|
||||
},
|
||||
};
|
||||
return response;
|
||||
},
|
||||
|
||||
availableNamespaces: () =>
|
||||
axios.get(endpoints.availableNamespaces).then(({ data }) =>
|
||||
data.map((namespace) => ({
|
||||
__typename: clientTypenames.AvailableNamespace,
|
||||
...namespace,
|
||||
id: namespace.id,
|
||||
fullPath: namespace.full_path,
|
||||
})),
|
||||
),
|
||||
},
|
||||
Mutation: {
|
||||
setImportTarget(_, { targetNamespace, newName, sourceGroupId }, { client }) {
|
||||
checkImportTargetIsValid({
|
||||
client,
|
||||
sourceGroupId,
|
||||
targetNamespace,
|
||||
newName,
|
||||
});
|
||||
|
||||
return makeGroup({
|
||||
id: sourceGroupId,
|
||||
import_target: {
|
||||
target_namespace: targetNamespace,
|
||||
new_name: newName,
|
||||
id: sourceGroupId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async setImportProgress(_, { sourceGroupId, status, jobId, importTarget }) {
|
||||
if (jobId) {
|
||||
groupsManager.updateImportProgress(jobId, status);
|
||||
}
|
||||
|
||||
return makeGroup({
|
||||
id: sourceGroupId,
|
||||
progress: {
|
||||
id: jobId ?? localProgressId(sourceGroupId),
|
||||
status,
|
||||
},
|
||||
last_import_target: {
|
||||
__typename: clientTypenames.BulkImportTarget,
|
||||
...importTarget,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async updateImportStatus(_, { id, status: newStatus }, { client, getCacheKey }) {
|
||||
groupsManager.updateImportProgress(id, newStatus);
|
||||
|
||||
const progressItem = client.readFragment({
|
||||
fragment: bulkImportSourceGroupProgressFragment,
|
||||
fragmentName: 'BulkImportSourceGroupProgress',
|
||||
|
@ -251,125 +116,58 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
|
|||
}),
|
||||
});
|
||||
|
||||
const isInProgress = Boolean(progressItem);
|
||||
const { status: currentStatus } = progressItem ?? {};
|
||||
if (newStatus === STATUSES.FINISHED && isInProgress && currentStatus !== newStatus) {
|
||||
const groups = groupsManager.getImportedGroupsByJobId(id);
|
||||
if (!progressItem) return null;
|
||||
|
||||
groups.forEach(async ({ id: groupId, importTarget }) => {
|
||||
client.mutate({
|
||||
mutation: setImportTargetMutation,
|
||||
variables: {
|
||||
sourceGroupId: groupId,
|
||||
targetNamespace: importTarget.target_namespace,
|
||||
newName: nextName(importTarget.new_name),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
localStorageCache.updateStatusByJobId(id, newStatus);
|
||||
|
||||
return {
|
||||
__typename: clientTypenames.BulkImportProgress,
|
||||
...progressItem,
|
||||
id,
|
||||
status: newStatus,
|
||||
};
|
||||
},
|
||||
|
||||
async addValidationError(_, { sourceGroupId, field, message }, { client }) {
|
||||
const {
|
||||
data: {
|
||||
bulkImportSourceGroup: { validation_errors: validationErrors, ...group },
|
||||
},
|
||||
} = await client.query({
|
||||
query: bulkImportSourceGroupQuery,
|
||||
variables: { id: sourceGroupId },
|
||||
async importGroups(_, { importRequests }, { client, getCacheKey }) {
|
||||
const importOperations = importRequests.map((importRequest) => {
|
||||
const group = getGroupFromCache({
|
||||
client,
|
||||
getCacheKey,
|
||||
id: importRequest.sourceGroupId,
|
||||
});
|
||||
|
||||
return {
|
||||
group,
|
||||
...importRequest,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...group,
|
||||
validation_errors: [
|
||||
...validationErrors.filter(({ field: f }) => f !== field),
|
||||
{
|
||||
__typename: clientTypenames.BulkImportValidationError,
|
||||
field,
|
||||
message,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
async removeValidationError(_, { sourceGroupId, field }, { client }) {
|
||||
const {
|
||||
data: {
|
||||
bulkImportSourceGroup: { validation_errors: validationErrors, ...group },
|
||||
},
|
||||
} = await client.query({
|
||||
query: bulkImportSourceGroupQuery,
|
||||
variables: { id: sourceGroupId },
|
||||
data: { id: jobId },
|
||||
} = await axios.post(endpoints.createBulkImport, {
|
||||
bulk_import: importOperations.map((op) => ({
|
||||
source_type: 'group_entity',
|
||||
source_full_path: op.group.fullPath,
|
||||
destination_namespace: op.targetNamespace,
|
||||
destination_name: op.newName,
|
||||
})),
|
||||
});
|
||||
|
||||
return {
|
||||
...group,
|
||||
validation_errors: validationErrors.filter(({ field: f }) => f !== field),
|
||||
};
|
||||
},
|
||||
return importOperations.map((op) => {
|
||||
const lastImportTarget = {
|
||||
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 progress = {
|
||||
id: jobId,
|
||||
status: STATUSES.CREATED,
|
||||
};
|
||||
|
||||
const GROUPS_BEING_SCHEDULED = sourceGroupIds.map((sourceGroupId) =>
|
||||
makeGroup({
|
||||
id: sourceGroupId,
|
||||
progress: {
|
||||
id: localProgressId(sourceGroupId),
|
||||
status: STATUSES.SCHEDULING,
|
||||
},
|
||||
}),
|
||||
);
|
||||
localStorageCache.set(op.group.webUrl, { progress, lastImportTarget });
|
||||
|
||||
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 };
|
||||
})
|
||||
.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 @@
|
|||
|
||||
fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup {
|
||||
id
|
||||
web_url
|
||||
full_path
|
||||
full_name
|
||||
webUrl
|
||||
fullPath
|
||||
fullName
|
||||
lastImportTarget {
|
||||
id
|
||||
targetNamespace
|
||||
newName
|
||||
}
|
||||
progress {
|
||||
...BulkImportSourceGroupProgress
|
||||
}
|
||||
import_target {
|
||||
target_namespace
|
||||
new_name
|
||||
}
|
||||
last_import_target {
|
||||
target_namespace
|
||||
new_name
|
||||
}
|
||||
validation_errors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
mutation addValidationError($sourceGroupId: String!, $field: String!, $message: String!) {
|
||||
addValidationError(sourceGroupId: $sourceGroupId, field: $field, message: $message) @client {
|
||||
id
|
||||
validation_errors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,11 @@
|
|||
mutation importGroups($sourceGroupIds: [String!]!) {
|
||||
importGroups(sourceGroupIds: $sourceGroupIds) @client {
|
||||
mutation importGroups($importRequests: [ImportGroupInput!]!) {
|
||||
importGroups(importRequests: $importRequests) @client {
|
||||
id
|
||||
lastImportTarget {
|
||||
id
|
||||
targetNamespace
|
||||
newName
|
||||
}
|
||||
progress {
|
||||
id
|
||||
status
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
mutation removeValidationError($sourceGroupId: String!, $field: String!) {
|
||||
removeValidationError(sourceGroupId: $sourceGroupId, field: $field) @client {
|
||||
id
|
||||
validation_errors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
mutation setImportTarget($newName: String!, $targetNamespace: String!, $sourceGroupId: String!) {
|
||||
setImportTarget(
|
||||
newName: $newName
|
||||
targetNamespace: $targetNamespace
|
||||
sourceGroupId: $sourceGroupId
|
||||
) @client {
|
||||
id
|
||||
import_target {
|
||||
new_name
|
||||
target_namespace
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
query availableNamespaces {
|
||||
availableNamespaces @client {
|
||||
id
|
||||
full_path
|
||||
fullPath
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
#import "../fragments/bulk_import_source_group_item.fragment.graphql"
|
||||
|
||||
query bulkImportSourceGroup($id: ID!) {
|
||||
bulkImportSourceGroup(id: $id) @client {
|
||||
...BulkImportSourceGroupItem
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
query groupAndProject($fullPath: ID!) {
|
||||
existingGroup: group(fullPath: $fullPath) {
|
||||
id
|
||||
}
|
||||
|
||||
existingProject: project(fullPath: $fullPath) {
|
||||
id
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
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);
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
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);
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
type ClientBulkImportAvailableNamespace {
|
||||
id: ID!
|
||||
full_path: String!
|
||||
fullPath: String!
|
||||
}
|
||||
|
||||
type ClientBulkImportTarget {
|
||||
target_namespace: String!
|
||||
new_name: String!
|
||||
targetNamespace: String!
|
||||
newName: String!
|
||||
}
|
||||
|
||||
type ClientBulkImportSourceGroupConnection {
|
||||
|
@ -14,7 +14,7 @@ type ClientBulkImportSourceGroupConnection {
|
|||
}
|
||||
|
||||
type ClientBulkImportProgress {
|
||||
id: ID
|
||||
id: ID!
|
||||
status: String!
|
||||
}
|
||||
|
||||
|
@ -25,13 +25,11 @@ type ClientBulkImportValidationError {
|
|||
|
||||
type ClientBulkImportSourceGroup {
|
||||
id: ID!
|
||||
web_url: String!
|
||||
full_path: String!
|
||||
full_name: String!
|
||||
progress: ClientBulkImportProgress!
|
||||
import_target: ClientBulkImportTarget!
|
||||
last_import_target: ClientBulkImportTarget
|
||||
validation_errors: [ClientBulkImportValidationError!]!
|
||||
webUrl: String!
|
||||
fullPath: String!
|
||||
fullName: String!
|
||||
lastImportTarget: ClientBulkImportTarget
|
||||
progress: ClientBulkImportProgress
|
||||
}
|
||||
|
||||
type ClientBulkImportPageInfo {
|
||||
|
@ -41,8 +39,13 @@ type ClientBulkImportPageInfo {
|
|||
totalPages: Int!
|
||||
}
|
||||
|
||||
type ClientBulkImportNamespaceSuggestion {
|
||||
id: ID!
|
||||
exists: Boolean!
|
||||
suggestions: [String!]!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
bulkImportSourceGroup(id: ID!): ClientBulkImportSourceGroup
|
||||
bulkImportSourceGroups(
|
||||
page: Int!
|
||||
perPage: Int!
|
||||
|
@ -51,26 +54,13 @@ extend type Query {
|
|||
availableNamespaces: [ClientBulkImportAvailableNamespace!]!
|
||||
}
|
||||
|
||||
input InputTargetInput {
|
||||
target_namespace: String!
|
||||
new_name: String!
|
||||
input ImportRequestInput {
|
||||
sourceGroupId: ID!
|
||||
targetNamespace: String!
|
||||
newName: String!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
setNewName(newName: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
|
||||
setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
|
||||
importGroups(sourceGroupIds: [ID!]!): [ClientBulkImportSourceGroup!]!
|
||||
setImportProgress(
|
||||
id: ID
|
||||
status: String!
|
||||
jobId: String
|
||||
importTarget: ImportTargetInput!
|
||||
): ClientBulkImportSourceGroup!
|
||||
updateImportProgress(id: ID, status: String!): ClientBulkImportProgress
|
||||
addValidationError(
|
||||
sourceGroupId: ID!
|
||||
field: String!
|
||||
message: String!
|
||||
): ClientBulkImportSourceGroup!
|
||||
removeValidationError(sourceGroupId: ID!, field: String!): ClientBulkImportSourceGroup!
|
||||
importGroups(importRequests: [ImportRequestInput!]!): [ClientBulkImportSourceGroup!]!
|
||||
updateImportStatus(id: ID, status: String!): ClientBulkImportProgress
|
||||
}
|
||||
|
|
|
@ -17,7 +17,6 @@ export function mountImportGroupsApp(mountElement) {
|
|||
jobsPath,
|
||||
sourceUrl,
|
||||
groupPathRegex,
|
||||
groupUrlErrorMessage,
|
||||
} = mountElement.dataset;
|
||||
const apolloProvider = new VueApollo({
|
||||
defaultClient: createApolloClient({
|
||||
|
@ -26,7 +25,6 @@ export function mountImportGroupsApp(mountElement) {
|
|||
status: statusPath,
|
||||
availableNamespaces: availableNamespacesPath,
|
||||
createBulkImport: createBulkImportPath,
|
||||
jobs: jobsPath,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@ -38,8 +36,8 @@ export function mountImportGroupsApp(mountElement) {
|
|||
return createElement(ImportTable, {
|
||||
props: {
|
||||
sourceUrl,
|
||||
jobsPath,
|
||||
groupPathRegex: new RegExp(`^(${groupPathRegex})$`),
|
||||
groupUrlErrorMessage,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
@ -32,4 +32,8 @@ export class StatusPoller {
|
|||
startPolling() {
|
||||
this.eTagPoll.makeRequest();
|
||||
}
|
||||
|
||||
stopPolling() {
|
||||
this.eTagPoll.stop();
|
||||
}
|
||||
}
|
|
@ -1,22 +1,25 @@
|
|||
import { STATUSES } from '../constants';
|
||||
import { NEW_NAME_FIELD } from './constants';
|
||||
|
||||
export function isNameValid(group, validationRegex) {
|
||||
return validationRegex.test(group.import_target[NEW_NAME_FIELD]);
|
||||
export function isNameValid(importTarget, validationRegex) {
|
||||
return validationRegex.test(importTarget[NEW_NAME_FIELD]);
|
||||
}
|
||||
|
||||
export function getInvalidNameValidationMessage(group) {
|
||||
return group.validation_errors.find(({ field }) => field === NEW_NAME_FIELD)?.message;
|
||||
}
|
||||
|
||||
export function isInvalid(group, validationRegex) {
|
||||
return Boolean(!isNameValid(group, validationRegex) || getInvalidNameValidationMessage(group));
|
||||
export function getInvalidNameValidationMessage(importTarget) {
|
||||
return importTarget.validationErrors?.find(({ field }) => field === NEW_NAME_FIELD)?.message;
|
||||
}
|
||||
|
||||
export function isFinished(group) {
|
||||
return group.progress.status === STATUSES.FINISHED;
|
||||
return [STATUSES.FINISHED, STATUSES.FAILED].includes(group.progress?.status);
|
||||
}
|
||||
|
||||
export function isAvailableForImport(group) {
|
||||
return [STATUSES.NONE, STATUSES.FINISHED].some((status) => group.progress.status === status);
|
||||
return !group.progress || isFinished(group);
|
||||
}
|
||||
|
||||
export function isSameTarget(importTarget) {
|
||||
return (target) =>
|
||||
target !== importTarget &&
|
||||
target.newName.toLowerCase() === importTarget.newName.toLowerCase() &&
|
||||
target.targetNamespace.id === importTarget.targetNamespace.id;
|
||||
}
|
||||
|
|
|
@ -46,10 +46,6 @@ export default {
|
|||
return `${this.filter}-${this.repositories.length}-${this.pageInfo.page}`;
|
||||
},
|
||||
|
||||
availableNamespaces() {
|
||||
return this.namespaces.map(({ fullPath }) => fullPath);
|
||||
},
|
||||
|
||||
importAllButtonText() {
|
||||
if (this.isImportingAnyRepo) {
|
||||
return n__('Importing %d repository', 'Importing %d repositories', this.importingRepoCount);
|
||||
|
@ -167,7 +163,7 @@ export default {
|
|||
<provider-repo-table-row
|
||||
:key="repo.importSource.providerLink"
|
||||
:repo="repo"
|
||||
:available-namespaces="availableNamespaces"
|
||||
:available-namespaces="namespaces"
|
||||
:user-namespace="defaultTargetNamespace"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
@ -128,17 +128,17 @@ export default {
|
|||
<gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
|
||||
<gl-dropdown-item
|
||||
v-for="ns in namespaces"
|
||||
:key="ns"
|
||||
:key="ns.fullPath"
|
||||
data-qa-selector="target_group_dropdown_item"
|
||||
:data-qa-group-name="ns"
|
||||
@click="updateImportTarget({ targetNamespace: ns })"
|
||||
:data-qa-group-name="ns.fullPath"
|
||||
@click="updateImportTarget({ targetNamespace: ns.fullPath })"
|
||||
>
|
||||
{{ ns }}
|
||||
{{ ns.fullPath }}
|
||||
</gl-dropdown-item>
|
||||
<gl-dropdown-divider />
|
||||
</template>
|
||||
<gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
|
||||
<gl-dropdown-item @click="updateImportTarget({ targetNamespace: ns })">{{
|
||||
<gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{
|
||||
userNamespace
|
||||
}}</gl-dropdown-item>
|
||||
</import-group-dropdown>
|
||||
|
|
|
@ -3,7 +3,7 @@ import { debounce } from 'lodash';
|
|||
import createFlash from '~/flash';
|
||||
import { __ } from '~/locale';
|
||||
import InputValidator from '~/validators/input_validator';
|
||||
import fetchGroupPathAvailability from './fetch_group_path_availability';
|
||||
import { getGroupPathAvailability } from '~/rest_api';
|
||||
|
||||
const debounceTimeoutDuration = 1000;
|
||||
const invalidInputClass = 'gl-field-error-outline';
|
||||
|
@ -45,7 +45,7 @@ export default class GroupPathValidator extends InputValidator {
|
|||
if (inputDomElement.checkValidity() && groupPath.length > 1) {
|
||||
GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector);
|
||||
|
||||
fetchGroupPathAvailability(groupPath, parentId)
|
||||
getGroupPathAvailability(groupPath, parentId)
|
||||
.then(({ data }) => data)
|
||||
.then((data) => {
|
||||
GroupPathValidator.setInputState(inputDomElement, !data.exists);
|
||||
|
|
|
@ -3,6 +3,7 @@ export * from './api/projects_api';
|
|||
export * from './api/user_api';
|
||||
export * from './api/markdown_api';
|
||||
export * from './api/bulk_imports_api';
|
||||
export * from './api/namespaces_api';
|
||||
|
||||
// Note: It's not possible to spy on methods imported from this file in
|
||||
// Jest tests.
|
||||
|
|
|
@ -21,7 +21,7 @@ module Routing
|
|||
case key
|
||||
when :project_id
|
||||
[key, "project#{@project&.id}"]
|
||||
when :namespace_id
|
||||
when :namespace_id, :group_id
|
||||
namespace = @group || @project&.namespace
|
||||
[key, "namespace#{namespace&.id}"]
|
||||
when :id
|
||||
|
@ -31,11 +31,24 @@ module Routing
|
|||
end
|
||||
end
|
||||
|
||||
Gitlab::Routing.url_helpers.url_for(masked_params.merge(masked_query_params))
|
||||
generate_url(masked_params.merge(masked_query_params))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_url(masked_params)
|
||||
# The below check is added since `project/insights` route does not
|
||||
# work with Rails router `url_for` method.
|
||||
# See https://gitlab.com/gitlab-org/gitlab/-/issues/343551
|
||||
if @request.path_parameters[:controller] == 'projects/insights'
|
||||
default_root_url + "#{Gitlab::Routing.url_helpers.namespace_project_insights_path(masked_params)}"
|
||||
elsif @request.path_parameters[:controller] == 'groups/insights'
|
||||
default_root_url + "#{Gitlab::Routing.url_helpers.group_insights_path(masked_params)}"
|
||||
else
|
||||
Gitlab::Routing.url_helpers.url_for(masked_params.merge(masked_query_params))
|
||||
end
|
||||
end
|
||||
|
||||
def mask_id(value)
|
||||
if @request.path_parameters[:controller] == 'projects/blob'
|
||||
':repository_path'
|
||||
|
@ -50,7 +63,7 @@ module Routing
|
|||
|
||||
def has_maskable_params?
|
||||
request_params = @request.path_parameters.to_h
|
||||
request_params.has_key?(:namespace_id) || request_params.has_key?(:project_id) || request_params.has_key?(:id) || @request.query_string.present?
|
||||
request_params.key?(:namespace_id) || request_params.key?(:group_id) || request_params.key?(:project_id) || request_params.key?(:id) || @request.query_string.present?
|
||||
end
|
||||
|
||||
def masked_query_params
|
||||
|
@ -79,7 +92,10 @@ module Routing
|
|||
current_project = project if defined?(project)
|
||||
mask_helper = MaskHelper.new(request, current_group, current_project)
|
||||
mask_helper.mask_params
|
||||
rescue ActionController::RoutingError, URI::InvalidURIError => e
|
||||
|
||||
# We rescue all exception for time being till we test this helper extensively.
|
||||
# Check https://gitlab.com/gitlab-org/gitlab/-/merge_requests/72864#note_711515501
|
||||
rescue => e # rubocop:disable Style/RescueStandardError
|
||||
Gitlab::ErrorTracking.track_exception(e, url: request.original_fullpath)
|
||||
nil
|
||||
end
|
||||
|
|
|
@ -15,7 +15,7 @@ module Gitlab
|
|||
].freeze
|
||||
HTTP_ERRORS = HTTP_TIMEOUT_ERRORS + [
|
||||
EOFError, SocketError, OpenSSL::SSL::SSLError, OpenSSL::OpenSSLError,
|
||||
Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
|
||||
Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
|
||||
Gitlab::HTTP::BlockedUrlError, Gitlab::HTTP::RedirectionTooDeep
|
||||
].freeze
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ module Gitlab
|
|||
include Gitlab::Utils::StrongMemoize
|
||||
|
||||
DEFAULT_MAX_BYTES = 10.gigabytes.freeze
|
||||
TIMEOUT_LIMIT = 60.seconds
|
||||
TIMEOUT_LIMIT = 210.seconds
|
||||
|
||||
def initialize(archive_path:, max_bytes: self.class.max_bytes)
|
||||
@archive_path = archive_path
|
||||
|
|
|
@ -172,7 +172,7 @@ module Gitlab
|
|||
condition do
|
||||
quick_action_target.issue_type_supports?(:confidentiality) &&
|
||||
!quick_action_target.confidential? &&
|
||||
current_user.can?(:"admin_#{quick_action_target.to_ability_name}", quick_action_target)
|
||||
current_user.can?(:set_confidentiality, quick_action_target)
|
||||
end
|
||||
command :confidential do
|
||||
@updates[:confidential] = true
|
||||
|
|
|
@ -5930,10 +5930,13 @@ msgstr ""
|
|||
msgid "BulkImport|Import groups from GitLab"
|
||||
msgstr ""
|
||||
|
||||
msgid "BulkImport|Import is finished. Pick another name for re-import"
|
||||
msgstr ""
|
||||
|
||||
msgid "BulkImport|Import selected"
|
||||
msgstr ""
|
||||
|
||||
msgid "BulkImport|Importing the group failed"
|
||||
msgid "BulkImport|Importing the group failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "BulkImport|Last imported to %{link}"
|
||||
|
@ -5942,6 +5945,9 @@ msgstr ""
|
|||
msgid "BulkImport|Name already exists."
|
||||
msgstr ""
|
||||
|
||||
msgid "BulkImport|Name already used as a target for another group."
|
||||
msgstr ""
|
||||
|
||||
msgid "BulkImport|New group"
|
||||
msgstr ""
|
||||
|
||||
|
@ -19328,6 +19334,9 @@ msgstr ""
|
|||
msgid "Iterations|Automated scheduling"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Cadence configuration is invalid."
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Cadence name"
|
||||
msgstr ""
|
||||
|
||||
|
@ -19337,6 +19346,9 @@ msgstr ""
|
|||
msgid "Iterations|Create cadence"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Create cadence and start iteration"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Create iteration"
|
||||
msgstr ""
|
||||
|
||||
|
@ -19436,6 +19448,9 @@ msgstr ""
|
|||
msgid "Iterations|Unable to find iteration."
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Unable to save cadence. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "Iteration|Dates cannot overlap with other existing Iterations within this group"
|
||||
msgstr ""
|
||||
|
||||
|
@ -23018,6 +23033,9 @@ msgstr ""
|
|||
msgid "New milestone"
|
||||
msgstr ""
|
||||
|
||||
msgid "New name"
|
||||
msgstr ""
|
||||
|
||||
msgid "New password"
|
||||
msgstr ""
|
||||
|
||||
|
@ -24712,6 +24730,9 @@ msgstr ""
|
|||
msgid "Page settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Page size"
|
||||
msgstr ""
|
||||
|
||||
msgid "PagerDutySettings|Active"
|
||||
msgstr ""
|
||||
|
||||
|
@ -36556,9 +36577,6 @@ msgstr ""
|
|||
msgid "Unable to load the merge request widget. Try reloading the page."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unable to save cadence. Please try again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unable to save iteration. Please try again"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -255,10 +255,17 @@ module RuboCop
|
|||
]
|
||||
|
||||
# For EE additionally process `ee/` feature flags
|
||||
if File.exist?(File.expand_path('../../../ee/app/models/license.rb', __dir__)) && !%w[true 1].include?(ENV['FOSS_ONLY'].to_s)
|
||||
is_ee = File.exist?(File.expand_path('../../../ee/app/models/license.rb', __dir__)) && !%w[true 1].include?(ENV['FOSS_ONLY'].to_s)
|
||||
if is_ee
|
||||
flags_paths << 'ee/config/feature_flags/**/*.yml'
|
||||
end
|
||||
|
||||
# For JH additionally process `jh/` feature flags
|
||||
is_jh = is_ee && Dir.exist?(File.expand_path('../../../jh', __dir__)) && !%w[true 1].include?(ENV['EE_ONLY'].to_s)
|
||||
if is_jh
|
||||
flags_paths << 'jh/config/feature_flags/**/*.yml'
|
||||
end
|
||||
|
||||
flags_paths.each_with_object([]) do |flags_path, memo|
|
||||
flags_path = File.expand_path("../../../#{flags_path}", __dir__)
|
||||
Dir.glob(flags_path).each do |path|
|
||||
|
|
|
@ -27,7 +27,8 @@ flags_paths = [
|
|||
]
|
||||
|
||||
# For EE additionally process `ee/` feature flags
|
||||
if File.exist?('ee/app/models/license.rb') && !%w[true 1].include?(ENV['FOSS_ONLY'].to_s)
|
||||
is_ee = File.exist?('ee/app/models/license.rb') && !%w[true 1].include?(ENV['FOSS_ONLY'].to_s)
|
||||
if is_ee
|
||||
flags_paths << 'ee/config/feature_flags/**/*.yml'
|
||||
|
||||
# Geo feature flags are constructed dynamically and there's no explicit checks in the codebase so we mark all
|
||||
|
@ -41,6 +42,19 @@ if File.exist?('ee/app/models/license.rb') && !%w[true 1].include?(ENV['FOSS_ONL
|
|||
end
|
||||
end
|
||||
|
||||
# For JH additionally process `jh/` feature flags
|
||||
is_jh = is_ee && Dir.exist?('jh') && !%w[true 1].include?(ENV['EE_ONLY'].to_s)
|
||||
if is_jh
|
||||
flags_paths << 'jh/config/feature_flags/**/*.yml'
|
||||
|
||||
Dir.glob('jh/app/replicators/geo/*_replicator.rb').each_with_object(Set.new) do |path, memo|
|
||||
replicator_name = File.basename(path, '.rb')
|
||||
feature_flag_name = "geo_#{replicator_name.delete_suffix('_replicator')}_replication"
|
||||
|
||||
FileUtils.touch(File.join('tmp', 'feature_flags', "#{feature_flag_name}.used"))
|
||||
end
|
||||
end
|
||||
|
||||
all_flags = {}
|
||||
additional_flags = Set.new
|
||||
|
||||
|
|
|
@ -24,14 +24,21 @@ describe('Import entities group dropdown component', () => {
|
|||
});
|
||||
|
||||
it('passes namespaces from props to default slot', () => {
|
||||
const namespaces = ['ns1', 'ns2'];
|
||||
const namespaces = [
|
||||
{ id: 1, fullPath: 'ns1' },
|
||||
{ id: 2, fullPath: 'ns2' },
|
||||
];
|
||||
createComponent({ namespaces });
|
||||
|
||||
expect(namespacesTracker).toHaveBeenCalledWith({ namespaces });
|
||||
});
|
||||
|
||||
it('filters namespaces based on user input', async () => {
|
||||
const namespaces = ['match1', 'some unrelated', 'match2'];
|
||||
const namespaces = [
|
||||
{ id: 1, fullPath: 'match1' },
|
||||
{ id: 2, fullPath: 'some unrelated' },
|
||||
{ id: 3, fullPath: 'match2' },
|
||||
];
|
||||
createComponent({ namespaces });
|
||||
|
||||
namespacesTracker.mockReset();
|
||||
|
@ -39,6 +46,11 @@ describe('Import entities group dropdown component', () => {
|
|||
|
||||
await nextTick();
|
||||
|
||||
expect(namespacesTracker).toHaveBeenCalledWith({ namespaces: ['match1', 'match2'] });
|
||||
expect(namespacesTracker).toHaveBeenCalledWith({
|
||||
namespaces: [
|
||||
{ id: 1, fullPath: 'match1' },
|
||||
{ id: 3, fullPath: 'match2' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import { GlButton, GlIcon } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { STATUSES } from '~/import_entities/constants';
|
||||
import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue';
|
||||
import { generateFakeEntry } from '../graphql/fixtures';
|
||||
|
||||
describe('import actions cell', () => {
|
||||
let wrapper;
|
||||
|
@ -10,7 +8,9 @@ describe('import actions cell', () => {
|
|||
const createComponent = (props) => {
|
||||
wrapper = shallowMount(ImportActionsCell, {
|
||||
propsData: {
|
||||
groupPathRegex: /^[a-zA-Z]+$/,
|
||||
isFinished: false,
|
||||
isAvailableForImport: false,
|
||||
isInvalid: false,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
@ -20,10 +20,9 @@ describe('import actions cell', () => {
|
|||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('when import status is NONE', () => {
|
||||
describe('when group is available for import', () => {
|
||||
beforeEach(() => {
|
||||
const group = generateFakeEntry({ id: 1, status: STATUSES.NONE });
|
||||
createComponent({ group });
|
||||
createComponent({ isAvailableForImport: true });
|
||||
});
|
||||
|
||||
it('renders import button', () => {
|
||||
|
@ -37,10 +36,9 @@ describe('import actions cell', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('when import status is FINISHED', () => {
|
||||
describe('when group is finished', () => {
|
||||
beforeEach(() => {
|
||||
const group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED });
|
||||
createComponent({ group });
|
||||
createComponent({ isAvailableForImport: true, isFinished: true });
|
||||
});
|
||||
|
||||
it('renders re-import button', () => {
|
||||
|
@ -58,29 +56,22 @@ describe('import actions cell', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not render import button when group import is in progress', () => {
|
||||
const group = generateFakeEntry({ id: 1, status: STATUSES.STARTED });
|
||||
createComponent({ group });
|
||||
it('does not render import button when group is not available for import', () => {
|
||||
createComponent({ isAvailableForImport: false });
|
||||
|
||||
const button = wrapper.findComponent(GlButton);
|
||||
expect(button.exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders import button as disabled when there are validation errors', () => {
|
||||
const group = generateFakeEntry({
|
||||
id: 1,
|
||||
status: STATUSES.NONE,
|
||||
validation_errors: [{ field: 'new_name', message: 'something ' }],
|
||||
});
|
||||
createComponent({ group });
|
||||
it('renders import button as disabled when group is invalid', () => {
|
||||
createComponent({ isInvalid: true, isAvailableForImport: true });
|
||||
|
||||
const button = wrapper.findComponent(GlButton);
|
||||
expect(button.props().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('emits import-group event when import button is clicked', () => {
|
||||
const group = generateFakeEntry({ id: 1, status: STATUSES.NONE });
|
||||
createComponent({ group });
|
||||
createComponent({ isAvailableForImport: true });
|
||||
|
||||
const button = wrapper.findComponent(GlButton);
|
||||
button.vm.$emit('click');
|
||||
|
|
|
@ -4,6 +4,11 @@ import { STATUSES } from '~/import_entities/constants';
|
|||
import ImportSourceCell from '~/import_entities/import_groups/components/import_source_cell.vue';
|
||||
import { generateFakeEntry } from '../graphql/fixtures';
|
||||
|
||||
const generateFakeTableEntry = ({ flags = {}, ...entry }) => ({
|
||||
...generateFakeEntry(entry),
|
||||
flags,
|
||||
});
|
||||
|
||||
describe('import source cell', () => {
|
||||
let wrapper;
|
||||
let group;
|
||||
|
@ -23,14 +28,14 @@ describe('import source cell', () => {
|
|||
|
||||
describe('when group status is NONE', () => {
|
||||
beforeEach(() => {
|
||||
group = generateFakeEntry({ id: 1, status: STATUSES.NONE });
|
||||
group = generateFakeTableEntry({ id: 1, status: STATUSES.NONE });
|
||||
createComponent({ group });
|
||||
});
|
||||
|
||||
it('renders link to a group', () => {
|
||||
const link = wrapper.findComponent(GlLink);
|
||||
expect(link.attributes().href).toBe(group.web_url);
|
||||
expect(link.text()).toContain(group.full_path);
|
||||
expect(link.attributes().href).toBe(group.webUrl);
|
||||
expect(link.text()).toContain(group.fullPath);
|
||||
});
|
||||
|
||||
it('does not render last imported line', () => {
|
||||
|
@ -40,20 +45,24 @@ describe('import source cell', () => {
|
|||
|
||||
describe('when group status is FINISHED', () => {
|
||||
beforeEach(() => {
|
||||
group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED });
|
||||
group = generateFakeTableEntry({
|
||||
id: 1,
|
||||
status: STATUSES.FINISHED,
|
||||
flags: {
|
||||
isFinished: true,
|
||||
},
|
||||
});
|
||||
createComponent({ group });
|
||||
});
|
||||
|
||||
it('renders link to a group', () => {
|
||||
const link = wrapper.findComponent(GlLink);
|
||||
expect(link.attributes().href).toBe(group.web_url);
|
||||
expect(link.text()).toContain(group.full_path);
|
||||
expect(link.attributes().href).toBe(group.webUrl);
|
||||
expect(link.text()).toContain(group.fullPath);
|
||||
});
|
||||
|
||||
it('renders last imported line', () => {
|
||||
expect(wrapper.text()).toMatchInterpolatedText(
|
||||
'fake_group_1 Last imported to root/last-group1',
|
||||
);
|
||||
expect(wrapper.text()).toMatchInterpolatedText('fake_group_1 Last imported to root/group1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,39 +1,30 @@
|
|||
import {
|
||||
GlButton,
|
||||
GlEmptyState,
|
||||
GlLoadingIcon,
|
||||
GlSearchBoxByClick,
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
GlTable,
|
||||
} from '@gitlab/ui';
|
||||
import { mount, createLocalVue } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import stubChildren from 'helpers/stub_children';
|
||||
import { stubComponent } from 'helpers/stub_component';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import createFlash from '~/flash';
|
||||
import httpStatus from '~/lib/utils/http_status';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { STATUSES } from '~/import_entities/constants';
|
||||
import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue';
|
||||
import { i18n } from '~/import_entities/import_groups/constants';
|
||||
import ImportTable from '~/import_entities/import_groups/components/import_table.vue';
|
||||
import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue';
|
||||
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
|
||||
import setImportTargetMutation from '~/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql';
|
||||
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
|
||||
|
||||
import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtures';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueApollo);
|
||||
jest.mock('~/flash');
|
||||
jest.mock('~/import_entities/import_groups/services/status_poller');
|
||||
|
||||
const GlDropdownStub = stubComponent(GlDropdown, {
|
||||
template: '<div><h1 ref="text"><slot name="button-content"></slot></h1><slot></slot></div>',
|
||||
});
|
||||
Vue.use(VueApollo);
|
||||
|
||||
describe('import table', () => {
|
||||
let wrapper;
|
||||
let apolloProvider;
|
||||
let axiosMock;
|
||||
|
||||
const SOURCE_URL = 'https://demo.host';
|
||||
const FAKE_GROUP = generateFakeEntry({ id: 1, status: STATUSES.NONE });
|
||||
|
@ -44,76 +35,81 @@ describe('import table', () => {
|
|||
const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 };
|
||||
|
||||
const findImportSelectedButton = () =>
|
||||
wrapper.findAllComponents(GlButton).wrappers.find((w) => w.text() === 'Import selected');
|
||||
const findPaginationDropdown = () => wrapper.findComponent(GlDropdown);
|
||||
const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text();
|
||||
wrapper.findAll('button').wrappers.find((w) => w.text() === 'Import selected');
|
||||
const findImportButtons = () =>
|
||||
wrapper.findAll('button').wrappers.filter((w) => w.text() === 'Import');
|
||||
const findPaginationDropdown = () => wrapper.find('[aria-label="Page size"]');
|
||||
const findPaginationDropdownText = () => findPaginationDropdown().find('button').text();
|
||||
|
||||
// TODO: remove this ugly approach when
|
||||
// issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531
|
||||
const findTable = () => wrapper.vm.getTableRef();
|
||||
const selectRow = (idx) =>
|
||||
wrapper.findAll('tbody td input[type=checkbox]').at(idx).trigger('click');
|
||||
|
||||
const createComponent = ({ bulkImportSourceGroups }) => {
|
||||
const createComponent = ({ bulkImportSourceGroups, importGroups }) => {
|
||||
apolloProvider = createMockApollo([], {
|
||||
Query: {
|
||||
availableNamespaces: () => availableNamespacesFixture,
|
||||
bulkImportSourceGroups,
|
||||
},
|
||||
Mutation: {
|
||||
setTargetNamespace: jest.fn(),
|
||||
setNewName: jest.fn(),
|
||||
importGroup: jest.fn(),
|
||||
importGroups,
|
||||
},
|
||||
});
|
||||
|
||||
wrapper = mount(ImportTable, {
|
||||
propsData: {
|
||||
groupPathRegex: /.*/,
|
||||
jobsPath: '/fake_job_path',
|
||||
sourceUrl: SOURCE_URL,
|
||||
groupUrlErrorMessage: 'Please choose a group URL with no special characters or spaces.',
|
||||
},
|
||||
stubs: {
|
||||
...stubChildren(ImportTable),
|
||||
GlSprintf: false,
|
||||
GlDropdown: GlDropdownStub,
|
||||
GlTable: false,
|
||||
},
|
||||
localVue,
|
||||
apolloProvider,
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
gon.api_version = 'v4';
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(axios);
|
||||
axiosMock.onGet(/.*\/exists$/, () => []).reply(200);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders loading icon while performing request', async () => {
|
||||
createComponent({
|
||||
bulkImportSourceGroups: () => new Promise(() => {}),
|
||||
});
|
||||
await waitForPromises();
|
||||
describe('loading state', () => {
|
||||
it('renders loading icon while performing request', async () => {
|
||||
createComponent({
|
||||
bulkImportSourceGroups: () => new Promise(() => {}),
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
|
||||
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not renders loading icon when request is completed', async () => {
|
||||
createComponent({
|
||||
bulkImportSourceGroups: () => [],
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not renders loading icon when request is completed', async () => {
|
||||
createComponent({
|
||||
bulkImportSourceGroups: () => [],
|
||||
describe('empty state', () => {
|
||||
it('renders message about empty state when no groups are available for import', async () => {
|
||||
createComponent({
|
||||
bulkImportSourceGroups: () => ({
|
||||
nodes: [],
|
||||
pageInfo: FAKE_PAGE_INFO,
|
||||
}),
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.find(GlEmptyState).props().title).toBe('You have no groups to import');
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders message about empty state when no groups are available for import', async () => {
|
||||
createComponent({
|
||||
bulkImportSourceGroups: () => ({
|
||||
nodes: [],
|
||||
pageInfo: FAKE_PAGE_INFO,
|
||||
}),
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.find(GlEmptyState).props().title).toBe('You have no groups to import');
|
||||
});
|
||||
|
||||
it('renders import row for each group in response', async () => {
|
||||
|
@ -140,38 +136,49 @@ describe('import table', () => {
|
|||
expect(wrapper.text()).not.toContain('Showing 1-0');
|
||||
});
|
||||
|
||||
describe('converts row events to mutation invocations', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({
|
||||
bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }),
|
||||
});
|
||||
return waitForPromises();
|
||||
it('invokes importGroups mutation when row button is clicked', async () => {
|
||||
createComponent({
|
||||
bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }),
|
||||
});
|
||||
|
||||
it.each`
|
||||
event | payload | mutation | variables
|
||||
${'update-target-namespace'} | ${'new-namespace'} | ${setImportTargetMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace', newName: 'group1' }}
|
||||
${'update-new-name'} | ${'new-name'} | ${setImportTargetMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'root', newName: 'new-name' }}
|
||||
`('correctly maps $event to mutation', async ({ event, payload, mutation, variables }) => {
|
||||
jest.spyOn(apolloProvider.defaultClient, 'mutate');
|
||||
wrapper.find(ImportTargetCell).vm.$emit(event, payload);
|
||||
await waitForPromises();
|
||||
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
|
||||
mutation,
|
||||
variables,
|
||||
});
|
||||
jest.spyOn(apolloProvider.defaultClient, 'mutate');
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
await findImportButtons()[0].trigger('click');
|
||||
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: importGroupsMutation,
|
||||
variables: {
|
||||
importRequests: [
|
||||
{
|
||||
newName: FAKE_GROUP.lastImportTarget.newName,
|
||||
sourceGroupId: FAKE_GROUP.id,
|
||||
targetNamespace: availableNamespacesFixture[0].fullPath,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error if importing group fails', async () => {
|
||||
createComponent({
|
||||
bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }),
|
||||
importGroups: () => {
|
||||
throw new Error();
|
||||
},
|
||||
});
|
||||
|
||||
it('invokes importGroups mutation when row button is clicked', async () => {
|
||||
jest.spyOn(apolloProvider.defaultClient, 'mutate');
|
||||
axiosMock.onPost('/import/bulk_imports.json').reply(httpStatus.BAD_REQUEST);
|
||||
|
||||
wrapper.findComponent(ImportActionsCell).vm.$emit('import-group');
|
||||
await waitForPromises();
|
||||
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: importGroupsMutation,
|
||||
variables: { sourceGroupIds: [FAKE_GROUP.id] },
|
||||
});
|
||||
});
|
||||
await waitForPromises();
|
||||
await findImportButtons()[0].trigger('click');
|
||||
await waitForPromises();
|
||||
|
||||
expect(createFlash).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: i18n.ERROR_IMPORT,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
|
@ -195,10 +202,10 @@ describe('import table', () => {
|
|||
});
|
||||
|
||||
it('updates page size when selected in Dropdown', async () => {
|
||||
const otherOption = wrapper.findAllComponents(GlDropdownItem).at(1);
|
||||
const otherOption = findPaginationDropdown().findAll('li p').at(1);
|
||||
expect(otherOption.text()).toMatchInterpolatedText('50 items per page');
|
||||
|
||||
otherOption.vm.$emit('click');
|
||||
await otherOption.trigger('click');
|
||||
await waitForPromises();
|
||||
|
||||
expect(findPaginationDropdownText()).toMatchInterpolatedText('50 items per page');
|
||||
|
@ -247,7 +254,11 @@ describe('import table', () => {
|
|||
return waitForPromises();
|
||||
});
|
||||
|
||||
const findFilterInput = () => wrapper.find(GlSearchBoxByClick);
|
||||
const setFilter = (value) => {
|
||||
const input = wrapper.find('input[placeholder="Filter by source group"]');
|
||||
input.setValue(value);
|
||||
return input.trigger('keydown.enter');
|
||||
};
|
||||
|
||||
it('properly passes filter to graphql query when search box is submitted', async () => {
|
||||
createComponent({
|
||||
|
@ -256,7 +267,7 @@ describe('import table', () => {
|
|||
await waitForPromises();
|
||||
|
||||
const FILTER_VALUE = 'foo';
|
||||
findFilterInput().vm.$emit('submit', FILTER_VALUE);
|
||||
await setFilter(FILTER_VALUE);
|
||||
await waitForPromises();
|
||||
|
||||
expect(bulkImportSourceGroupsQueryMock).toHaveBeenCalledWith(
|
||||
|
@ -274,7 +285,7 @@ describe('import table', () => {
|
|||
await waitForPromises();
|
||||
|
||||
const FILTER_VALUE = 'foo';
|
||||
findFilterInput().vm.$emit('submit', FILTER_VALUE);
|
||||
await setFilter(FILTER_VALUE);
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('Showing 1-1 of 40 groups matching filter "foo" from');
|
||||
|
@ -282,12 +293,14 @@ describe('import table', () => {
|
|||
|
||||
it('properly resets filter in graphql query when search box is cleared', async () => {
|
||||
const FILTER_VALUE = 'foo';
|
||||
findFilterInput().vm.$emit('submit', FILTER_VALUE);
|
||||
await setFilter(FILTER_VALUE);
|
||||
await waitForPromises();
|
||||
|
||||
bulkImportSourceGroupsQueryMock.mockClear();
|
||||
await apolloProvider.defaultClient.resetStore();
|
||||
findFilterInput().vm.$emit('clear');
|
||||
|
||||
await setFilter('');
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(bulkImportSourceGroupsQueryMock).toHaveBeenCalledWith(
|
||||
|
@ -320,8 +333,8 @@ describe('import table', () => {
|
|||
}),
|
||||
});
|
||||
await waitForPromises();
|
||||
wrapper.find(GlTable).vm.$emit('row-selected', [FAKE_GROUPS[0]]);
|
||||
await nextTick();
|
||||
|
||||
await selectRow(0);
|
||||
|
||||
expect(findImportSelectedButton().props().disabled).toBe(false);
|
||||
});
|
||||
|
@ -337,7 +350,7 @@ describe('import table', () => {
|
|||
});
|
||||
await waitForPromises();
|
||||
|
||||
findTable().selectRow(0);
|
||||
await selectRow(0);
|
||||
await nextTick();
|
||||
|
||||
expect(findImportSelectedButton().props().disabled).toBe(true);
|
||||
|
@ -348,7 +361,6 @@ describe('import table', () => {
|
|||
generateFakeEntry({
|
||||
id: 2,
|
||||
status: STATUSES.NONE,
|
||||
validation_errors: [{ field: 'new_name', message: 'FAKE_VALIDATION_ERROR' }],
|
||||
}),
|
||||
];
|
||||
|
||||
|
@ -360,9 +372,9 @@ describe('import table', () => {
|
|||
});
|
||||
await waitForPromises();
|
||||
|
||||
// TODO: remove this ugly approach when
|
||||
// issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531
|
||||
findTable().selectRow(0);
|
||||
await wrapper.find('tbody input[aria-label="New name"]').setValue('');
|
||||
jest.runOnlyPendingTimers();
|
||||
await selectRow(0);
|
||||
await nextTick();
|
||||
|
||||
expect(findImportSelectedButton().props().disabled).toBe(true);
|
||||
|
@ -384,15 +396,28 @@ describe('import table', () => {
|
|||
jest.spyOn(apolloProvider.defaultClient, 'mutate');
|
||||
await waitForPromises();
|
||||
|
||||
findTable().selectRow(0);
|
||||
findTable().selectRow(1);
|
||||
await selectRow(0);
|
||||
await selectRow(1);
|
||||
await nextTick();
|
||||
|
||||
findImportSelectedButton().vm.$emit('click');
|
||||
await findImportSelectedButton().trigger('click');
|
||||
|
||||
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: importGroupsMutation,
|
||||
variables: { sourceGroupIds: [NEW_GROUPS[0].id, NEW_GROUPS[1].id] },
|
||||
variables: {
|
||||
importRequests: [
|
||||
{
|
||||
targetNamespace: availableNamespacesFixture[0].fullPath,
|
||||
newName: NEW_GROUPS[0].lastImportTarget.newName,
|
||||
sourceGroupId: NEW_GROUPS[0].id,
|
||||
},
|
||||
{
|
||||
targetNamespace: availableNamespacesFixture[0].fullPath,
|
||||
newName: NEW_GROUPS[1].lastImportTarget.newName,
|
||||
sourceGroupId: NEW_GROUPS[1].id,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,20 +3,20 @@ import { shallowMount } from '@vue/test-utils';
|
|||
import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue';
|
||||
import { STATUSES } from '~/import_entities/constants';
|
||||
import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue';
|
||||
import { availableNamespacesFixture } from '../graphql/fixtures';
|
||||
import { generateFakeEntry, availableNamespacesFixture } from '../graphql/fixtures';
|
||||
|
||||
const getFakeGroup = (status) => ({
|
||||
web_url: 'https://fake.host/',
|
||||
full_path: 'fake_group_1',
|
||||
full_name: 'fake_name_1',
|
||||
import_target: {
|
||||
target_namespace: 'root',
|
||||
new_name: 'group1',
|
||||
},
|
||||
id: 1,
|
||||
validation_errors: [],
|
||||
progress: { status },
|
||||
});
|
||||
const generateFakeTableEntry = ({ flags = {}, ...config }) => {
|
||||
const entry = generateFakeEntry(config);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
importTarget: {
|
||||
targetNamespace: availableNamespacesFixture[0],
|
||||
newName: entry.lastImportTarget.newName,
|
||||
},
|
||||
flags,
|
||||
};
|
||||
};
|
||||
|
||||
describe('import target cell', () => {
|
||||
let wrapper;
|
||||
|
@ -31,7 +31,6 @@ describe('import target cell', () => {
|
|||
propsData: {
|
||||
availableNamespaces: availableNamespacesFixture,
|
||||
groupPathRegex: /.*/,
|
||||
groupUrlErrorMessage: 'Please choose a group URL with no special characters or spaces.',
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
@ -44,11 +43,11 @@ describe('import target cell', () => {
|
|||
|
||||
describe('events', () => {
|
||||
beforeEach(() => {
|
||||
group = getFakeGroup(STATUSES.NONE);
|
||||
group = generateFakeTableEntry({ id: 1, status: STATUSES.NONE });
|
||||
createComponent({ group });
|
||||
});
|
||||
|
||||
it('invokes $event', () => {
|
||||
it('emits update-new-name when input value is changed', () => {
|
||||
findNameInput().vm.$emit('input', 'demo');
|
||||
expect(wrapper.emitted('update-new-name')).toBeDefined();
|
||||
expect(wrapper.emitted('update-new-name')[0][0]).toBe('demo');
|
||||
|
@ -56,18 +55,23 @@ describe('import target cell', () => {
|
|||
|
||||
it('emits update-target-namespace when dropdown option is clicked', () => {
|
||||
const dropdownItem = findNamespaceDropdown().findAllComponents(GlDropdownItem).at(2);
|
||||
const dropdownItemText = dropdownItem.text();
|
||||
|
||||
dropdownItem.vm.$emit('click');
|
||||
|
||||
expect(wrapper.emitted('update-target-namespace')).toBeDefined();
|
||||
expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(dropdownItemText);
|
||||
expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(availableNamespacesFixture[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when entity status is NONE', () => {
|
||||
beforeEach(() => {
|
||||
group = getFakeGroup(STATUSES.NONE);
|
||||
group = generateFakeTableEntry({
|
||||
id: 1,
|
||||
status: STATUSES.NONE,
|
||||
flags: {
|
||||
isAvailableForImport: true,
|
||||
},
|
||||
});
|
||||
createComponent({ group });
|
||||
});
|
||||
|
||||
|
@ -78,7 +82,7 @@ describe('import target cell', () => {
|
|||
|
||||
it('renders only no parent option if available namespaces list is empty', () => {
|
||||
createComponent({
|
||||
group: getFakeGroup(STATUSES.NONE),
|
||||
group: generateFakeTableEntry({ id: 1, status: STATUSES.NONE }),
|
||||
availableNamespaces: [],
|
||||
});
|
||||
|
||||
|
@ -92,7 +96,7 @@ describe('import target cell', () => {
|
|||
|
||||
it('renders both no parent option and available namespaces list when available namespaces list is not empty', () => {
|
||||
createComponent({
|
||||
group: getFakeGroup(STATUSES.NONE),
|
||||
group: generateFakeTableEntry({ id: 1, status: STATUSES.NONE }),
|
||||
availableNamespaces: availableNamespacesFixture,
|
||||
});
|
||||
|
||||
|
@ -104,9 +108,12 @@ describe('import target cell', () => {
|
|||
expect(rest).toHaveLength(availableNamespacesFixture.length);
|
||||
});
|
||||
|
||||
describe('when entity status is SCHEDULING', () => {
|
||||
describe('when entity is not available for import', () => {
|
||||
beforeEach(() => {
|
||||
group = getFakeGroup(STATUSES.SCHEDULING);
|
||||
group = generateFakeTableEntry({
|
||||
id: 1,
|
||||
flags: { isAvailableForImport: false },
|
||||
});
|
||||
createComponent({ group });
|
||||
});
|
||||
|
||||
|
@ -115,9 +122,9 @@ describe('import target cell', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('when entity status is FINISHED', () => {
|
||||
describe('when entity is available for import', () => {
|
||||
beforeEach(() => {
|
||||
group = getFakeGroup(STATUSES.FINISHED);
|
||||
group = generateFakeTableEntry({ id: 1, flags: { isAvailableForImport: true } });
|
||||
createComponent({ group });
|
||||
});
|
||||
|
||||
|
@ -125,41 +132,4 @@ describe('import target cell', () => {
|
|||
expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validations', () => {
|
||||
it('reports invalid group name when name is not matching regex', () => {
|
||||
createComponent({
|
||||
group: {
|
||||
...getFakeGroup(STATUSES.NONE),
|
||||
import_target: {
|
||||
target_namespace: 'root',
|
||||
new_name: 'very`bad`name',
|
||||
},
|
||||
},
|
||||
groupPathRegex: /^[a-zA-Z]+$/,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Please choose a group URL with no special characters or spaces.',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports invalid group name if relevant validation error exists', async () => {
|
||||
const FAKE_ERROR_MESSAGE = 'fake error';
|
||||
|
||||
createComponent({
|
||||
group: {
|
||||
...getFakeGroup(STATUSES.NONE),
|
||||
validation_errors: [
|
||||
{
|
||||
field: 'new_name',
|
||||
message: FAKE_ERROR_MESSAGE,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,32 +2,27 @@ import { InMemoryCache } from 'apollo-cache-inmemory';
|
|||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { createMockClient } from 'mock-apollo-client';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import createFlash from '~/flash';
|
||||
import { STATUSES } from '~/import_entities/constants';
|
||||
import {
|
||||
clientTypenames,
|
||||
createResolvers,
|
||||
} from '~/import_entities/import_groups/graphql/client_factory';
|
||||
import addValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql';
|
||||
import { LocalStorageCache } from '~/import_entities/import_groups/graphql/services/local_storage_cache';
|
||||
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
|
||||
import removeValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql';
|
||||
import setImportProgressMutation from '~/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql';
|
||||
import setImportTargetMutation from '~/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql';
|
||||
import updateImportStatusMutation from '~/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql';
|
||||
import availableNamespacesQuery from '~/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql';
|
||||
import bulkImportSourceGroupQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql';
|
||||
import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql';
|
||||
import groupAndProjectQuery from '~/import_entities/import_groups/graphql/queries/group_and_project.query.graphql';
|
||||
import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller';
|
||||
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import httpStatus from '~/lib/utils/http_status';
|
||||
import { statusEndpointFixture, availableNamespacesFixture } from './fixtures';
|
||||
|
||||
jest.mock('~/flash');
|
||||
jest.mock('~/import_entities/import_groups/graphql/services/status_poller', () => ({
|
||||
StatusPoller: jest.fn().mockImplementation(function mock() {
|
||||
this.startPolling = jest.fn();
|
||||
jest.mock('~/import_entities/import_groups/graphql/services/local_storage_cache', () => ({
|
||||
LocalStorageCache: jest.fn().mockImplementation(function mock() {
|
||||
this.get = jest.fn();
|
||||
this.set = jest.fn();
|
||||
this.updateStatusByJobId = jest.fn();
|
||||
}),
|
||||
}));
|
||||
|
||||
|
@ -38,13 +33,6 @@ const FAKE_ENDPOINTS = {
|
|||
jobs: '/fake_jobs',
|
||||
};
|
||||
|
||||
const FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
existingGroup: null,
|
||||
existingProject: null,
|
||||
},
|
||||
});
|
||||
|
||||
describe('Bulk import resolvers', () => {
|
||||
let axiosMockAdapter;
|
||||
let client;
|
||||
|
@ -58,14 +46,28 @@ describe('Bulk import resolvers', () => {
|
|||
resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS, ...extraResolverArgs }),
|
||||
});
|
||||
|
||||
mockedClient.setRequestHandler(groupAndProjectQuery, FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER);
|
||||
|
||||
return mockedClient;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
let results;
|
||||
beforeEach(async () => {
|
||||
axiosMockAdapter = new MockAdapter(axios);
|
||||
client = createClient();
|
||||
|
||||
axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture);
|
||||
axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply(
|
||||
httpStatus.OK,
|
||||
availableNamespacesFixture.map((ns) => ({
|
||||
id: ns.id,
|
||||
full_path: ns.fullPath,
|
||||
})),
|
||||
);
|
||||
|
||||
client.watchQuery({ query: bulkImportSourceGroupsQuery }).subscribe(({ data }) => {
|
||||
results = data.bulkImportSourceGroups.nodes;
|
||||
});
|
||||
|
||||
return waitForPromises();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -74,104 +76,41 @@ describe('Bulk import resolvers', () => {
|
|||
|
||||
describe('queries', () => {
|
||||
describe('availableNamespaces', () => {
|
||||
let results;
|
||||
|
||||
let namespacesResults;
|
||||
beforeEach(async () => {
|
||||
axiosMockAdapter
|
||||
.onGet(FAKE_ENDPOINTS.availableNamespaces)
|
||||
.reply(httpStatus.OK, availableNamespacesFixture);
|
||||
|
||||
const response = await client.query({ query: availableNamespacesQuery });
|
||||
results = response.data.availableNamespaces;
|
||||
namespacesResults = response.data.availableNamespaces;
|
||||
});
|
||||
|
||||
it('mirrors REST endpoint response fields', () => {
|
||||
const extractRelevantFields = (obj) => ({ id: obj.id, full_path: obj.full_path });
|
||||
|
||||
expect(results.map(extractRelevantFields)).toStrictEqual(
|
||||
expect(namespacesResults.map(extractRelevantFields)).toStrictEqual(
|
||||
availableNamespacesFixture.map(extractRelevantFields),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkImportSourceGroup', () => {
|
||||
beforeEach(async () => {
|
||||
axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture);
|
||||
axiosMockAdapter
|
||||
.onGet(FAKE_ENDPOINTS.availableNamespaces)
|
||||
.reply(httpStatus.OK, availableNamespacesFixture);
|
||||
|
||||
return client.query({
|
||||
query: bulkImportSourceGroupsQuery,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns group', async () => {
|
||||
const { id } = statusEndpointFixture.importable_data[0];
|
||||
const {
|
||||
data: { bulkImportSourceGroup: group },
|
||||
} = await client.query({
|
||||
query: bulkImportSourceGroupQuery,
|
||||
variables: { id: id.toString() },
|
||||
});
|
||||
|
||||
expect(group).toMatchObject(statusEndpointFixture.importable_data[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkImportSourceGroups', () => {
|
||||
let results;
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture);
|
||||
axiosMockAdapter
|
||||
.onGet(FAKE_ENDPOINTS.availableNamespaces)
|
||||
.reply(httpStatus.OK, availableNamespacesFixture);
|
||||
});
|
||||
|
||||
it('respects cached import state when provided by group manager', async () => {
|
||||
const FAKE_JOB_ID = '1';
|
||||
const FAKE_STATUS = 'DEMO_STATUS';
|
||||
const FAKE_IMPORT_TARGET = {
|
||||
new_name: 'test-name',
|
||||
target_namespace: 'test-namespace',
|
||||
const [localStorageCache] = LocalStorageCache.mock.instances;
|
||||
const CACHED_DATA = {
|
||||
progress: {
|
||||
id: 'DEMO',
|
||||
status: 'cached',
|
||||
},
|
||||
};
|
||||
const TARGET_INDEX = 0;
|
||||
localStorageCache.get.mockReturnValueOnce(CACHED_DATA);
|
||||
|
||||
const clientWithMockedManager = createClient({
|
||||
GroupsManager: jest.fn().mockImplementation(() => ({
|
||||
getImportStateFromStorageByGroupId(groupId) {
|
||||
if (groupId === statusEndpointFixture.importable_data[TARGET_INDEX].id) {
|
||||
return {
|
||||
jobId: FAKE_JOB_ID,
|
||||
importState: {
|
||||
status: FAKE_STATUS,
|
||||
importTarget: FAKE_IMPORT_TARGET,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const clientResponse = await clientWithMockedManager.query({
|
||||
const updatedResults = await client.query({
|
||||
query: bulkImportSourceGroupsQuery,
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
const clientResults = clientResponse.data.bulkImportSourceGroups.nodes;
|
||||
|
||||
expect(clientResults[TARGET_INDEX].import_target).toStrictEqual(FAKE_IMPORT_TARGET);
|
||||
expect(clientResults[TARGET_INDEX].progress.status).toBe(FAKE_STATUS);
|
||||
});
|
||||
|
||||
it('populates each result instance with empty import_target when there are no available namespaces', async () => {
|
||||
axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply(httpStatus.OK, []);
|
||||
|
||||
const response = await client.query({ query: bulkImportSourceGroupsQuery });
|
||||
results = response.data.bulkImportSourceGroups.nodes;
|
||||
|
||||
expect(results.every((r) => r.import_target.target_namespace === '')).toBe(true);
|
||||
expect(updatedResults.data.bulkImportSourceGroups.nodes[0].progress).toStrictEqual({
|
||||
__typename: clientTypenames.BulkImportProgress,
|
||||
...CACHED_DATA.progress,
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called', () => {
|
||||
|
@ -181,37 +120,23 @@ describe('Bulk import resolvers', () => {
|
|||
});
|
||||
|
||||
it('mirrors REST endpoint response fields', () => {
|
||||
const MIRRORED_FIELDS = ['id', 'full_name', 'full_path', 'web_url'];
|
||||
const MIRRORED_FIELDS = [
|
||||
{ from: 'id', to: 'id' },
|
||||
{ from: 'full_name', to: 'fullName' },
|
||||
{ from: 'full_path', to: 'fullPath' },
|
||||
{ from: 'web_url', to: 'webUrl' },
|
||||
];
|
||||
expect(
|
||||
results.every((r, idx) =>
|
||||
MIRRORED_FIELDS.every(
|
||||
(field) => r[field] === statusEndpointFixture.importable_data[idx][field],
|
||||
(field) => r[field.to] === statusEndpointFixture.importable_data[idx][field.from],
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('populates each result instance with status default to none', () => {
|
||||
expect(results.every((r) => r.progress.status === STATUSES.NONE)).toBe(true);
|
||||
});
|
||||
|
||||
it('populates each result instance with import_target defaulted to first available namespace', () => {
|
||||
expect(
|
||||
results.every(
|
||||
(r) => r.import_target.target_namespace === availableNamespacesFixture[0].full_path,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('starts polling when request completes', async () => {
|
||||
const [statusPoller] = StatusPoller.mock.instances;
|
||||
expect(statusPoller.startPolling).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests validation status when request completes', async () => {
|
||||
expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).not.toHaveBeenCalled();
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).toHaveBeenCalled();
|
||||
it('populates each result instance with empty status', () => {
|
||||
expect(results.every((r) => r.progress === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -223,6 +148,7 @@ describe('Bulk import resolvers', () => {
|
|||
`(
|
||||
'properly passes GraphQL variable $variable as REST $queryParam query parameter',
|
||||
async ({ variable, queryParam, value }) => {
|
||||
axiosMockAdapter.resetHistory();
|
||||
await client.query({
|
||||
query: bulkImportSourceGroupsQuery,
|
||||
variables: { [variable]: value },
|
||||
|
@ -237,275 +163,61 @@ describe('Bulk import resolvers', () => {
|
|||
});
|
||||
|
||||
describe('mutations', () => {
|
||||
const GROUP_ID = 1;
|
||||
|
||||
beforeEach(() => {
|
||||
client.writeQuery({
|
||||
query: bulkImportSourceGroupsQuery,
|
||||
data: {
|
||||
bulkImportSourceGroups: {
|
||||
nodes: [
|
||||
{
|
||||
__typename: clientTypenames.BulkImportSourceGroup,
|
||||
id: GROUP_ID,
|
||||
progress: {
|
||||
id: `test-${GROUP_ID}`,
|
||||
status: STATUSES.NONE,
|
||||
},
|
||||
web_url: 'https://fake.host/1',
|
||||
full_path: 'fake_group_1',
|
||||
full_name: 'fake_name_1',
|
||||
import_target: {
|
||||
target_namespace: 'root',
|
||||
new_name: 'group1',
|
||||
},
|
||||
last_import_target: {
|
||||
target_namespace: 'root',
|
||||
new_name: 'group1',
|
||||
},
|
||||
validation_errors: [],
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
total: 37,
|
||||
totalPages: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('setImportTarget', () => {
|
||||
it('updates group target namespace and name', async () => {
|
||||
const NEW_TARGET_NAMESPACE = 'target';
|
||||
const NEW_NAME = 'new';
|
||||
|
||||
const {
|
||||
data: {
|
||||
setImportTarget: {
|
||||
id: idInResponse,
|
||||
import_target: { target_namespace: namespaceInResponse, new_name: newNameInResponse },
|
||||
},
|
||||
},
|
||||
} = await client.mutate({
|
||||
mutation: setImportTargetMutation,
|
||||
variables: {
|
||||
sourceGroupId: GROUP_ID,
|
||||
targetNamespace: NEW_TARGET_NAMESPACE,
|
||||
newName: NEW_NAME,
|
||||
},
|
||||
});
|
||||
|
||||
expect(idInResponse).toBe(GROUP_ID);
|
||||
expect(namespaceInResponse).toBe(NEW_TARGET_NAMESPACE);
|
||||
expect(newNameInResponse).toBe(NEW_NAME);
|
||||
});
|
||||
|
||||
it('invokes validation', async () => {
|
||||
const NEW_TARGET_NAMESPACE = 'target';
|
||||
const NEW_NAME = 'new';
|
||||
|
||||
await client.mutate({
|
||||
mutation: setImportTargetMutation,
|
||||
variables: {
|
||||
sourceGroupId: GROUP_ID,
|
||||
targetNamespace: NEW_TARGET_NAMESPACE,
|
||||
newName: NEW_NAME,
|
||||
},
|
||||
});
|
||||
|
||||
expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).toHaveBeenCalledWith({
|
||||
fullPath: `${NEW_TARGET_NAMESPACE}/${NEW_NAME}`,
|
||||
});
|
||||
});
|
||||
axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 });
|
||||
});
|
||||
|
||||
describe('importGroup', () => {
|
||||
it('sets status to SCHEDULING when request initiates', async () => {
|
||||
axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(() => new Promise(() => {}));
|
||||
|
||||
client.mutate({
|
||||
it('sets import status to CREATED when request completes', async () => {
|
||||
await client.mutate({
|
||||
mutation: importGroupsMutation,
|
||||
variables: { sourceGroupIds: [GROUP_ID] },
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
const {
|
||||
bulkImportSourceGroups: { nodes: intermediateResults },
|
||||
} = client.readQuery({
|
||||
query: bulkImportSourceGroupsQuery,
|
||||
variables: {
|
||||
importRequests: [
|
||||
{
|
||||
sourceGroupId: statusEndpointFixture.importable_data[0].id,
|
||||
newName: 'test',
|
||||
targetNamespace: 'root',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(intermediateResults[0].progress.status).toBe(STATUSES.SCHEDULING);
|
||||
});
|
||||
|
||||
describe('when request completes', () => {
|
||||
let results;
|
||||
|
||||
beforeEach(() => {
|
||||
client
|
||||
.watchQuery({
|
||||
query: bulkImportSourceGroupsQuery,
|
||||
fetchPolicy: 'cache-only',
|
||||
})
|
||||
.subscribe(({ data }) => {
|
||||
results = data.bulkImportSourceGroups.nodes;
|
||||
});
|
||||
});
|
||||
|
||||
it('sets import status to CREATED when request completes', async () => {
|
||||
axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 });
|
||||
await client.mutate({
|
||||
mutation: importGroupsMutation,
|
||||
variables: { sourceGroupIds: [GROUP_ID] },
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
expect(results[0].progress.status).toBe(STATUSES.CREATED);
|
||||
});
|
||||
|
||||
it('resets status to NONE if request fails', async () => {
|
||||
axiosMockAdapter
|
||||
.onPost(FAKE_ENDPOINTS.createBulkImport)
|
||||
.reply(httpStatus.INTERNAL_SERVER_ERROR);
|
||||
|
||||
client
|
||||
.mutate({
|
||||
mutation: [importGroupsMutation],
|
||||
variables: { sourceGroupIds: [GROUP_ID] },
|
||||
})
|
||||
.catch(() => {});
|
||||
await waitForPromises();
|
||||
|
||||
expect(results[0].progress.status).toBe(STATUSES.NONE);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows default error message when server error is not provided', async () => {
|
||||
axiosMockAdapter
|
||||
.onPost(FAKE_ENDPOINTS.createBulkImport)
|
||||
.reply(httpStatus.INTERNAL_SERVER_ERROR);
|
||||
|
||||
client
|
||||
.mutate({
|
||||
mutation: importGroupsMutation,
|
||||
variables: { sourceGroupIds: [GROUP_ID] },
|
||||
})
|
||||
.catch(() => {});
|
||||
await waitForPromises();
|
||||
|
||||
expect(createFlash).toHaveBeenCalledWith({ message: 'Importing the group failed' });
|
||||
});
|
||||
|
||||
it('shows provided error message when error is included in backend response', async () => {
|
||||
const CUSTOM_MESSAGE = 'custom message';
|
||||
|
||||
axiosMockAdapter
|
||||
.onPost(FAKE_ENDPOINTS.createBulkImport)
|
||||
.reply(httpStatus.INTERNAL_SERVER_ERROR, { error: CUSTOM_MESSAGE });
|
||||
|
||||
client
|
||||
.mutate({
|
||||
mutation: importGroupsMutation,
|
||||
variables: { sourceGroupIds: [GROUP_ID] },
|
||||
})
|
||||
.catch(() => {});
|
||||
await waitForPromises();
|
||||
|
||||
expect(createFlash).toHaveBeenCalledWith({ message: CUSTOM_MESSAGE });
|
||||
await axios.waitForAll();
|
||||
expect(results[0].progress.status).toBe(STATUSES.CREATED);
|
||||
});
|
||||
});
|
||||
|
||||
it('setImportProgress updates group progress and sets import target', async () => {
|
||||
it('updateImportStatus updates status', async () => {
|
||||
const NEW_STATUS = 'dummy';
|
||||
const FAKE_JOB_ID = 5;
|
||||
const IMPORT_TARGET = {
|
||||
__typename: 'ClientBulkImportTarget',
|
||||
new_name: 'fake_name',
|
||||
target_namespace: 'fake_target',
|
||||
};
|
||||
const {
|
||||
data: {
|
||||
setImportProgress: { progress, last_import_target: lastImportTarget },
|
||||
},
|
||||
} = await client.mutate({
|
||||
mutation: setImportProgressMutation,
|
||||
await client.mutate({
|
||||
mutation: importGroupsMutation,
|
||||
variables: {
|
||||
sourceGroupId: GROUP_ID,
|
||||
status: NEW_STATUS,
|
||||
jobId: FAKE_JOB_ID,
|
||||
importTarget: IMPORT_TARGET,
|
||||
importRequests: [
|
||||
{
|
||||
sourceGroupId: statusEndpointFixture.importable_data[0].id,
|
||||
newName: 'test',
|
||||
targetNamespace: 'root',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await axios.waitForAll();
|
||||
await waitForPromises();
|
||||
|
||||
expect(lastImportTarget).toStrictEqual(IMPORT_TARGET);
|
||||
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 {
|
||||
data: { updateImportStatus: statusInResponse },
|
||||
} = await client.mutate({
|
||||
mutation: updateImportStatusMutation,
|
||||
variables: { id: FAKE_JOB_ID, status: NEW_STATUS },
|
||||
variables: { id, status: NEW_STATUS },
|
||||
});
|
||||
|
||||
expect(statusInResponse).toStrictEqual({
|
||||
__typename: clientTypenames.BulkImportProgress,
|
||||
id: FAKE_JOB_ID,
|
||||
id,
|
||||
status: NEW_STATUS,
|
||||
});
|
||||
});
|
||||
|
||||
it('addValidationError adds error to group', async () => {
|
||||
const FAKE_FIELD = 'some-field';
|
||||
const FAKE_MESSAGE = 'some-message';
|
||||
const {
|
||||
data: {
|
||||
addValidationError: { validation_errors: validationErrors },
|
||||
},
|
||||
} = await client.mutate({
|
||||
mutation: addValidationErrorMutation,
|
||||
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE },
|
||||
});
|
||||
|
||||
expect(validationErrors).toStrictEqual([
|
||||
{
|
||||
__typename: clientTypenames.BulkImportValidationError,
|
||||
field: FAKE_FIELD,
|
||||
message: FAKE_MESSAGE,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('removeValidationError removes error from group', async () => {
|
||||
const FAKE_FIELD = 'some-field';
|
||||
const FAKE_MESSAGE = 'some-message';
|
||||
|
||||
await client.mutate({
|
||||
mutation: addValidationErrorMutation,
|
||||
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE },
|
||||
});
|
||||
|
||||
const {
|
||||
data: {
|
||||
removeValidationError: { validation_errors: validationErrors },
|
||||
},
|
||||
} = await client.mutate({
|
||||
mutation: removeValidationErrorMutation,
|
||||
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD },
|
||||
});
|
||||
|
||||
expect(validationErrors).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
import { STATUSES } from '~/import_entities/constants';
|
||||
import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory';
|
||||
|
||||
export const generateFakeEntry = ({ id, status, ...rest }) => ({
|
||||
__typename: clientTypenames.BulkImportSourceGroup,
|
||||
web_url: `https://fake.host/${id}`,
|
||||
full_path: `fake_group_${id}`,
|
||||
full_name: `fake_name_${id}`,
|
||||
import_target: {
|
||||
target_namespace: 'root',
|
||||
new_name: `group${id}`,
|
||||
},
|
||||
last_import_target: {
|
||||
target_namespace: 'root',
|
||||
new_name: `last-group${id}`,
|
||||
webUrl: `https://fake.host/${id}`,
|
||||
fullPath: `fake_group_${id}`,
|
||||
fullName: `fake_name_${id}`,
|
||||
lastImportTarget: {
|
||||
id,
|
||||
targetNamespace: 'root',
|
||||
newName: `group${id}`,
|
||||
},
|
||||
id,
|
||||
progress: {
|
||||
id: `test-${id}`,
|
||||
status,
|
||||
},
|
||||
validation_errors: [],
|
||||
progress:
|
||||
status === STATUSES.NONE || status === STATUSES.PENDING
|
||||
? null
|
||||
: {
|
||||
id,
|
||||
status,
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
|
||||
|
@ -51,9 +51,9 @@ export const statusEndpointFixture = {
|
|||
],
|
||||
};
|
||||
|
||||
export const availableNamespacesFixture = [
|
||||
{ id: 24, full_path: 'Commit451' },
|
||||
{ id: 22, full_path: 'gitlab-org' },
|
||||
{ id: 23, full_path: 'gnuwget' },
|
||||
{ id: 25, full_path: 'jashkenas' },
|
||||
];
|
||||
export const availableNamespacesFixture = Object.freeze([
|
||||
{ id: 24, fullPath: 'Commit451' },
|
||||
{ id: 22, fullPath: 'gitlab-org' },
|
||||
{ id: 23, fullPath: 'gnuwget' },
|
||||
{ id: 25, fullPath: 'jashkenas' },
|
||||
]);
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,64 +0,0 @@
|
|||
import {
|
||||
KEY,
|
||||
SourceGroupsManager,
|
||||
} from '~/import_entities/import_groups/graphql/services/source_groups_manager';
|
||||
|
||||
const FAKE_SOURCE_URL = 'http://demo.host';
|
||||
|
||||
describe('SourceGroupsManager', () => {
|
||||
let manager;
|
||||
let storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
};
|
||||
|
||||
manager = new SourceGroupsManager({ storage, sourceUrl: FAKE_SOURCE_URL });
|
||||
});
|
||||
|
||||
describe('storage management', () => {
|
||||
const IMPORT_ID = 1;
|
||||
const IMPORT_TARGET = { new_name: 'demo', target_namespace: 'foo' };
|
||||
const STATUS = 'FAKE_STATUS';
|
||||
const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS };
|
||||
|
||||
it('loads state from storage on creation', () => {
|
||||
expect(storage.getItem).toHaveBeenCalledWith(KEY);
|
||||
});
|
||||
|
||||
it('saves to storage when createImportState is called', () => {
|
||||
const FAKE_STATUS = 'fake;';
|
||||
manager.createImportState(IMPORT_ID, { status: FAKE_STATUS, groups: [FAKE_GROUP] });
|
||||
const storedObject = JSON.parse(storage.setItem.mock.calls[0][1]);
|
||||
expect(Object.values(storedObject)[0]).toStrictEqual({
|
||||
status: FAKE_STATUS,
|
||||
groups: [
|
||||
{
|
||||
id: FAKE_GROUP.id,
|
||||
importTarget: IMPORT_TARGET,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('updates storage when previous state is available', () => {
|
||||
const CHANGED_STATUS = 'changed';
|
||||
|
||||
manager.createImportState(IMPORT_ID, { status: STATUS, groups: [FAKE_GROUP] });
|
||||
|
||||
manager.updateImportProgress(IMPORT_ID, CHANGED_STATUS);
|
||||
const storedObject = JSON.parse(storage.setItem.mock.calls[1][1]);
|
||||
expect(Object.values(storedObject)[0]).toStrictEqual({
|
||||
status: CHANGED_STATUS,
|
||||
groups: [
|
||||
{
|
||||
id: FAKE_GROUP.id,
|
||||
importTarget: IMPORT_TARGET,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2,19 +2,13 @@ import MockAdapter from 'axios-mock-adapter';
|
|||
import Visibility from 'visibilityjs';
|
||||
import createFlash from '~/flash';
|
||||
import { STATUSES } from '~/import_entities/constants';
|
||||
import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller';
|
||||
import { StatusPoller } from '~/import_entities/import_groups/services/status_poller';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import Poll from '~/lib/utils/poll';
|
||||
|
||||
jest.mock('visibilityjs');
|
||||
jest.mock('~/flash');
|
||||
jest.mock('~/lib/utils/poll');
|
||||
jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manager', () => ({
|
||||
SourceGroupsManager: jest.fn().mockImplementation(function mock() {
|
||||
this.setImportStatus = jest.fn();
|
||||
this.findByImportId = jest.fn();
|
||||
}),
|
||||
}));
|
||||
|
||||
const FAKE_POLL_PATH = '/fake/poll/path';
|
||||
|
||||
|
@ -81,6 +75,7 @@ describe('Bulk import status poller', () => {
|
|||
const [pollInstance] = Poll.mock.instances;
|
||||
|
||||
poller.startPolling();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(pollInstance.makeRequest).toHaveBeenCalled();
|
||||
});
|
|
@ -1326,14 +1326,25 @@ RSpec.describe QuickActions::InterpretService do
|
|||
let(:issuable) { issue }
|
||||
end
|
||||
|
||||
it_behaves_like 'confidential command' do
|
||||
let(:content) { '/confidential' }
|
||||
let(:issuable) { issue }
|
||||
end
|
||||
context '/confidential' do
|
||||
it_behaves_like 'confidential command' do
|
||||
let(:content) { '/confidential' }
|
||||
let(:issuable) { issue }
|
||||
end
|
||||
|
||||
it_behaves_like 'confidential command' do
|
||||
let(:content) { '/confidential' }
|
||||
let(:issuable) { create(:incident, project: project) }
|
||||
it_behaves_like 'confidential command' do
|
||||
let(:content) { '/confidential' }
|
||||
let(:issuable) { create(:incident, project: project) }
|
||||
end
|
||||
|
||||
context 'when non-member is creating a new issue' do
|
||||
let(:service) { described_class.new(project, create(:user)) }
|
||||
|
||||
it_behaves_like 'confidential command' do
|
||||
let(:content) { '/confidential' }
|
||||
let(:issuable) { build(:issue, project: project) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'lock command' do
|
||||
|
|
Loading…
Reference in New Issue