Commit 3fee7c1c authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch 'xanf-allow-reimport' into 'master'

Allow groups to be re-imported

See merge request gitlab-org/gitlab!68055
parents 6daf1508 26fb57df
<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: {
GlIcon,
GlButton,
},
directives: {
GlTooltip,
},
props: {
group: {
type: Object,
required: true,
},
groupPathRegex: {
type: RegExp,
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);
},
},
};
</script>
<template>
<span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center">
<gl-button
v-if="isAvailableForImport"
:disabled="isInvalid"
variant="confirm"
category="secondary"
data-qa-selector="import_group_button"
@click="$emit('import-group')"
>
{{ isFinished ? __('Re-import') : __('Import') }}
</gl-button>
<gl-icon
v-if="isFinished"
v-gl-tooltip
:size="16"
name="information-o"
:title="
s__('BulkImports|Re-import creates a new group. It does not sync with the existing group.')
"
class="gl-ml-3"
/>
</span>
</template>
<script>
import { GlLink, GlSprintf, GlIcon } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import { isFinished } from '../utils';
export default {
components: {
GlLink,
GlSprintf,
GlIcon,
},
props: {
group: {
type: Object,
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);
},
isFinished() {
return isFinished(this.group);
},
},
};
</script>
<template>
<div>
<gl-link
:href="group.web_url"
target="_blank"
class="gl-display-inline-flex gl-align-items-center gl-h-7"
>
{{ group.full_path }} <gl-icon name="external-link" />
</gl-link>
<div v-if="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">{{
fullLastImportPath
}}</gl-link>
</template>
</gl-sprintf>
</div>
</div>
</template>
...@@ -9,19 +9,19 @@ import { ...@@ -9,19 +9,19 @@ import {
GlLoadingIcon, GlLoadingIcon,
GlSearchBoxByClick, GlSearchBoxByClick,
GlSprintf, GlSprintf,
GlSafeHtmlDirective as SafeHtml,
GlTable, GlTable,
GlFormCheckbox, GlFormCheckbox,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__, __, n__ } from '~/locale'; import { s__, __, n__ } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import ImportStatus from '../../components/import_status.vue'; import ImportStatusCell from '../../components/import_status.vue';
import { STATUSES } from '../../constants';
import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql'; import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql';
import setImportTargetMutation from '../graphql/mutations/set_import_target.mutation.graphql'; import setImportTargetMutation from '../graphql/mutations/set_import_target.mutation.graphql';
import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql';
import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql';
import { isInvalid } from '../utils'; import { isInvalid, isFinished, isAvailableForImport } from '../utils';
import ImportActionsCell from './import_actions_cell.vue';
import ImportSourceCell from './import_source_cell.vue';
import ImportTargetCell from './import_target_cell.vue'; import ImportTargetCell from './import_target_cell.vue';
const PAGE_SIZES = [20, 50, 100]; const PAGE_SIZES = [20, 50, 100];
...@@ -43,13 +43,12 @@ export default { ...@@ -43,13 +43,12 @@ export default {
GlFormCheckbox, GlFormCheckbox,
GlSprintf, GlSprintf,
GlTable, GlTable,
ImportStatus, ImportSourceCell,
ImportTargetCell, ImportTargetCell,
ImportStatusCell,
ImportActionsCell,
PaginationLinks, PaginationLinks,
}, },
directives: {
SafeHtml,
},
props: { props: {
sourceUrl: { sourceUrl: {
...@@ -136,7 +135,7 @@ export default { ...@@ -136,7 +135,7 @@ export default {
}, },
availableGroupsForImport() { availableGroupsForImport() {
return this.groups.filter((g) => g.progress.status === STATUSES.NONE && !this.isInvalid(g)); return this.groups.filter((g) => isAvailableForImport(g) && !this.isInvalid(g));
}, },
humanizedTotal() { humanizedTotal() {
...@@ -190,6 +189,24 @@ export default { ...@@ -190,6 +189,24 @@ export default {
}, },
methods: { methods: {
isUnselectable(group) {
return !this.isAvailableForImport(group) || this.isInvalid(group);
},
rowClasses(group) {
const DEFAULT_CLASSES = [
'gl-border-gray-200',
'gl-border-0',
'gl-border-b-1',
'gl-border-solid',
];
const result = [...DEFAULT_CLASSES];
if (this.isUnselectable(group)) {
result.push('gl-cursor-default!');
}
return result;
},
qaRowAttributes(group, type) { qaRowAttributes(group, type) {
if (type === 'row') { if (type === 'row') {
return { return {
...@@ -201,10 +218,8 @@ export default { ...@@ -201,10 +218,8 @@ export default {
return {}; return {};
}, },
isAlreadyImported(group) { isAvailableForImport,
return group.progress.status !== STATUSES.NONE; isFinished,
},
isInvalid(group) { isInvalid(group) {
return isInvalid(group, this.groupPathRegex); return isInvalid(group, this.groupPathRegex);
}, },
...@@ -253,7 +268,7 @@ export default { ...@@ -253,7 +268,7 @@ export default {
const table = this.getTableRef(); const table = this.getTableRef();
this.groups.forEach((group, idx) => { this.groups.forEach((group, idx) => {
if (table.isRowSelected(idx) && (this.isAlreadyImported(group) || this.isInvalid(group))) { if (table.isRowSelected(idx) && this.isUnselectable(group)) {
table.unselectRow(idx); table.unselectRow(idx);
} }
}); });
...@@ -291,7 +306,7 @@ export default { ...@@ -291,7 +306,7 @@ export default {
<strong>{{ filter }}</strong> <strong>{{ filter }}</strong>
</template> </template>
<template #link> <template #link>
<gl-link class="gl-display-inline-block" :href="sourceUrl" target="_blank"> <gl-link :href="sourceUrl" target="_blank">
{{ sourceUrl }} <gl-icon name="external-link" class="vertical-align-middle" /> {{ sourceUrl }} <gl-icon name="external-link" class="vertical-align-middle" />
</gl-link> </gl-link>
</template> </template>
...@@ -338,7 +353,7 @@ export default { ...@@ -338,7 +353,7 @@ export default {
ref="table" ref="table"
class="gl-w-full" class="gl-w-full"
data-qa-selector="import_table" data-qa-selector="import_table"
tbody-tr-class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid" :tbody-tr-class="rowClasses"
:tbody-tr-attr="qaRowAttributes" :tbody-tr-attr="qaRowAttributes"
:items="groups" :items="groups"
:fields="$options.fields" :fields="$options.fields"
...@@ -360,18 +375,12 @@ export default { ...@@ -360,18 +375,12 @@ export default {
<gl-form-checkbox <gl-form-checkbox
class="gl-h-7 gl-pt-3" class="gl-h-7 gl-pt-3"
:checked="rowSelected" :checked="rowSelected"
:disabled="isAlreadyImported(group) || isInvalid(group)" :disabled="!isAvailableForImport(group) || isInvalid(group)"
@change="rowSelected ? unselectRow() : selectRow()" @change="rowSelected ? unselectRow() : selectRow()"
/> />
</template> </template>
<template #cell(web_url)="{ value: web_url, item: { full_path } }"> <template #cell(web_url)="{ item: group }">
<gl-link <import-source-cell :group="group" />
:href="web_url"
target="_blank"
class="gl-display-inline-flex gl-align-items-center gl-h-7"
>
{{ full_path }} <gl-icon name="external-link" />
</gl-link>
</template> </template>
<template #cell(import_target)="{ item: group }"> <template #cell(import_target)="{ item: group }">
<import-target-cell <import-target-cell
...@@ -388,19 +397,14 @@ export default { ...@@ -388,19 +397,14 @@ export default {
/> />
</template> </template>
<template #cell(progress)="{ value: { status } }"> <template #cell(progress)="{ value: { status } }">
<import-status :status="status" class="gl-line-height-32" /> <import-status-cell :status="status" class="gl-line-height-32" />
</template> </template>
<template #cell(actions)="{ item: group }"> <template #cell(actions)="{ item: group }">
<gl-button <import-actions-cell
v-if="!isAlreadyImported(group)" :group="group"
:disabled="isInvalid(group)" :group-path-regex="groupPathRegex"
variant="confirm" @import-group="importGroups([group.id])"
category="secondary" />
data-qa-selector="import_group_button"
@click="importGroups([group.id])"
>
{{ __('Import') }}
</gl-button>
</template> </template>
</gl-table> </gl-table>
<div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center"> <div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center">
......
...@@ -3,14 +3,16 @@ import { ...@@ -3,14 +3,16 @@ import {
GlDropdownDivider, GlDropdownDivider,
GlDropdownItem, GlDropdownItem,
GlDropdownSectionHeader, GlDropdownSectionHeader,
GlLink,
GlFormInput, GlFormInput,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import ImportGroupDropdown from '../../components/group_dropdown.vue'; import ImportGroupDropdown from '../../components/group_dropdown.vue';
import { STATUSES } from '../../constants'; import {
import { isInvalid, getInvalidNameValidationMessage, isNameValid } from '../utils'; isInvalid,
getInvalidNameValidationMessage,
isNameValid,
isAvailableForImport,
} from '../utils';
export default { export default {
components: { components: {
...@@ -18,7 +20,6 @@ export default { ...@@ -18,7 +20,6 @@ export default {
GlDropdownDivider, GlDropdownDivider,
GlDropdownItem, GlDropdownItem,
GlDropdownSectionHeader, GlDropdownSectionHeader,
GlLink,
GlFormInput, GlFormInput,
}, },
props: { props: {
...@@ -61,20 +62,8 @@ export default { ...@@ -61,20 +62,8 @@ export default {
return isNameValid(this.group, this.groupPathRegex); return isNameValid(this.group, this.groupPathRegex);
}, },
isAlreadyImported() { isAvailableForImport() {
return this.group.progress.status !== STATUSES.NONE; return isAvailableForImport(this.group);
},
isFinished() {
return this.group.progress.status === STATUSES.FINISHED;
},
fullPath() {
return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`;
},
absolutePath() {
return joinPaths(gon.relative_url_root || '/', this.fullPath);
}, },
}, },
...@@ -85,25 +74,11 @@ export default { ...@@ -85,25 +74,11 @@ export default {
</script> </script>
<template> <template>
<gl-link <div class="gl-display-flex gl-align-items-stretch">
v-if="isFinished"
class="gl-display-inline-flex gl-align-items-center gl-h-7"
:href="absolutePath"
>
{{ fullPath }}
</gl-link>
<div
v-else
class="gl-display-flex gl-align-items-stretch"
:class="{
disabled: isAlreadyImported,
}"
>
<import-group-dropdown <import-group-dropdown
#default="{ namespaces }" #default="{ namespaces }"
:text="importTarget.target_namespace" :text="importTarget.target_namespace"
:disabled="isAlreadyImported" :disabled="!isAvailableForImport"
:namespaces="availableNamespaceNames" :namespaces="availableNamespaceNames"
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"
...@@ -131,8 +106,8 @@ export default { ...@@ -131,8 +106,8 @@ export default {
<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': isAlreadyImported, 'gl-text-gray-400 gl-border-gray-100': !isAvailableForImport,
'gl-border-gray-200': !isAlreadyImported, 'gl-border-gray-200': isAvailableForImport,
}" }"
> >
/ /
...@@ -141,11 +116,11 @@ export default { ...@@ -141,11 +116,11 @@ 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!': !isAlreadyImported, 'gl-inset-border-1-gray-200!': isAvailableForImport,
'gl-inset-border-1-gray-100!': isAlreadyImported, 'gl-inset-border-1-gray-100!': !isAvailableForImport,
'is-invalid': isInvalid && !isAlreadyImported, 'is-invalid': isInvalid && isAvailableForImport,
}" }"
:disabled="isAlreadyImported" :disabled="!isAvailableForImport"
:value="importTarget.new_name" :value="importTarget.new_name"
@input="$emit('update-new-name', $event)" @input="$emit('update-new-name', $event)"
/> />
......
...@@ -5,10 +5,13 @@ import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; ...@@ -5,10 +5,13 @@ import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { STATUSES } from '../../constants'; import { STATUSES } from '../../constants';
import { i18n, NEW_NAME_FIELD } from '../constants'; import { i18n, NEW_NAME_FIELD } from '../constants';
import { isAvailableForImport } from '../utils';
import bulkImportSourceGroupItemFragment from './fragments/bulk_import_source_group_item.fragment.graphql'; import bulkImportSourceGroupItemFragment from './fragments/bulk_import_source_group_item.fragment.graphql';
import bulkImportSourceGroupProgressFragment from './fragments/bulk_import_source_group_progress.fragment.graphql';
import addValidationErrorMutation from './mutations/add_validation_error.mutation.graphql'; import addValidationErrorMutation from './mutations/add_validation_error.mutation.graphql';
import removeValidationErrorMutation from './mutations/remove_validation_error.mutation.graphql'; import removeValidationErrorMutation from './mutations/remove_validation_error.mutation.graphql';
import setImportProgressMutation from './mutations/set_import_progress.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 updateImportStatusMutation from './mutations/update_import_status.mutation.graphql';
import availableNamespacesQuery from './queries/available_namespaces.query.graphql'; import availableNamespacesQuery from './queries/available_namespaces.query.graphql';
import bulkImportSourceGroupQuery from './queries/bulk_import_source_group.query.graphql'; import bulkImportSourceGroupQuery from './queries/bulk_import_source_group.query.graphql';
...@@ -34,6 +37,7 @@ function makeGroup(data) { ...@@ -34,6 +37,7 @@ function makeGroup(data) {
}; };
const NESTED_OBJECT_FIELDS = { const NESTED_OBJECT_FIELDS = {
import_target: clientTypenames.BulkImportTarget, import_target: clientTypenames.BulkImportTarget,
last_import_target: clientTypenames.BulkImportTarget,
progress: clientTypenames.BulkImportProgress, progress: clientTypenames.BulkImportProgress,
}; };
...@@ -55,6 +59,7 @@ async function checkImportTargetIsValid({ client, newName, targetNamespace, sour ...@@ -55,6 +59,7 @@ async function checkImportTargetIsValid({ client, newName, targetNamespace, sour
data: { existingGroup, existingProject }, data: { existingGroup, existingProject },
} = await client.query({ } = await client.query({
query: groupAndProjectQuery, query: groupAndProjectQuery,
fetchPolicy: 'no-cache',
variables: { variables: {
fullPath: `${targetNamespace}/${newName}`, fullPath: `${targetNamespace}/${newName}`,
}, },
...@@ -82,6 +87,7 @@ async function checkImportTargetIsValid({ client, newName, targetNamespace, sour ...@@ -82,6 +87,7 @@ async function checkImportTargetIsValid({ client, newName, targetNamespace, sour
} }
const localProgressId = (id) => `not-started-${id}`; const localProgressId = (id) => `not-started-${id}`;
const nextName = (name) => `${name}-1`;
export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) { export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) {
const groupsManager = new GroupsManager({ const groupsManager = new GroupsManager({
...@@ -140,17 +146,28 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ...@@ -140,17 +146,28 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
const { jobId, importState: cachedImportState } = const { jobId, importState: cachedImportState } =
groupsManager.getImportStateFromStorageByGroupId(group.id) ?? {}; 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({ return makeGroup({
...group, ...group,
validation_errors: [], validation_errors: [],
progress: { progress: {
id: jobId ?? localProgressId(group.id), id: jobId ?? localProgressId(group.id),
status: cachedImportState?.status ?? STATUSES.NONE, status,
},
import_target: cachedImportState?.importTarget ?? {
new_name: group.full_path,
target_namespace: availableNamespaces[0]?.full_path ?? '',
}, },
import_target: importTarget,
last_import_target: cachedImportState?.importTarget ?? null,
}); });
}), }),
pageInfo: { pageInfo: {
...@@ -161,7 +178,7 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ...@@ -161,7 +178,7 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
setTimeout(() => { setTimeout(() => {
response.nodes.forEach((group) => { response.nodes.forEach((group) => {
if (group.progress.status === STATUSES.NONE) { if (isAvailableForImport(group)) {
checkImportTargetIsValid({ checkImportTargetIsValid({
client, client,
newName: group.import_target.new_name, newName: group.import_target.new_name,
...@@ -193,32 +210,18 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ...@@ -193,32 +210,18 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
targetNamespace, targetNamespace,
newName, newName,
}); });
return makeGroup({ return makeGroup({
id: sourceGroupId, id: sourceGroupId,
import_target: { import_target: {
target_namespace: targetNamespace, target_namespace: targetNamespace,
new_name: newName, new_name: newName,
},
});
},
setTargetNamespace: (_, { targetNamespace, sourceGroupId }) =>
makeGroup({
id: sourceGroupId, id: sourceGroupId,
import_target: {
target_namespace: targetNamespace,
}, },
}), });
setNewName: (_, { newName, sourceGroupId }) =>
makeGroup({
id: sourceGroupId,
import_target: {
new_name: newName,
}, },
}),
async setImportProgress(_, { sourceGroupId, status, jobId }) { async setImportProgress(_, { sourceGroupId, status, jobId, importTarget }) {
if (jobId) { if (jobId) {
groupsManager.updateImportProgress(jobId, status); groupsManager.updateImportProgress(jobId, status);
} }
...@@ -229,16 +232,46 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ...@@ -229,16 +232,46 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
id: jobId ?? localProgressId(sourceGroupId), id: jobId ?? localProgressId(sourceGroupId),
status, status,
}, },
last_import_target: {
__typename: clientTypenames.BulkImportTarget,
...importTarget,
},
}); });
}, },
async updateImportStatus(_, { id, status }) { async updateImportStatus(_, { id, status: newStatus }, { client, getCacheKey }) {
groupsManager.updateImportProgress(id, status); groupsManager.updateImportProgress(id, newStatus);
const progressItem = client.readFragment({
fragment: bulkImportSourceGroupProgressFragment,
fragmentName: 'BulkImportSourceGroupProgress',
id: getCacheKey({
__typename: clientTypenames.BulkImportProgress,
id,
}),
});
const isInProgress = Boolean(progressItem);
const { status: currentStatus } = progressItem ?? {};
if (newStatus === STATUSES.FINISHED && isInProgress && currentStatus !== newStatus) {
const groups = groupsManager.getImportedGroupsByJobId(id);
groups.forEach(async ({ id: groupId, importTarget }) => {
client.mutate({
mutation: setImportTargetMutation,
variables: {
sourceGroupId: groupId,
targetNamespace: importTarget.target_namespace,
newName: nextName(importTarget.new_name),
},
});
});
}
return { return {
__typename: clientTypenames.BulkImportProgress, __typename: clientTypenames.BulkImportProgress,
id, id,
status, status: newStatus,
}; };
}, },
...@@ -327,10 +360,10 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ...@@ -327,10 +360,10 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
return { status: STATUSES.NONE }; return { status: STATUSES.NONE };
}) })
.then((newStatus) => .then((newStatus) =>
sourceGroupIds.forEach((sourceGroupId) => sourceGroupIds.forEach((sourceGroupId, idx) =>
client.mutate({ client.mutate({
mutation: setImportProgressMutation, mutation: setImportProgressMutation,
variables: { sourceGroupId, ...newStatus }, variables: { sourceGroupId, ...newStatus, importTarget: groups[idx].import_target },
}), }),
), ),
) )
......
...@@ -12,6 +12,10 @@ fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup { ...@@ -12,6 +12,10 @@ fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup {
target_namespace target_namespace
new_name new_name
} }
last_import_target {
target_namespace
new_name
}
validation_errors { validation_errors {
field field
message message
......
mutation setImportProgress($status: String!, $sourceGroupId: String!, $jobId: String) { mutation setImportProgress(
setImportProgress(status: $status, sourceGroupId: $sourceGroupId, jobId: $jobId) @client { $status: String!
$sourceGroupId: String!
$jobId: String
$importTarget: ImportTargetInput!
) {
setImportProgress(
status: $status
sourceGroupId: $sourceGroupId
jobId: $jobId
importTarget: $importTarget
) @client {
id id
progress { progress {
id id
status status
} }
last_import_target {
target_namespace
new_name
}
} }
} }
...@@ -35,15 +35,18 @@ export class SourceGroupsManager { ...@@ -35,15 +35,18 @@ export class SourceGroupsManager {
} }
createImportState(importId, jobConfig) { createImportState(importId, jobConfig) {
this.importStates[this.getStorageKey(importId)] = { this.importStates[importId] = {
status: jobConfig.status, status: jobConfig.status,
groups: jobConfig.groups.map((g) => ({ importTarget: g.import_target, id: g.id })), groups: jobConfig.groups.map((g) => ({
importTarget: { ...g.import_target },
id: g.id,
})),
}; };
this.saveImportStatesToStorage(); this.saveImportStatesToStorage();
} }
updateImportProgress(importId, status) { updateImportProgress(importId, status) {
const currentState = this.importStates[this.getStorageKey(importId)]; const currentState = this.importStates[importId];
if (!currentState) { if (!currentState) {
return; return;
} }
...@@ -52,12 +55,15 @@ export class SourceGroupsManager { ...@@ -52,12 +55,15 @@ export class SourceGroupsManager {
this.saveImportStatesToStorage(); this.saveImportStatesToStorage();
} }
getImportedGroupsByJobId(jobId) {
return this.importStates[jobId]?.groups ?? [];
}
getImportStateFromStorageByGroupId(groupId) { getImportStateFromStorageByGroupId(groupId) {
const PREFIX = this.getStorageKey('');
const [jobId, importState] = const [jobId, importState] =
Object.entries(this.importStates).find( Object.entries(this.importStates)
([key, state]) => key.startsWith(PREFIX) && state.groups.some((g) => g.id === groupId), .reverse()
) ?? []; .find(([, state]) => state.groups.some((g) => g.id === groupId)) ?? [];
if (!jobId) { if (!jobId) {
return null; return null;
...@@ -67,10 +73,6 @@ export class SourceGroupsManager { ...@@ -67,10 +73,6 @@ export class SourceGroupsManager {
return { jobId, importState: { ...group, status: importState.status } }; return { jobId, importState: { ...group, status: importState.status } };
} }
getStorageKey(importId) {
return `${this.sourceUrl}|${importId}`;
}
saveImportStatesToStorage = debounce(() => { saveImportStatesToStorage = debounce(() => {
try { try {
// storage might be changed in other tab so fetch first // storage might be changed in other tab so fetch first
......
...@@ -30,6 +30,7 @@ type ClientBulkImportSourceGroup { ...@@ -30,6 +30,7 @@ type ClientBulkImportSourceGroup {
full_name: String! full_name: String!
progress: ClientBulkImportProgress! progress: ClientBulkImportProgress!
import_target: ClientBulkImportTarget! import_target: ClientBulkImportTarget!
last_import_target: ClientBulkImportTarget
validation_errors: [ClientBulkImportValidationError!]! validation_errors: [ClientBulkImportValidationError!]!
} }
...@@ -50,11 +51,21 @@ extend type Query { ...@@ -50,11 +51,21 @@ extend type Query {
availableNamespaces: [ClientBulkImportAvailableNamespace!]! availableNamespaces: [ClientBulkImportAvailableNamespace!]!
} }
input InputTargetInput {
target_namespace: String!
new_name: String!
}
extend type Mutation { extend type Mutation {
setNewName(newName: String, sourceGroupId: ID!): ClientBulkImportSourceGroup! setNewName(newName: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientBulkImportSourceGroup! setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
importGroups(sourceGroupIds: [ID!]!): [ClientBulkImportSourceGroup!]! importGroups(sourceGroupIds: [ID!]!): [ClientBulkImportSourceGroup!]!
setImportProgress(id: ID, status: String!): ClientBulkImportSourceGroup! setImportProgress(
id: ID
status: String!
jobId: String
importTarget: ImportTargetInput!
): ClientBulkImportSourceGroup!
updateImportProgress(id: ID, status: String!): ClientBulkImportProgress updateImportProgress(id: ID, status: String!): ClientBulkImportProgress
addValidationError( addValidationError(
sourceGroupId: ID! sourceGroupId: ID!
......
import { STATUSES } from '../constants';
import { NEW_NAME_FIELD } from './constants'; import { NEW_NAME_FIELD } from './constants';
export function isNameValid(group, validationRegex) { export function isNameValid(group, validationRegex) {
...@@ -11,3 +12,11 @@ export function getInvalidNameValidationMessage(group) { ...@@ -11,3 +12,11 @@ export function getInvalidNameValidationMessage(group) {
export function isInvalid(group, validationRegex) { export function isInvalid(group, validationRegex) {
return Boolean(!isNameValid(group, validationRegex) || getInvalidNameValidationMessage(group)); return Boolean(!isNameValid(group, validationRegex) || getInvalidNameValidationMessage(group));
} }
export function isFinished(group) {
return group.progress.status === STATUSES.FINISHED;
}
export function isAvailableForImport(group) {
return [STATUSES.NONE, STATUSES.FINISHED].some((status) => group.progress.status === status);
}
...@@ -5830,6 +5830,9 @@ msgstr "" ...@@ -5830,6 +5830,9 @@ msgstr ""
msgid "Bulk update" msgid "Bulk update"
msgstr "" msgstr ""
msgid "BulkImports|Re-import creates a new group. It does not sync with the existing group."
msgstr ""
msgid "BulkImport|Existing groups" msgid "BulkImport|Existing groups"
msgstr "" msgstr ""
...@@ -5851,6 +5854,9 @@ msgstr "" ...@@ -5851,6 +5854,9 @@ msgstr ""
msgid "BulkImport|Importing the group failed" msgid "BulkImport|Importing the group failed"
msgstr "" msgstr ""
msgid "BulkImport|Last imported to %{link}"
msgstr ""
msgid "BulkImport|Name already exists." msgid "BulkImport|Name already exists."
msgstr "" msgstr ""
...@@ -27568,6 +27574,9 @@ msgstr "" ...@@ -27568,6 +27574,9 @@ msgstr ""
msgid "Re-authentication required" msgid "Re-authentication required"
msgstr "" msgstr ""
msgid "Re-import"
msgstr ""
msgid "Re-request review" msgid "Re-request review"
msgstr "" msgstr ""
......
...@@ -7,7 +7,6 @@ module QA ...@@ -7,7 +7,6 @@ module QA
view "app/assets/javascripts/import_entities/import_groups/components/import_table.vue" do view "app/assets/javascripts/import_entities/import_groups/components/import_table.vue" do
element :import_table element :import_table
element :import_item element :import_item
element :import_group_button
element :import_status_indicator element :import_status_indicator
end end
...@@ -19,6 +18,10 @@ module QA ...@@ -19,6 +18,10 @@ module QA
element :target_namespace_selector_dropdown element :target_namespace_selector_dropdown
end end
view "app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue" do
element :import_group_button
end
# Import source group in to target group # Import source group in to target group
# #
# @param [String] source_group_name # @param [String] source_group_name
......
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;
const createComponent = (props) => {
wrapper = shallowMount(ImportActionsCell, {
propsData: {
groupPathRegex: /^[a-zA-Z]+$/,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when import status is NONE', () => {
beforeEach(() => {
const group = generateFakeEntry({ id: 1, status: STATUSES.NONE });
createComponent({ group });
});
it('renders import button', () => {
const button = wrapper.findComponent(GlButton);
expect(button.exists()).toBe(true);
expect(button.text()).toBe('Import');
});
it('does not render icon with a hint', () => {
expect(wrapper.findComponent(GlIcon).exists()).toBe(false);
});
});
describe('when import status is FINISHED', () => {
beforeEach(() => {
const group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED });
createComponent({ group });
});
it('renders re-import button', () => {
const button = wrapper.findComponent(GlButton);
expect(button.exists()).toBe(true);
expect(button.text()).toBe('Re-import');
});
it('renders icon with a hint', () => {
const icon = wrapper.findComponent(GlIcon);
expect(icon.exists()).toBe(true);
expect(icon.attributes().title).toBe(
'Re-import creates a new group. It does not sync with the existing group.',
);
});
});
it('does not render import button when group import is in progress', () => {
const group = generateFakeEntry({ id: 1, status: STATUSES.STARTED });
createComponent({ group });
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 });
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 });
const button = wrapper.findComponent(GlButton);
button.vm.$emit('click');
expect(wrapper.emitted('import-group')).toHaveLength(1);
});
});
import { GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { STATUSES } from '~/import_entities/constants';
import ImportSourceCell from '~/import_entities/import_groups/components/import_source_cell.vue';
import { generateFakeEntry } from '../graphql/fixtures';
describe('import source cell', () => {
let wrapper;
let group;
const createComponent = (props) => {
wrapper = shallowMount(ImportSourceCell, {
propsData: {
...props,
},
stubs: { GlSprintf },
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when group status is NONE', () => {
beforeEach(() => {
group = generateFakeEntry({ 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);
});
it('does not render last imported line', () => {
expect(wrapper.text()).not.toContain('Last imported to');
});
});
describe('when group status is FINISHED', () => {
beforeEach(() => {
group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED });
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);
});
it('renders last imported line', () => {
expect(wrapper.text()).toMatchInterpolatedText(
'fake_group_1 Last imported to root/last-group1',
);
});
});
});
...@@ -15,6 +15,7 @@ import stubChildren from 'helpers/stub_children'; ...@@ -15,6 +15,7 @@ import stubChildren from 'helpers/stub_children';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { STATUSES } from '~/import_entities/constants'; import { STATUSES } from '~/import_entities/constants';
import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue';
import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue';
import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue'; import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue';
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
...@@ -163,11 +164,8 @@ describe('import table', () => { ...@@ -163,11 +164,8 @@ describe('import table', () => {
it('invokes importGroups mutation when row button is clicked', async () => { it('invokes importGroups mutation when row button is clicked', async () => {
jest.spyOn(apolloProvider.defaultClient, 'mutate'); jest.spyOn(apolloProvider.defaultClient, 'mutate');
const triggerImportButton = wrapper
.findAllComponents(GlButton)
.wrappers.find((w) => w.text() === 'Import');
triggerImportButton.vm.$emit('click'); wrapper.findComponent(ImportActionsCell).vm.$emit('import-group');
await waitForPromises(); await waitForPromises();
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation: importGroupsMutation, mutation: importGroupsMutation,
...@@ -329,7 +327,7 @@ describe('import table', () => { ...@@ -329,7 +327,7 @@ describe('import table', () => {
}); });
it('does not allow selecting already started groups', async () => { it('does not allow selecting already started groups', async () => {
const NEW_GROUPS = [generateFakeEntry({ id: 1, status: STATUSES.FINISHED })]; const NEW_GROUPS = [generateFakeEntry({ id: 1, status: STATUSES.STARTED })];
createComponent({ createComponent({
bulkImportSourceGroups: () => ({ bulkImportSourceGroups: () => ({
......
import { GlButton, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui'; import { GlDropdownItem, GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
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 { availableNamespacesFixture } from '../graphql/fixtures';
Vue.use(VueApollo);
const getFakeGroup = (status) => ({ const getFakeGroup = (status) => ({
web_url: 'https://fake.host/', web_url: 'https://fake.host/',
full_path: 'fake_group_1', full_path: 'fake_group_1',
...@@ -26,9 +22,6 @@ describe('import target cell', () => { ...@@ -26,9 +22,6 @@ describe('import target cell', () => {
let wrapper; let wrapper;
let group; let group;
const findByText = (cmp, text) => {
return wrapper.findAll(cmp).wrappers.find((node) => node.text().indexOf(text) === 0);
};
const findNameInput = () => wrapper.find(GlFormInput); const findNameInput = () => wrapper.find(GlFormInput);
const findNamespaceDropdown = () => wrapper.find(ImportGroupDropdown); const findNamespaceDropdown = () => wrapper.find(ImportGroupDropdown);
...@@ -117,10 +110,6 @@ describe('import target cell', () => { ...@@ -117,10 +110,6 @@ describe('import target cell', () => {
createComponent({ group }); createComponent({ group });
}); });
it('does not render Import button', () => {
expect(findByText(GlButton, 'Import')).toBe(undefined);
});
it('renders namespace dropdown as disabled', () => { it('renders namespace dropdown as disabled', () => {
expect(findNamespaceDropdown().attributes('disabled')).toBe('true'); expect(findNamespaceDropdown().attributes('disabled')).toBe('true');
}); });
...@@ -132,17 +121,8 @@ describe('import target cell', () => { ...@@ -132,17 +121,8 @@ describe('import target cell', () => {
createComponent({ group }); createComponent({ group });
}); });
it('does not render Import button', () => { it('renders namespace dropdown as enabled', () => {
expect(findByText(GlButton, 'Import')).toBe(undefined); expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined);
});
it('does not render namespace dropdown', () => {
expect(findNamespaceDropdown().exists()).toBe(false);
});
it('renders target as link', () => {
const TARGET_LINK = `${group.import_target.target_namespace}/${group.import_target.new_name}`;
expect(findByText(GlLink, TARGET_LINK).exists()).toBe(true);
}); });
}); });
...@@ -179,9 +159,6 @@ describe('import target cell', () => { ...@@ -179,9 +159,6 @@ describe('import target cell', () => {
}, },
}); });
jest.runOnlyPendingTimers();
await nextTick();
expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE); expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE);
}); });
}); });
......
...@@ -259,6 +259,10 @@ describe('Bulk import resolvers', () => { ...@@ -259,6 +259,10 @@ describe('Bulk import resolvers', () => {
target_namespace: 'root', target_namespace: 'root',
new_name: 'group1', new_name: 'group1',
}, },
last_import_target: {
target_namespace: 'root',
new_name: 'group1',
},
validation_errors: [], validation_errors: [],
}, },
], ],
...@@ -414,19 +418,32 @@ describe('Bulk import resolvers', () => { ...@@ -414,19 +418,32 @@ describe('Bulk import resolvers', () => {
}); });
}); });
it('setImportProgress updates group progress', async () => { it('setImportProgress updates group progress and sets import target', async () => {
const NEW_STATUS = 'dummy'; const NEW_STATUS = 'dummy';
const FAKE_JOB_ID = 5; const FAKE_JOB_ID = 5;
const IMPORT_TARGET = {
__typename: 'ClientBulkImportTarget',
new_name: 'fake_name',
target_namespace: 'fake_target',
};
const { const {
data: { data: {
setImportProgress: { progress }, setImportProgress: { progress, last_import_target: lastImportTarget },
}, },
} = await client.mutate({ } = await client.mutate({
mutation: setImportProgressMutation, mutation: setImportProgressMutation,
variables: { sourceGroupId: GROUP_ID, status: NEW_STATUS, jobId: FAKE_JOB_ID }, variables: {
sourceGroupId: GROUP_ID,
status: NEW_STATUS,
jobId: FAKE_JOB_ID,
importTarget: IMPORT_TARGET,
},
}); });
expect(progress).toMatchObject({ expect(lastImportTarget).toStrictEqual(IMPORT_TARGET);
expect(progress).toStrictEqual({
__typename: clientTypenames.BulkImportProgress,
id: FAKE_JOB_ID, id: FAKE_JOB_ID,
status: NEW_STATUS, status: NEW_STATUS,
}); });
...@@ -442,7 +459,8 @@ describe('Bulk import resolvers', () => { ...@@ -442,7 +459,8 @@ describe('Bulk import resolvers', () => {
variables: { id: FAKE_JOB_ID, status: NEW_STATUS }, variables: { id: FAKE_JOB_ID, status: NEW_STATUS },
}); });
expect(statusInResponse).toMatchObject({ expect(statusInResponse).toStrictEqual({
__typename: clientTypenames.BulkImportProgress,
id: FAKE_JOB_ID, id: FAKE_JOB_ID,
status: NEW_STATUS, status: NEW_STATUS,
}); });
...@@ -460,7 +478,13 @@ describe('Bulk import resolvers', () => { ...@@ -460,7 +478,13 @@ describe('Bulk import resolvers', () => {
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE }, variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE },
}); });
expect(validationErrors).toMatchObject([{ 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 () => { it('removeValidationError removes error from group', async () => {
...@@ -481,7 +505,7 @@ describe('Bulk import resolvers', () => { ...@@ -481,7 +505,7 @@ describe('Bulk import resolvers', () => {
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD }, variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD },
}); });
expect(validationErrors).toMatchObject([]); expect(validationErrors).toStrictEqual([]);
}); });
}); });
}); });
...@@ -9,6 +9,10 @@ export const generateFakeEntry = ({ id, status, ...rest }) => ({ ...@@ -9,6 +9,10 @@ export const generateFakeEntry = ({ id, status, ...rest }) => ({
target_namespace: 'root', target_namespace: 'root',
new_name: `group${id}`, new_name: `group${id}`,
}, },
last_import_target: {
target_namespace: 'root',
new_name: `last-group${id}`,
},
id, id,
progress: { progress: {
id: `test-${id}`, id: `test-${id}`,
......
...@@ -20,7 +20,7 @@ describe('SourceGroupsManager', () => { ...@@ -20,7 +20,7 @@ describe('SourceGroupsManager', () => {
describe('storage management', () => { describe('storage management', () => {
const IMPORT_ID = 1; const IMPORT_ID = 1;
const IMPORT_TARGET = { destination_name: 'demo', destination_namespace: 'foo' }; const IMPORT_TARGET = { new_name: 'demo', target_namespace: 'foo' };
const STATUS = 'FAKE_STATUS'; const STATUS = 'FAKE_STATUS';
const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS }; const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS };
......
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