Commit aaeffccc authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch...

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

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

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