Commit aaeffccc authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch...

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

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

See merge request gitlab-org/gitlab!71834
parents 4bef03b8 0c8e7f53
...@@ -3,10 +3,11 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -3,10 +3,11 @@ import axios from '~/lib/utils/axios_utils';
const NAMESPACE_EXISTS_PATH = '/api/:version/namespaces/:id/exists'; const NAMESPACE_EXISTS_PATH = '/api/:version/namespaces/:id/exists';
export default function fetchGroupPathAvailability(groupPath, parentId) { export function getGroupPathAvailability(groupPath, parentId, axiosOptions = {}) {
const url = buildApiUrl(NAMESPACE_EXISTS_PATH).replace(':id', encodeURIComponent(groupPath)); const url = buildApiUrl(NAMESPACE_EXISTS_PATH).replace(':id', encodeURIComponent(groupPath));
return axios.get(url, { return axios.get(url, {
params: { parent_id: parentId }, params: { parent_id: parentId, ...axiosOptions.params },
...axiosOptions,
}); });
} }
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability'; import { getGroupPathAvailability } from '~/rest_api';
import { slugify } from './lib/utils/text_utility'; import { slugify } from './lib/utils/text_utility';
export default class Group { export default class Group {
...@@ -51,7 +51,7 @@ export default class Group { ...@@ -51,7 +51,7 @@ export default class Group {
const slug = this.groupPaths[0]?.value || slugify(value); const slug = this.groupPaths[0]?.value || slugify(value);
if (!slug) return; if (!slug) return;
fetchGroupPathAvailability(slug, this.parentId?.value) getGroupPathAvailability(slug, this.parentId?.value)
.then(({ data }) => data) .then(({ data }) => data)
.then(({ exists, suggests }) => { .then(({ exists, suggests }) => {
if (exists && suggests.length) { if (exists && suggests.length) {
......
...@@ -19,7 +19,7 @@ export default { ...@@ -19,7 +19,7 @@ export default {
computed: { computed: {
filteredNamespaces() { filteredNamespaces() {
return this.namespaces.filter((ns) => return this.namespaces.filter((ns) =>
ns.toLowerCase().includes(this.searchTerm.toLowerCase()), ns.fullPath.toLowerCase().includes(this.searchTerm.toLowerCase()),
); );
}, },
}, },
......
<script> <script>
import { GlButton, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { GlButton, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import { isFinished, isInvalid, isAvailableForImport } from '../utils';
export default { export default {
components: { components: {
...@@ -12,32 +10,17 @@ export default { ...@@ -12,32 +10,17 @@ export default {
GlTooltip, GlTooltip,
}, },
props: { props: {
group: { isFinished: {
type: Object, type: Boolean,
required: true, required: true,
}, },
groupPathRegex: { isAvailableForImport: {
type: RegExp, type: Boolean,
required: true, required: true,
}, },
}, isInvalid: {
computed: { type: Boolean,
fullLastImportPath() { required: true,
return this.group.last_import_target
? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}`
: null;
},
absoluteLastImportPath() {
return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath);
},
isAvailableForImport() {
return isAvailableForImport(this.group);
},
isFinished() {
return isFinished(this.group);
},
isInvalid() {
return isInvalid(this.group, this.groupPathRegex);
}, },
}, },
}; };
...@@ -56,7 +39,7 @@ export default { ...@@ -56,7 +39,7 @@ export default {
{{ isFinished ? __('Re-import') : __('Import') }} {{ isFinished ? __('Re-import') : __('Import') }}
</gl-button> </gl-button>
<gl-icon <gl-icon
v-if="isFinished" v-if="isAvailableForImport && isFinished"
v-gl-tooltip v-gl-tooltip
:size="16" :size="16"
name="information-o" name="information-o"
......
<script> <script>
import { GlLink, GlSprintf, GlIcon } from '@gitlab/ui'; import { GlLink, GlSprintf, GlIcon } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility'; import { joinPaths } from '~/lib/utils/url_utility';
import { isFinished } from '../utils';
export default { export default {
components: { components: {
...@@ -17,16 +16,13 @@ export default { ...@@ -17,16 +16,13 @@ export default {
}, },
computed: { computed: {
fullLastImportPath() { fullLastImportPath() {
return this.group.last_import_target return this.group.lastImportTarget
? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}` ? `${this.group.lastImportTarget.targetNamespace}/${this.group.lastImportTarget.newName}`
: null; : null;
}, },
absoluteLastImportPath() { absoluteLastImportPath() {
return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath); return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath);
}, },
isFinished() {
return isFinished(this.group);
},
}, },
}; };
</script> </script>
...@@ -34,13 +30,13 @@ export default { ...@@ -34,13 +30,13 @@ export default {
<template> <template>
<div> <div>
<gl-link <gl-link
:href="group.web_url" :href="group.webUrl"
target="_blank" target="_blank"
class="gl-display-inline-flex gl-align-items-center gl-h-7" class="gl-display-inline-flex gl-align-items-center gl-h-7"
> >
{{ group.full_path }} <gl-icon name="external-link" /> {{ group.fullPath }} <gl-icon name="external-link" />
</gl-link> </gl-link>
<div v-if="isFinished && fullLastImportPath" class="gl-font-sm"> <div v-if="group.flags.isFinished && fullLastImportPath" class="gl-font-sm">
<gl-sprintf :message="s__('BulkImport|Last imported to %{link}')"> <gl-sprintf :message="s__('BulkImport|Last imported to %{link}')">
<template #link> <template #link>
<gl-link :href="absoluteLastImportPath" class="gl-font-sm" target="_blank">{{ <gl-link :href="absoluteLastImportPath" class="gl-font-sm" target="_blank">{{
......
...@@ -7,12 +7,7 @@ import { ...@@ -7,12 +7,7 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import ImportGroupDropdown from '../../components/group_dropdown.vue'; import ImportGroupDropdown from '../../components/group_dropdown.vue';
import { import { getInvalidNameValidationMessage } from '../utils';
isInvalid,
getInvalidNameValidationMessage,
isNameValid,
isAvailableForImport,
} from '../utils';
export default { export default {
components: { components: {
...@@ -31,44 +26,15 @@ export default { ...@@ -31,44 +26,15 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
groupPathRegex: {
type: RegExp,
required: true,
},
groupUrlErrorMessage: {
type: String,
required: true,
},
}, },
computed: { computed: {
availableNamespaceNames() { fullPath() {
return this.availableNamespaces.map((ns) => ns.full_path); return this.group.importTarget.targetNamespace.fullPath || s__('BulkImport|No parent');
},
importTarget() {
return this.group.import_target;
}, },
invalidNameValidationMessage() { invalidNameValidationMessage() {
return getInvalidNameValidationMessage(this.group); return getInvalidNameValidationMessage(this.group.importTarget);
}, },
isInvalid() {
return isInvalid(this.group, this.groupPathRegex);
},
isNameValid() {
return isNameValid(this.group, this.groupPathRegex);
},
isAvailableForImport() {
return isAvailableForImport(this.group);
},
},
i18n: {
NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
}, },
}; };
</script> </script>
...@@ -77,14 +43,14 @@ export default { ...@@ -77,14 +43,14 @@ export default {
<div class="gl-display-flex gl-align-items-stretch"> <div class="gl-display-flex gl-align-items-stretch">
<import-group-dropdown <import-group-dropdown
#default="{ namespaces }" #default="{ namespaces }"
:text="importTarget.target_namespace" :text="fullPath"
:disabled="!isAvailableForImport" :disabled="!group.flags.isAvailableForImport"
:namespaces="availableNamespaceNames" :namespaces="availableNamespaces"
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
class="gl-h-7 gl-flex-grow-1" class="gl-h-7 gl-flex-grow-1"
data-qa-selector="target_namespace_selector_dropdown" data-qa-selector="target_namespace_selector_dropdown"
> >
<gl-dropdown-item @click="$emit('update-target-namespace', '')">{{ <gl-dropdown-item @click="$emit('update-target-namespace', { fullPath: '', id: null })">{{
s__('BulkImport|No parent') s__('BulkImport|No parent')
}}</gl-dropdown-item> }}</gl-dropdown-item>
<template v-if="namespaces.length"> <template v-if="namespaces.length">
...@@ -94,20 +60,20 @@ export default { ...@@ -94,20 +60,20 @@ export default {
</gl-dropdown-section-header> </gl-dropdown-section-header>
<gl-dropdown-item <gl-dropdown-item
v-for="ns in namespaces" v-for="ns in namespaces"
:key="ns" :key="ns.fullPath"
data-qa-selector="target_group_dropdown_item" data-qa-selector="target_group_dropdown_item"
:data-qa-group-name="ns" :data-qa-group-name="ns.fullPath"
@click="$emit('update-target-namespace', ns)" @click="$emit('update-target-namespace', ns)"
> >
{{ ns }} {{ ns.fullPath }}
</gl-dropdown-item> </gl-dropdown-item>
</template> </template>
</import-group-dropdown> </import-group-dropdown>
<div <div
class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10" class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10"
:class="{ :class="{
'gl-text-gray-400 gl-border-gray-100': !isAvailableForImport, 'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport,
'gl-border-gray-200': isAvailableForImport, 'gl-border-gray-200': group.flags.isAvailableForImport,
}" }"
> >
/ /
...@@ -116,21 +82,21 @@ export default { ...@@ -116,21 +82,21 @@ export default {
<gl-form-input <gl-form-input
class="gl-rounded-top-left-none gl-rounded-bottom-left-none" class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:class="{ :class="{
'gl-inset-border-1-gray-200!': isAvailableForImport, 'gl-inset-border-1-gray-200!': group.flags.isAvailableForImport,
'gl-inset-border-1-gray-100!': !isAvailableForImport, 'gl-inset-border-1-gray-100!': !group.flags.isAvailableForImport,
'is-invalid': isInvalid && isAvailableForImport, 'is-invalid': group.flags.isInvalid && group.flags.isAvailableForImport,
}" }"
:disabled="!isAvailableForImport" debounce="500"
:value="importTarget.new_name" :disabled="!group.flags.isAvailableForImport"
:value="group.importTarget.newName"
:aria-label="__('New name')"
@input="$emit('update-new-name', $event)" @input="$emit('update-new-name', $event)"
/> />
<p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2"> <p
<template v-if="!isNameValid"> v-if="group.flags.isAvailableForImport && group.flags.isInvalid"
{{ groupUrlErrorMessage }} class="gl-text-red-500 gl-m-0 gl-mt-2"
</template> >
<template v-else-if="invalidNameValidationMessage"> {{ invalidNameValidationMessage }}
{{ invalidNameValidationMessage }}
</template>
</p> </p>
</div> </div>
</div> </div>
......
import { s__ } from '~/locale'; import { __, s__ } from '~/locale';
export const i18n = { export const i18n = {
NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'), ERROR_INVALID_FORMAT: s__(
'GroupSettings|Please choose a group URL with no special characters or spaces.',
),
ERROR_NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
ERROR_REQUIRED: __('This field is required.'),
ERROR_NAME_ALREADY_USED_IN_SUGGESTION: s__(
'BulkImport|Name already used as a target for another group.',
),
ERROR_IMPORT: s__('BulkImport|Importing the group failed.'),
ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'),
}; };
export const NEW_NAME_FIELD = 'new_name'; export const NEW_NAME_FIELD = 'newName';
...@@ -2,22 +2,15 @@ ...@@ -2,22 +2,15 @@
fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup { fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup {
id id
web_url webUrl
full_path fullPath
full_name fullName
lastImportTarget {
id
targetNamespace
newName
}
progress { progress {
...BulkImportSourceGroupProgress ...BulkImportSourceGroupProgress
} }
import_target {
target_namespace
new_name
}
last_import_target {
target_namespace
new_name
}
validation_errors {
field
message
}
} }
mutation addValidationError($sourceGroupId: String!, $field: String!, $message: String!) {
addValidationError(sourceGroupId: $sourceGroupId, field: $field, message: $message) @client {
id
validation_errors {
field
message
}
}
}
mutation importGroups($sourceGroupIds: [String!]!) { mutation importGroups($importRequests: [ImportGroupInput!]!) {
importGroups(sourceGroupIds: $sourceGroupIds) @client { importGroups(importRequests: $importRequests) @client {
id id
lastImportTarget {
id
targetNamespace
newName
}
progress { progress {
id id
status status
......
mutation removeValidationError($sourceGroupId: String!, $field: String!) {
removeValidationError(sourceGroupId: $sourceGroupId, field: $field) @client {
id
validation_errors {
field
message
}
}
}
mutation setImportProgress(
$status: String!
$sourceGroupId: String!
$jobId: String
$importTarget: ImportTargetInput!
) {
setImportProgress(
status: $status
sourceGroupId: $sourceGroupId
jobId: $jobId
importTarget: $importTarget
) @client {
id
progress {
id
status
}
last_import_target {
target_namespace
new_name
}
}
}
mutation setImportTarget($newName: String!, $targetNamespace: String!, $sourceGroupId: String!) {
setImportTarget(
newName: $newName
targetNamespace: $targetNamespace
sourceGroupId: $sourceGroupId
) @client {
id
import_target {
new_name
target_namespace
}
}
}
query availableNamespaces { query availableNamespaces {
availableNamespaces @client { availableNamespaces @client {
id id
full_path fullPath
} }
} }
#import "../fragments/bulk_import_source_group_item.fragment.graphql"
query bulkImportSourceGroup($id: ID!) {
bulkImportSourceGroup(id: $id) @client {
...BulkImportSourceGroupItem
}
}
query groupAndProject($fullPath: ID!) {
existingGroup: group(fullPath: $fullPath) {
id
}
existingProject: project(fullPath: $fullPath) {
id
}
}
import { debounce, merge } from 'lodash';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
const OLD_KEY = 'gl-bulk-imports-import-state';
export const KEY = 'gl-bulk-imports-import-state-v2';
export const DEBOUNCE_INTERVAL = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
export class LocalStorageCache {
constructor({ storage = window.localStorage } = {}) {
this.storage = storage;
this.cache = this.loadCacheFromStorage();
try {
// remove old storage data
this.storage.removeItem(OLD_KEY);
} catch {
// empty catch intended
}
// cache for searching data by jobid
this.jobsLookupCache = {};
}
loadCacheFromStorage() {
try {
return JSON.parse(this.storage.getItem(KEY)) ?? {};
} catch {
return {};
}
}
set(webUrl, data) {
this.cache[webUrl] = data;
this.saveCacheToStorage();
// There are changes to jobIds, drop cache
this.jobsLookupCache = {};
}
get(webUrl) {
return this.cache[webUrl];
}
getCacheKeysByJobId(jobId) {
// this is invoked by polling, so we would like to cache results
if (!this.jobsLookupCache[jobId]) {
this.jobsLookupCache[jobId] = Object.keys(this.cache).filter(
(url) => this.cache[url]?.progress.id === jobId,
);
}
return this.jobsLookupCache[jobId];
}
updateStatusByJobId(jobId, status) {
this.getCacheKeysByJobId(jobId).forEach((webUrl) =>
this.set(webUrl, {
...(this.get(webUrl) ?? {}),
progress: {
id: jobId,
status,
},
}),
);
this.saveCacheToStorage();
}
saveCacheToStorage = debounce(() => {
try {
// storage might be changed in other tab so fetch first
this.storage.setItem(KEY, JSON.stringify(merge({}, this.loadCacheFromStorage(), this.cache)));
} catch {
// empty catch intentional: storage might be unavailable or full
}
}, DEBOUNCE_INTERVAL);
}
import { debounce, merge } from 'lodash';
export const KEY = 'gl-bulk-imports-import-state';
export const DEBOUNCE_INTERVAL = 200;
export class SourceGroupsManager {
constructor({ sourceUrl, storage = window.localStorage }) {
this.sourceUrl = sourceUrl;
this.storage = storage;
this.importStates = this.loadImportStatesFromStorage();
}
loadImportStatesFromStorage() {
try {
return Object.fromEntries(
Object.entries(JSON.parse(this.storage.getItem(KEY)) ?? {}).map(([jobId, config]) => {
// new format of storage
if (config.groups) {
return [jobId, config];
}
return [
jobId,
{
status: config.status,
groups: [{ id: config.id, importTarget: config.importTarget }],
},
];
}),
);
} catch {
return {};
}
}
createImportState(importId, jobConfig) {
this.importStates[importId] = {
status: jobConfig.status,
groups: jobConfig.groups.map((g) => ({
importTarget: { ...g.import_target },
id: g.id,
})),
};
this.saveImportStatesToStorage();
}
updateImportProgress(importId, status) {
const currentState = this.importStates[importId];
if (!currentState) {
return;
}
currentState.status = status;
this.saveImportStatesToStorage();
}
getImportedGroupsByJobId(jobId) {
return this.importStates[jobId]?.groups ?? [];
}
getImportStateFromStorageByGroupId(groupId) {
const [jobId, importState] =
Object.entries(this.importStates)
.reverse()
.find(([, state]) => state.groups.some((g) => g.id === groupId)) ?? [];
if (!jobId) {
return null;
}
const group = importState.groups.find((g) => g.id === groupId);
return { jobId, importState: { ...group, status: importState.status } };
}
saveImportStatesToStorage = debounce(() => {
try {
// storage might be changed in other tab so fetch first
this.storage.setItem(
KEY,
JSON.stringify(merge({}, this.loadImportStatesFromStorage(), this.importStates)),
);
} catch {
// empty catch intentional: storage might be unavailable or full
}
}, DEBOUNCE_INTERVAL);
}
type ClientBulkImportAvailableNamespace { type ClientBulkImportAvailableNamespace {
id: ID! id: ID!
full_path: String! fullPath: String!
} }
type ClientBulkImportTarget { type ClientBulkImportTarget {
target_namespace: String! targetNamespace: String!
new_name: String! newName: String!
} }
type ClientBulkImportSourceGroupConnection { type ClientBulkImportSourceGroupConnection {
...@@ -14,7 +14,7 @@ type ClientBulkImportSourceGroupConnection { ...@@ -14,7 +14,7 @@ type ClientBulkImportSourceGroupConnection {
} }
type ClientBulkImportProgress { type ClientBulkImportProgress {
id: ID id: ID!
status: String! status: String!
} }
...@@ -25,13 +25,11 @@ type ClientBulkImportValidationError { ...@@ -25,13 +25,11 @@ type ClientBulkImportValidationError {
type ClientBulkImportSourceGroup { type ClientBulkImportSourceGroup {
id: ID! id: ID!
web_url: String! webUrl: String!
full_path: String! fullPath: String!
full_name: String! fullName: String!
progress: ClientBulkImportProgress! lastImportTarget: ClientBulkImportTarget
import_target: ClientBulkImportTarget! progress: ClientBulkImportProgress
last_import_target: ClientBulkImportTarget
validation_errors: [ClientBulkImportValidationError!]!
} }
type ClientBulkImportPageInfo { type ClientBulkImportPageInfo {
...@@ -41,8 +39,13 @@ type ClientBulkImportPageInfo { ...@@ -41,8 +39,13 @@ type ClientBulkImportPageInfo {
totalPages: Int! totalPages: Int!
} }
type ClientBulkImportNamespaceSuggestion {
id: ID!
exists: Boolean!
suggestions: [String!]!
}
extend type Query { extend type Query {
bulkImportSourceGroup(id: ID!): ClientBulkImportSourceGroup
bulkImportSourceGroups( bulkImportSourceGroups(
page: Int! page: Int!
perPage: Int! perPage: Int!
...@@ -51,26 +54,13 @@ extend type Query { ...@@ -51,26 +54,13 @@ extend type Query {
availableNamespaces: [ClientBulkImportAvailableNamespace!]! availableNamespaces: [ClientBulkImportAvailableNamespace!]!
} }
input InputTargetInput { input ImportRequestInput {
target_namespace: String! sourceGroupId: ID!
new_name: String! targetNamespace: String!
newName: String!
} }
extend type Mutation { extend type Mutation {
setNewName(newName: String, sourceGroupId: ID!): ClientBulkImportSourceGroup! importGroups(importRequests: [ImportRequestInput!]!): [ClientBulkImportSourceGroup!]!
setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientBulkImportSourceGroup! updateImportStatus(id: ID, status: String!): ClientBulkImportProgress
importGroups(sourceGroupIds: [ID!]!): [ClientBulkImportSourceGroup!]!
setImportProgress(
id: ID
status: String!
jobId: String
importTarget: ImportTargetInput!
): ClientBulkImportSourceGroup!
updateImportProgress(id: ID, status: String!): ClientBulkImportProgress
addValidationError(
sourceGroupId: ID!
field: String!
message: String!
): ClientBulkImportSourceGroup!
removeValidationError(sourceGroupId: ID!, field: String!): ClientBulkImportSourceGroup!
} }
...@@ -17,7 +17,6 @@ export function mountImportGroupsApp(mountElement) { ...@@ -17,7 +17,6 @@ export function mountImportGroupsApp(mountElement) {
jobsPath, jobsPath,
sourceUrl, sourceUrl,
groupPathRegex, groupPathRegex,
groupUrlErrorMessage,
} = mountElement.dataset; } = mountElement.dataset;
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createApolloClient({ defaultClient: createApolloClient({
...@@ -26,7 +25,6 @@ export function mountImportGroupsApp(mountElement) { ...@@ -26,7 +25,6 @@ export function mountImportGroupsApp(mountElement) {
status: statusPath, status: statusPath,
availableNamespaces: availableNamespacesPath, availableNamespaces: availableNamespacesPath,
createBulkImport: createBulkImportPath, createBulkImport: createBulkImportPath,
jobs: jobsPath,
}, },
}), }),
}); });
...@@ -38,8 +36,8 @@ export function mountImportGroupsApp(mountElement) { ...@@ -38,8 +36,8 @@ export function mountImportGroupsApp(mountElement) {
return createElement(ImportTable, { return createElement(ImportTable, {
props: { props: {
sourceUrl, sourceUrl,
jobsPath,
groupPathRegex: new RegExp(`^(${groupPathRegex})$`), groupPathRegex: new RegExp(`^(${groupPathRegex})$`),
groupUrlErrorMessage,
}, },
}); });
}, },
......
...@@ -32,4 +32,8 @@ export class StatusPoller { ...@@ -32,4 +32,8 @@ export class StatusPoller {
startPolling() { startPolling() {
this.eTagPoll.makeRequest(); this.eTagPoll.makeRequest();
} }
stopPolling() {
this.eTagPoll.stop();
}
} }
import { STATUSES } from '../constants'; import { STATUSES } from '../constants';
import { NEW_NAME_FIELD } from './constants'; import { NEW_NAME_FIELD } from './constants';
export function isNameValid(group, validationRegex) { export function isNameValid(importTarget, validationRegex) {
return validationRegex.test(group.import_target[NEW_NAME_FIELD]); return validationRegex.test(importTarget[NEW_NAME_FIELD]);
} }
export function getInvalidNameValidationMessage(group) { export function getInvalidNameValidationMessage(importTarget) {
return group.validation_errors.find(({ field }) => field === NEW_NAME_FIELD)?.message; return importTarget.validationErrors?.find(({ field }) => field === NEW_NAME_FIELD)?.message;
}
export function isInvalid(group, validationRegex) {
return Boolean(!isNameValid(group, validationRegex) || getInvalidNameValidationMessage(group));
} }
export function isFinished(group) { export function isFinished(group) {
return group.progress.status === STATUSES.FINISHED; return [STATUSES.FINISHED, STATUSES.FAILED].includes(group.progress?.status);
} }
export function isAvailableForImport(group) { export function isAvailableForImport(group) {
return [STATUSES.NONE, STATUSES.FINISHED].some((status) => group.progress.status === status); return !group.progress || isFinished(group);
}
export function isSameTarget(importTarget) {
return (target) =>
target !== importTarget &&
target.newName.toLowerCase() === importTarget.newName.toLowerCase() &&
target.targetNamespace.id === importTarget.targetNamespace.id;
} }
...@@ -46,10 +46,6 @@ export default { ...@@ -46,10 +46,6 @@ export default {
return `${this.filter}-${this.repositories.length}-${this.pageInfo.page}`; return `${this.filter}-${this.repositories.length}-${this.pageInfo.page}`;
}, },
availableNamespaces() {
return this.namespaces.map(({ fullPath }) => fullPath);
},
importAllButtonText() { importAllButtonText() {
if (this.isImportingAnyRepo) { if (this.isImportingAnyRepo) {
return n__('Importing %d repository', 'Importing %d repositories', this.importingRepoCount); return n__('Importing %d repository', 'Importing %d repositories', this.importingRepoCount);
...@@ -167,7 +163,7 @@ export default { ...@@ -167,7 +163,7 @@ export default {
<provider-repo-table-row <provider-repo-table-row
:key="repo.importSource.providerLink" :key="repo.importSource.providerLink"
:repo="repo" :repo="repo"
:available-namespaces="availableNamespaces" :available-namespaces="namespaces"
:user-namespace="defaultTargetNamespace" :user-namespace="defaultTargetNamespace"
/> />
</template> </template>
......
...@@ -128,17 +128,17 @@ export default { ...@@ -128,17 +128,17 @@ export default {
<gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
<gl-dropdown-item <gl-dropdown-item
v-for="ns in namespaces" v-for="ns in namespaces"
:key="ns" :key="ns.fullPath"
data-qa-selector="target_group_dropdown_item" data-qa-selector="target_group_dropdown_item"
:data-qa-group-name="ns" :data-qa-group-name="ns.fullPath"
@click="updateImportTarget({ targetNamespace: ns })" @click="updateImportTarget({ targetNamespace: ns.fullPath })"
> >
{{ ns }} {{ ns.fullPath }}
</gl-dropdown-item> </gl-dropdown-item>
<gl-dropdown-divider /> <gl-dropdown-divider />
</template> </template>
<gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
<gl-dropdown-item @click="updateImportTarget({ targetNamespace: ns })">{{ <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{
userNamespace userNamespace
}}</gl-dropdown-item> }}</gl-dropdown-item>
</import-group-dropdown> </import-group-dropdown>
......
...@@ -3,7 +3,7 @@ import { debounce } from 'lodash'; ...@@ -3,7 +3,7 @@ import { debounce } from 'lodash';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import InputValidator from '~/validators/input_validator'; import InputValidator from '~/validators/input_validator';
import fetchGroupPathAvailability from './fetch_group_path_availability'; import { getGroupPathAvailability } from '~/rest_api';
const debounceTimeoutDuration = 1000; const debounceTimeoutDuration = 1000;
const invalidInputClass = 'gl-field-error-outline'; const invalidInputClass = 'gl-field-error-outline';
...@@ -45,7 +45,7 @@ export default class GroupPathValidator extends InputValidator { ...@@ -45,7 +45,7 @@ export default class GroupPathValidator extends InputValidator {
if (inputDomElement.checkValidity() && groupPath.length > 1) { if (inputDomElement.checkValidity() && groupPath.length > 1) {
GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector); GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector);
fetchGroupPathAvailability(groupPath, parentId) getGroupPathAvailability(groupPath, parentId)
.then(({ data }) => data) .then(({ data }) => data)
.then((data) => { .then((data) => {
GroupPathValidator.setInputState(inputDomElement, !data.exists); GroupPathValidator.setInputState(inputDomElement, !data.exists);
......
...@@ -3,6 +3,7 @@ export * from './api/projects_api'; ...@@ -3,6 +3,7 @@ export * from './api/projects_api';
export * from './api/user_api'; export * from './api/user_api';
export * from './api/markdown_api'; export * from './api/markdown_api';
export * from './api/bulk_imports_api'; export * from './api/bulk_imports_api';
export * from './api/namespaces_api';
// Note: It's not possible to spy on methods imported from this file in // Note: It's not possible to spy on methods imported from this file in
// Jest tests. // Jest tests.
......
...@@ -5930,10 +5930,13 @@ msgstr "" ...@@ -5930,10 +5930,13 @@ msgstr ""
msgid "BulkImport|Import groups from GitLab" msgid "BulkImport|Import groups from GitLab"
msgstr "" msgstr ""
msgid "BulkImport|Import is finished. Pick another name for re-import"
msgstr ""
msgid "BulkImport|Import selected" msgid "BulkImport|Import selected"
msgstr "" msgstr ""
msgid "BulkImport|Importing the group failed" msgid "BulkImport|Importing the group failed."
msgstr "" msgstr ""
msgid "BulkImport|Last imported to %{link}" msgid "BulkImport|Last imported to %{link}"
...@@ -5942,6 +5945,9 @@ msgstr "" ...@@ -5942,6 +5945,9 @@ msgstr ""
msgid "BulkImport|Name already exists." msgid "BulkImport|Name already exists."
msgstr "" msgstr ""
msgid "BulkImport|Name already used as a target for another group."
msgstr ""
msgid "BulkImport|New group" msgid "BulkImport|New group"
msgstr "" msgstr ""
...@@ -23027,6 +23033,9 @@ msgstr "" ...@@ -23027,6 +23033,9 @@ msgstr ""
msgid "New milestone" msgid "New milestone"
msgstr "" msgstr ""
msgid "New name"
msgstr ""
msgid "New password" msgid "New password"
msgstr "" msgstr ""
...@@ -24721,6 +24730,9 @@ msgstr "" ...@@ -24721,6 +24730,9 @@ msgstr ""
msgid "Page settings" msgid "Page settings"
msgstr "" msgstr ""
msgid "Page size"
msgstr ""
msgid "PagerDutySettings|Active" msgid "PagerDutySettings|Active"
msgstr "" msgstr ""
......
...@@ -24,14 +24,21 @@ describe('Import entities group dropdown component', () => { ...@@ -24,14 +24,21 @@ describe('Import entities group dropdown component', () => {
}); });
it('passes namespaces from props to default slot', () => { it('passes namespaces from props to default slot', () => {
const namespaces = ['ns1', 'ns2']; const namespaces = [
{ id: 1, fullPath: 'ns1' },
{ id: 2, fullPath: 'ns2' },
];
createComponent({ namespaces }); createComponent({ namespaces });
expect(namespacesTracker).toHaveBeenCalledWith({ namespaces }); expect(namespacesTracker).toHaveBeenCalledWith({ namespaces });
}); });
it('filters namespaces based on user input', async () => { it('filters namespaces based on user input', async () => {
const namespaces = ['match1', 'some unrelated', 'match2']; const namespaces = [
{ id: 1, fullPath: 'match1' },
{ id: 2, fullPath: 'some unrelated' },
{ id: 3, fullPath: 'match2' },
];
createComponent({ namespaces }); createComponent({ namespaces });
namespacesTracker.mockReset(); namespacesTracker.mockReset();
...@@ -39,6 +46,11 @@ describe('Import entities group dropdown component', () => { ...@@ -39,6 +46,11 @@ describe('Import entities group dropdown component', () => {
await nextTick(); await nextTick();
expect(namespacesTracker).toHaveBeenCalledWith({ namespaces: ['match1', 'match2'] }); expect(namespacesTracker).toHaveBeenCalledWith({
namespaces: [
{ id: 1, fullPath: 'match1' },
{ id: 3, fullPath: 'match2' },
],
});
}); });
}); });
import { GlButton, GlIcon } from '@gitlab/ui'; import { GlButton, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { STATUSES } from '~/import_entities/constants';
import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue'; import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue';
import { generateFakeEntry } from '../graphql/fixtures';
describe('import actions cell', () => { describe('import actions cell', () => {
let wrapper; let wrapper;
...@@ -10,7 +8,9 @@ describe('import actions cell', () => { ...@@ -10,7 +8,9 @@ describe('import actions cell', () => {
const createComponent = (props) => { const createComponent = (props) => {
wrapper = shallowMount(ImportActionsCell, { wrapper = shallowMount(ImportActionsCell, {
propsData: { propsData: {
groupPathRegex: /^[a-zA-Z]+$/, isFinished: false,
isAvailableForImport: false,
isInvalid: false,
...props, ...props,
}, },
}); });
...@@ -20,10 +20,9 @@ describe('import actions cell', () => { ...@@ -20,10 +20,9 @@ describe('import actions cell', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('when import status is NONE', () => { describe('when group is available for import', () => {
beforeEach(() => { beforeEach(() => {
const group = generateFakeEntry({ id: 1, status: STATUSES.NONE }); createComponent({ isAvailableForImport: true });
createComponent({ group });
}); });
it('renders import button', () => { it('renders import button', () => {
...@@ -37,10 +36,9 @@ describe('import actions cell', () => { ...@@ -37,10 +36,9 @@ describe('import actions cell', () => {
}); });
}); });
describe('when import status is FINISHED', () => { describe('when group is finished', () => {
beforeEach(() => { beforeEach(() => {
const group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED }); createComponent({ isAvailableForImport: true, isFinished: true });
createComponent({ group });
}); });
it('renders re-import button', () => { it('renders re-import button', () => {
...@@ -58,29 +56,22 @@ describe('import actions cell', () => { ...@@ -58,29 +56,22 @@ describe('import actions cell', () => {
}); });
}); });
it('does not render import button when group import is in progress', () => { it('does not render import button when group is not available for import', () => {
const group = generateFakeEntry({ id: 1, status: STATUSES.STARTED }); createComponent({ isAvailableForImport: false });
createComponent({ group });
const button = wrapper.findComponent(GlButton); const button = wrapper.findComponent(GlButton);
expect(button.exists()).toBe(false); expect(button.exists()).toBe(false);
}); });
it('renders import button as disabled when there are validation errors', () => { it('renders import button as disabled when group is invalid', () => {
const group = generateFakeEntry({ createComponent({ isInvalid: true, isAvailableForImport: true });
id: 1,
status: STATUSES.NONE,
validation_errors: [{ field: 'new_name', message: 'something ' }],
});
createComponent({ group });
const button = wrapper.findComponent(GlButton); const button = wrapper.findComponent(GlButton);
expect(button.props().disabled).toBe(true); expect(button.props().disabled).toBe(true);
}); });
it('emits import-group event when import button is clicked', () => { it('emits import-group event when import button is clicked', () => {
const group = generateFakeEntry({ id: 1, status: STATUSES.NONE }); createComponent({ isAvailableForImport: true });
createComponent({ group });
const button = wrapper.findComponent(GlButton); const button = wrapper.findComponent(GlButton);
button.vm.$emit('click'); button.vm.$emit('click');
......
...@@ -4,6 +4,11 @@ import { STATUSES } from '~/import_entities/constants'; ...@@ -4,6 +4,11 @@ import { STATUSES } from '~/import_entities/constants';
import ImportSourceCell from '~/import_entities/import_groups/components/import_source_cell.vue'; import ImportSourceCell from '~/import_entities/import_groups/components/import_source_cell.vue';
import { generateFakeEntry } from '../graphql/fixtures'; import { generateFakeEntry } from '../graphql/fixtures';
const generateFakeTableEntry = ({ flags = {}, ...entry }) => ({
...generateFakeEntry(entry),
flags,
});
describe('import source cell', () => { describe('import source cell', () => {
let wrapper; let wrapper;
let group; let group;
...@@ -23,14 +28,14 @@ describe('import source cell', () => { ...@@ -23,14 +28,14 @@ describe('import source cell', () => {
describe('when group status is NONE', () => { describe('when group status is NONE', () => {
beforeEach(() => { beforeEach(() => {
group = generateFakeEntry({ id: 1, status: STATUSES.NONE }); group = generateFakeTableEntry({ id: 1, status: STATUSES.NONE });
createComponent({ group }); createComponent({ group });
}); });
it('renders link to a group', () => { it('renders link to a group', () => {
const link = wrapper.findComponent(GlLink); const link = wrapper.findComponent(GlLink);
expect(link.attributes().href).toBe(group.web_url); expect(link.attributes().href).toBe(group.webUrl);
expect(link.text()).toContain(group.full_path); expect(link.text()).toContain(group.fullPath);
}); });
it('does not render last imported line', () => { it('does not render last imported line', () => {
...@@ -40,20 +45,24 @@ describe('import source cell', () => { ...@@ -40,20 +45,24 @@ describe('import source cell', () => {
describe('when group status is FINISHED', () => { describe('when group status is FINISHED', () => {
beforeEach(() => { beforeEach(() => {
group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED }); group = generateFakeTableEntry({
id: 1,
status: STATUSES.FINISHED,
flags: {
isFinished: true,
},
});
createComponent({ group }); createComponent({ group });
}); });
it('renders link to a group', () => { it('renders link to a group', () => {
const link = wrapper.findComponent(GlLink); const link = wrapper.findComponent(GlLink);
expect(link.attributes().href).toBe(group.web_url); expect(link.attributes().href).toBe(group.webUrl);
expect(link.text()).toContain(group.full_path); expect(link.text()).toContain(group.fullPath);
}); });
it('renders last imported line', () => { it('renders last imported line', () => {
expect(wrapper.text()).toMatchInterpolatedText( expect(wrapper.text()).toMatchInterpolatedText('fake_group_1 Last imported to root/group1');
'fake_group_1 Last imported to root/last-group1',
);
}); });
}); });
}); });
...@@ -3,20 +3,20 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -3,20 +3,20 @@ import { shallowMount } from '@vue/test-utils';
import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue'; import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue';
import { STATUSES } from '~/import_entities/constants'; import { STATUSES } from '~/import_entities/constants';
import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue'; import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue';
import { availableNamespacesFixture } from '../graphql/fixtures'; import { generateFakeEntry, availableNamespacesFixture } from '../graphql/fixtures';
const getFakeGroup = (status) => ({ const generateFakeTableEntry = ({ flags = {}, ...config }) => {
web_url: 'https://fake.host/', const entry = generateFakeEntry(config);
full_path: 'fake_group_1',
full_name: 'fake_name_1', return {
import_target: { ...entry,
target_namespace: 'root', importTarget: {
new_name: 'group1', targetNamespace: availableNamespacesFixture[0],
}, newName: entry.lastImportTarget.newName,
id: 1, },
validation_errors: [], flags,
progress: { status }, };
}); };
describe('import target cell', () => { describe('import target cell', () => {
let wrapper; let wrapper;
...@@ -31,7 +31,6 @@ describe('import target cell', () => { ...@@ -31,7 +31,6 @@ describe('import target cell', () => {
propsData: { propsData: {
availableNamespaces: availableNamespacesFixture, availableNamespaces: availableNamespacesFixture,
groupPathRegex: /.*/, groupPathRegex: /.*/,
groupUrlErrorMessage: 'Please choose a group URL with no special characters or spaces.',
...props, ...props,
}, },
}); });
...@@ -44,11 +43,11 @@ describe('import target cell', () => { ...@@ -44,11 +43,11 @@ describe('import target cell', () => {
describe('events', () => { describe('events', () => {
beforeEach(() => { beforeEach(() => {
group = getFakeGroup(STATUSES.NONE); group = generateFakeTableEntry({ id: 1, status: STATUSES.NONE });
createComponent({ group }); createComponent({ group });
}); });
it('invokes $event', () => { it('emits update-new-name when input value is changed', () => {
findNameInput().vm.$emit('input', 'demo'); findNameInput().vm.$emit('input', 'demo');
expect(wrapper.emitted('update-new-name')).toBeDefined(); expect(wrapper.emitted('update-new-name')).toBeDefined();
expect(wrapper.emitted('update-new-name')[0][0]).toBe('demo'); expect(wrapper.emitted('update-new-name')[0][0]).toBe('demo');
...@@ -56,18 +55,23 @@ describe('import target cell', () => { ...@@ -56,18 +55,23 @@ describe('import target cell', () => {
it('emits update-target-namespace when dropdown option is clicked', () => { it('emits update-target-namespace when dropdown option is clicked', () => {
const dropdownItem = findNamespaceDropdown().findAllComponents(GlDropdownItem).at(2); const dropdownItem = findNamespaceDropdown().findAllComponents(GlDropdownItem).at(2);
const dropdownItemText = dropdownItem.text();
dropdownItem.vm.$emit('click'); dropdownItem.vm.$emit('click');
expect(wrapper.emitted('update-target-namespace')).toBeDefined(); expect(wrapper.emitted('update-target-namespace')).toBeDefined();
expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(dropdownItemText); expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(availableNamespacesFixture[1]);
}); });
}); });
describe('when entity status is NONE', () => { describe('when entity status is NONE', () => {
beforeEach(() => { beforeEach(() => {
group = getFakeGroup(STATUSES.NONE); group = generateFakeTableEntry({
id: 1,
status: STATUSES.NONE,
flags: {
isAvailableForImport: true,
},
});
createComponent({ group }); createComponent({ group });
}); });
...@@ -78,7 +82,7 @@ describe('import target cell', () => { ...@@ -78,7 +82,7 @@ describe('import target cell', () => {
it('renders only no parent option if available namespaces list is empty', () => { it('renders only no parent option if available namespaces list is empty', () => {
createComponent({ createComponent({
group: getFakeGroup(STATUSES.NONE), group: generateFakeTableEntry({ id: 1, status: STATUSES.NONE }),
availableNamespaces: [], availableNamespaces: [],
}); });
...@@ -92,7 +96,7 @@ describe('import target cell', () => { ...@@ -92,7 +96,7 @@ describe('import target cell', () => {
it('renders both no parent option and available namespaces list when available namespaces list is not empty', () => { it('renders both no parent option and available namespaces list when available namespaces list is not empty', () => {
createComponent({ createComponent({
group: getFakeGroup(STATUSES.NONE), group: generateFakeTableEntry({ id: 1, status: STATUSES.NONE }),
availableNamespaces: availableNamespacesFixture, availableNamespaces: availableNamespacesFixture,
}); });
...@@ -104,9 +108,12 @@ describe('import target cell', () => { ...@@ -104,9 +108,12 @@ describe('import target cell', () => {
expect(rest).toHaveLength(availableNamespacesFixture.length); expect(rest).toHaveLength(availableNamespacesFixture.length);
}); });
describe('when entity status is SCHEDULING', () => { describe('when entity is not available for import', () => {
beforeEach(() => { beforeEach(() => {
group = getFakeGroup(STATUSES.SCHEDULING); group = generateFakeTableEntry({
id: 1,
flags: { isAvailableForImport: false },
});
createComponent({ group }); createComponent({ group });
}); });
...@@ -115,9 +122,9 @@ describe('import target cell', () => { ...@@ -115,9 +122,9 @@ describe('import target cell', () => {
}); });
}); });
describe('when entity status is FINISHED', () => { describe('when entity is available for import', () => {
beforeEach(() => { beforeEach(() => {
group = getFakeGroup(STATUSES.FINISHED); group = generateFakeTableEntry({ id: 1, flags: { isAvailableForImport: true } });
createComponent({ group }); createComponent({ group });
}); });
...@@ -125,41 +132,4 @@ describe('import target cell', () => { ...@@ -125,41 +132,4 @@ describe('import target cell', () => {
expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined); expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined);
}); });
}); });
describe('validations', () => {
it('reports invalid group name when name is not matching regex', () => {
createComponent({
group: {
...getFakeGroup(STATUSES.NONE),
import_target: {
target_namespace: 'root',
new_name: 'very`bad`name',
},
},
groupPathRegex: /^[a-zA-Z]+$/,
});
expect(wrapper.text()).toContain(
'Please choose a group URL with no special characters or spaces.',
);
});
it('reports invalid group name if relevant validation error exists', async () => {
const FAKE_ERROR_MESSAGE = 'fake error';
createComponent({
group: {
...getFakeGroup(STATUSES.NONE),
validation_errors: [
{
field: 'new_name',
message: FAKE_ERROR_MESSAGE,
},
],
},
});
expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE);
});
});
}); });
import { STATUSES } from '~/import_entities/constants';
import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory'; import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory';
export const generateFakeEntry = ({ id, status, ...rest }) => ({ export const generateFakeEntry = ({ id, status, ...rest }) => ({
__typename: clientTypenames.BulkImportSourceGroup, __typename: clientTypenames.BulkImportSourceGroup,
web_url: `https://fake.host/${id}`, webUrl: `https://fake.host/${id}`,
full_path: `fake_group_${id}`, fullPath: `fake_group_${id}`,
full_name: `fake_name_${id}`, fullName: `fake_name_${id}`,
import_target: { lastImportTarget: {
target_namespace: 'root', id,
new_name: `group${id}`, targetNamespace: 'root',
}, newName: `group${id}`,
last_import_target: {
target_namespace: 'root',
new_name: `last-group${id}`,
}, },
id, id,
progress: { progress:
id: `test-${id}`, status === STATUSES.NONE || status === STATUSES.PENDING
status, ? null
}, : {
validation_errors: [], id,
status,
},
...rest, ...rest,
}); });
...@@ -51,9 +51,9 @@ export const statusEndpointFixture = { ...@@ -51,9 +51,9 @@ export const statusEndpointFixture = {
], ],
}; };
export const availableNamespacesFixture = [ export const availableNamespacesFixture = Object.freeze([
{ id: 24, full_path: 'Commit451' }, { id: 24, fullPath: 'Commit451' },
{ id: 22, full_path: 'gitlab-org' }, { id: 22, fullPath: 'gitlab-org' },
{ id: 23, full_path: 'gnuwget' }, { id: 23, fullPath: 'gnuwget' },
{ id: 25, full_path: 'jashkenas' }, { id: 25, fullPath: 'jashkenas' },
]; ]);
import {
KEY,
LocalStorageCache,
} from '~/import_entities/import_groups/graphql/services/local_storage_cache';
describe('Local storage cache', () => {
let cache;
let storage;
beforeEach(() => {
storage = {
getItem: jest.fn(),
setItem: jest.fn(),
};
cache = new LocalStorageCache({ storage });
});
describe('storage management', () => {
const IMPORT_URL = 'http://fake.url';
it('loads state from storage on creation', () => {
expect(storage.getItem).toHaveBeenCalledWith(KEY);
});
it('saves to storage when set is called', () => {
const STORAGE_CONTENT = { fake: 'content ' };
cache.set(IMPORT_URL, STORAGE_CONTENT);
expect(storage.setItem).toHaveBeenCalledWith(
KEY,
JSON.stringify({ [IMPORT_URL]: STORAGE_CONTENT }),
);
});
it('updates status by job id', () => {
const CHANGED_STATUS = 'changed';
const JOB_ID = 2;
cache.set(IMPORT_URL, {
progress: {
id: JOB_ID,
status: 'original',
},
});
cache.updateStatusByJobId(JOB_ID, CHANGED_STATUS);
expect(storage.setItem).toHaveBeenCalledWith(
KEY,
JSON.stringify({
[IMPORT_URL]: {
progress: {
id: JOB_ID,
status: CHANGED_STATUS,
},
},
}),
);
});
});
});
import {
KEY,
SourceGroupsManager,
} from '~/import_entities/import_groups/graphql/services/source_groups_manager';
const FAKE_SOURCE_URL = 'http://demo.host';
describe('SourceGroupsManager', () => {
let manager;
let storage;
beforeEach(() => {
storage = {
getItem: jest.fn(),
setItem: jest.fn(),
};
manager = new SourceGroupsManager({ storage, sourceUrl: FAKE_SOURCE_URL });
});
describe('storage management', () => {
const IMPORT_ID = 1;
const IMPORT_TARGET = { new_name: 'demo', target_namespace: 'foo' };
const STATUS = 'FAKE_STATUS';
const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS };
it('loads state from storage on creation', () => {
expect(storage.getItem).toHaveBeenCalledWith(KEY);
});
it('saves to storage when createImportState is called', () => {
const FAKE_STATUS = 'fake;';
manager.createImportState(IMPORT_ID, { status: FAKE_STATUS, groups: [FAKE_GROUP] });
const storedObject = JSON.parse(storage.setItem.mock.calls[0][1]);
expect(Object.values(storedObject)[0]).toStrictEqual({
status: FAKE_STATUS,
groups: [
{
id: FAKE_GROUP.id,
importTarget: IMPORT_TARGET,
},
],
});
});
it('updates storage when previous state is available', () => {
const CHANGED_STATUS = 'changed';
manager.createImportState(IMPORT_ID, { status: STATUS, groups: [FAKE_GROUP] });
manager.updateImportProgress(IMPORT_ID, CHANGED_STATUS);
const storedObject = JSON.parse(storage.setItem.mock.calls[1][1]);
expect(Object.values(storedObject)[0]).toStrictEqual({
status: CHANGED_STATUS,
groups: [
{
id: FAKE_GROUP.id,
importTarget: IMPORT_TARGET,
},
],
});
});
});
});
...@@ -2,19 +2,13 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -2,19 +2,13 @@ import MockAdapter from 'axios-mock-adapter';
import Visibility from 'visibilityjs'; import Visibility from 'visibilityjs';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { STATUSES } from '~/import_entities/constants'; import { STATUSES } from '~/import_entities/constants';
import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; import { StatusPoller } from '~/import_entities/import_groups/services/status_poller';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll'; import Poll from '~/lib/utils/poll';
jest.mock('visibilityjs'); jest.mock('visibilityjs');
jest.mock('~/flash'); jest.mock('~/flash');
jest.mock('~/lib/utils/poll'); jest.mock('~/lib/utils/poll');
jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manager', () => ({
SourceGroupsManager: jest.fn().mockImplementation(function mock() {
this.setImportStatus = jest.fn();
this.findByImportId = jest.fn();
}),
}));
const FAKE_POLL_PATH = '/fake/poll/path'; const FAKE_POLL_PATH = '/fake/poll/path';
...@@ -81,6 +75,7 @@ describe('Bulk import status poller', () => { ...@@ -81,6 +75,7 @@ describe('Bulk import status poller', () => {
const [pollInstance] = Poll.mock.instances; const [pollInstance] = Poll.mock.instances;
poller.startPolling(); poller.startPolling();
await Promise.resolve();
expect(pollInstance.makeRequest).toHaveBeenCalled(); expect(pollInstance.makeRequest).toHaveBeenCalled();
}); });
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment