Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-11-03 09:10:11 +00:00
parent 14c866bb2f
commit f3189d2a01
47 changed files with 1005 additions and 1375 deletions

View File

@ -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

View File

@ -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,
});
}

View File

@ -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) {

View File

@ -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()),
);
},
},

View File

@ -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"

View File

@ -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">{{

View File

@ -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')">

View File

@ -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>

View File

@ -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';

View File

@ -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 });
});
},
},
};

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -1,9 +0,0 @@
mutation removeValidationError($sourceGroupId: String!, $field: String!) {
removeValidationError(sourceGroupId: $sourceGroupId, field: $field) @client {
id
validation_errors {
field
message
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -1,6 +1,6 @@
query availableNamespaces {
availableNamespaces @client {
id
full_path
fullPath
}
}

View File

@ -1,7 +0,0 @@
#import "../fragments/bulk_import_source_group_item.fragment.graphql"
query bulkImportSourceGroup($id: ID!) {
bulkImportSourceGroup(id: $id) @client {
...BulkImportSourceGroupItem
}
}

View File

@ -1,9 +0,0 @@
query groupAndProject($fullPath: ID!) {
existingGroup: group(fullPath: $fullPath) {
id
}
existingProject: project(fullPath: $fullPath) {
id
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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
}

View File

@ -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,
},
});
},

View File

@ -32,4 +32,8 @@ export class StatusPoller {
startPolling() {
this.eTagPoll.makeRequest();
}
stopPolling() {
this.eTagPoll.stop();
}
}

View File

@ -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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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);

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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|

View File

@ -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

View File

@ -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' },
],
});
});
});

View File

@ -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');

View File

@ -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');
});
});
});

View File

@ -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,
},
],
},
});
});
});

View File

@ -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);
});
});
});

View File

@ -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([]);
});
});
});

View File

@ -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' },
]);

View File

@ -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,
},
},
}),
);
});
});
});

View File

@ -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,
},
],
});
});
});
});

View File

@ -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();
});

View File

@ -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