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