Commit 6a31a37b authored by Illya Klymov's avatar Illya Klymov

Implement bulk import for all groups on the page

- add Import all button

Changelog: added
parent e0c1d02d
<script> <script>
import { import {
GlButton,
GlEmptyState, GlEmptyState,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
...@@ -8,10 +9,13 @@ import { ...@@ -8,10 +9,13 @@ import {
GlLoadingIcon, GlLoadingIcon,
GlSearchBoxByClick, GlSearchBoxByClick,
GlSprintf, GlSprintf,
GlSafeHtmlDirective as SafeHtml,
GlTooltip,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__, __ } 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 importGroupMutation from '../graphql/mutations/import_group.mutation.graphql'; import { STATUSES } from '../../constants';
import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql';
import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql'; import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql';
import setTargetNamespaceMutation from '../graphql/mutations/set_target_namespace.mutation.graphql'; import setTargetNamespaceMutation from '../graphql/mutations/set_target_namespace.mutation.graphql';
import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql';
...@@ -23,6 +27,7 @@ const DEFAULT_PAGE_SIZE = PAGE_SIZES[0]; ...@@ -23,6 +27,7 @@ const DEFAULT_PAGE_SIZE = PAGE_SIZES[0];
export default { export default {
components: { components: {
GlButton,
GlEmptyState, GlEmptyState,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
...@@ -31,9 +36,13 @@ export default { ...@@ -31,9 +36,13 @@ export default {
GlLoadingIcon, GlLoadingIcon,
GlSearchBoxByClick, GlSearchBoxByClick,
GlSprintf, GlSprintf,
GlTooltip,
ImportTableRow, ImportTableRow,
PaginationLinks, PaginationLinks,
}, },
directives: {
SafeHtml,
},
props: { props: {
sourceUrl: { sourceUrl: {
...@@ -65,12 +74,28 @@ export default { ...@@ -65,12 +74,28 @@ export default {
}, },
computed: { computed: {
groups() {
return this.bulkImportSourceGroups?.nodes ?? [];
},
hasGroupsWithValidationError() {
return this.groups.some((g) => g.validation_errors.length);
},
availableGroupsForImport() {
return this.groups.filter((g) => g.progress.status === STATUSES.NONE);
},
isImportAllButtonDisabled() {
return this.hasGroupsWithValidationError || this.availableGroupsForImport.length === 0;
},
humanizedTotal() { humanizedTotal() {
return this.paginationInfo.total >= 1000 ? __('1000+') : this.paginationInfo.total; return this.paginationInfo.total >= 1000 ? __('1000+') : this.paginationInfo.total;
}, },
hasGroups() { hasGroups() {
return this.bulkImportSourceGroups?.nodes?.length > 0; return this.groups.length > 0;
}, },
hasEmptyFilter() { hasEmptyFilter() {
...@@ -105,6 +130,10 @@ export default { ...@@ -105,6 +130,10 @@ export default {
}, },
methods: { methods: {
groupsCount(count) {
return n__('%d group', '%d groups', count);
},
setPage(page) { setPage(page) {
this.page = page; this.page = page;
}, },
...@@ -123,24 +152,57 @@ export default { ...@@ -123,24 +152,57 @@ export default {
}); });
}, },
importGroup(sourceGroupId) { importGroups(sourceGroupIds) {
this.$apollo.mutate({ this.$apollo.mutate({
mutation: importGroupMutation, mutation: importGroupsMutation,
variables: { sourceGroupId }, variables: { sourceGroupIds },
}); });
}, },
importAllGroups() {
this.importGroups(this.availableGroupsForImport.map((g) => g.id));
},
setPageSize(size) { setPageSize(size) {
this.perPage = size; this.perPage = size;
}, },
}, },
gitlabLogo: window.gon.gitlab_logo,
PAGE_SIZES, PAGE_SIZES,
}; };
</script> </script>
<template> <template>
<div> <div>
<h1
class="gl-my-0 gl-py-4 gl-font-size-h1 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex"
>
<img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" />
{{ s__('BulkImport|Import groups from GitLab') }}
<div ref="importAllButtonWrapper" class="gl-ml-auto">
<gl-button
v-if="!$apollo.loading && hasGroups"
:disabled="isImportAllButtonDisabled"
variant="confirm"
@click="importAllGroups"
>
<gl-sprintf :message="s__('BulkImport|Import %{groups}')">
<template #groups>
{{ groupsCount(availableGroupsForImport.length) }}
</template>
</gl-sprintf>
</gl-button>
</div>
<gl-tooltip v-if="isImportAllButtonDisabled" :target="() => $refs.importAllButtonWrapper">
<template v-if="hasGroupsWithValidationError">
{{ s__('BulkImport|One or more groups has validation errors') }}
</template>
<template v-else>
{{ s__('BulkImport|No groups on this page are available for import') }}
</template>
</gl-tooltip>
</h1>
<div <div
class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex"
> >
...@@ -153,7 +215,7 @@ export default { ...@@ -153,7 +215,7 @@ export default {
<strong>{{ paginationInfo.end }}</strong> <strong>{{ paginationInfo.end }}</strong>
</template> </template>
<template #total> <template #total>
<strong>{{ n__('%d group', '%d groups', paginationInfo.total) }}</strong> <strong>{{ groupsCount(paginationInfo.total) }}</strong>
</template> </template>
<template #filter> <template #filter>
<strong>{{ filter }}</strong> <strong>{{ filter }}</strong>
...@@ -196,7 +258,7 @@ export default { ...@@ -196,7 +258,7 @@ export default {
:group-path-regex="groupPathRegex" :group-path-regex="groupPathRegex"
@update-target-namespace="updateTargetNamespace(group.id, $event)" @update-target-namespace="updateTargetNamespace(group.id, $event)"
@update-new-name="updateNewName(group.id, $event)" @update-new-name="updateNewName(group.id, $event)"
@import-group="importGroup(group.id)" @import-group="importGroups([group.id])"
/> />
</template> </template>
</tbody> </tbody>
......
...@@ -10,8 +10,11 @@ import { ...@@ -10,8 +10,11 @@ import {
GlFormInput, GlFormInput,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility'; import { joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import ImportStatus from '../../components/import_status.vue'; import ImportStatus from '../../components/import_status.vue';
import { STATUSES } from '../../constants'; import { STATUSES } from '../../constants';
import addValidationErrorMutation from '../graphql/mutations/add_validation_error.mutation.graphql';
import removeValidationErrorMutation from '../graphql/mutations/remove_validation_error.mutation.graphql';
import groupQuery from '../graphql/queries/group.query.graphql'; import groupQuery from '../graphql/queries/group.query.graphql';
const DEBOUNCE_INTERVAL = 300; const DEBOUNCE_INTERVAL = 300;
...@@ -52,6 +55,27 @@ export default { ...@@ -52,6 +55,27 @@ export default {
fullPath: this.fullPath, fullPath: this.fullPath,
}; };
}, },
update({ existingGroup }) {
const variables = {
field: 'new_name',
sourceGroupId: this.group.id,
};
if (!existingGroup) {
this.$apollo.mutate({
mutation: removeValidationErrorMutation,
variables,
});
} else {
this.$apollo.mutate({
mutation: addValidationErrorMutation,
variables: {
...variables,
message: s__('BulkImport|Name already exists.'),
},
});
}
},
skip() { skip() {
return !this.isNameValid || this.isAlreadyImported; return !this.isNameValid || this.isAlreadyImported;
}, },
...@@ -63,8 +87,12 @@ export default { ...@@ -63,8 +87,12 @@ export default {
return this.group.import_target; return this.group.import_target;
}, },
invalidNameValidationMessage() {
return this.group.validation_errors.find(({ field }) => field === 'new_name')?.message;
},
isInvalid() { isInvalid() {
return Boolean(!this.isNameValid || this.existingGroup); return Boolean(!this.isNameValid || this.invalidNameValidationMessage);
}, },
isNameValid() { isNameValid() {
...@@ -157,21 +185,21 @@ export default { ...@@ -157,21 +185,21 @@ export default {
<template v-if="!isNameValid"> <template v-if="!isNameValid">
{{ __('Please choose a group URL with no special characters.') }} {{ __('Please choose a group URL with no special characters.') }}
</template> </template>
<template v-else-if="existingGroup"> <template v-else-if="invalidNameValidationMessage">
{{ s__('BulkImport|Name already exists.') }} {{ invalidNameValidationMessage }}
</template> </template>
</p> </p>
</div> </div>
</div> </div>
</td> </td>
<td class="gl-p-4 gl-white-space-nowrap"> <td class="gl-p-4 gl-white-space-nowrap">
<import-status :status="group.progress.status" /> <import-status :status="group.progress.status" class="gl-mt-2" />
</td> </td>
<td class="gl-p-4"> <td class="gl-p-4">
<gl-button <gl-button
v-if="!isAlreadyImported" v-if="!isAlreadyImported"
:disabled="isInvalid" :disabled="isInvalid"
variant="success" variant="confirm"
category="secondary" category="secondary"
@click="$emit('import-group')" @click="$emit('import-group')"
>{{ __('Import') }}</gl-button >{{ __('Import') }}</gl-button
......
...@@ -20,6 +20,7 @@ export const clientTypenames = { ...@@ -20,6 +20,7 @@ export const clientTypenames = {
BulkImportPageInfo: 'ClientBulkImportPageInfo', BulkImportPageInfo: 'ClientBulkImportPageInfo',
BulkImportTarget: 'ClientBulkImportTarget', BulkImportTarget: 'ClientBulkImportTarget',
BulkImportProgress: 'ClientBulkImportProgress', BulkImportProgress: 'ClientBulkImportProgress',
BulkImportValidationError: 'ClientBulkImportValidationError',
}; };
function makeGroup(data) { function makeGroup(data) {
...@@ -106,6 +107,7 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ...@@ -106,6 +107,7 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
return makeGroup({ return makeGroup({
...group, ...group,
validation_errors: [],
progress: { progress: {
id: jobId ?? localProgressId(group.id), id: jobId ?? localProgressId(group.id),
status: cachedImportState?.status ?? STATUSES.NONE, status: cachedImportState?.status ?? STATUSES.NONE,
...@@ -152,7 +154,7 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ...@@ -152,7 +154,7 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
async setImportProgress(_, { sourceGroupId, status, jobId }) { async setImportProgress(_, { sourceGroupId, status, jobId }) {
if (jobId) { if (jobId) {
groupsManager.saveImportState(jobId, { status }); groupsManager.updateImportProgress(jobId, status);
} }
return makeGroup({ return makeGroup({
...@@ -165,7 +167,7 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ...@@ -165,7 +167,7 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
}, },
async updateImportStatus(_, { id, status }) { async updateImportStatus(_, { id, status }) {
groupsManager.saveImportState(id, { status }); groupsManager.updateImportProgress(id, status);
return { return {
__typename: clientTypenames.BulkImportProgress, __typename: clientTypenames.BulkImportProgress,
...@@ -174,39 +176,81 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ...@@ -174,39 +176,81 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
}; };
}, },
async importGroup(_, { sourceGroupId }, { client }) { async addValidationError(_, { sourceGroupId, field, message }, { client }) {
const { const {
data: { bulkImportSourceGroup: group }, data: {
bulkImportSourceGroup: { validation_errors: validationErrors, ...group },
},
} = await client.query({
query: bulkImportSourceGroupQuery,
variables: { id: sourceGroupId },
});
return {
...group,
validation_errors: [
...validationErrors.filter(({ field: f }) => f !== field),
{
__typename: clientTypenames.BulkImportValidationError,
field,
message,
},
],
};
},
async removeValidationError(_, { sourceGroupId, field }, { client }) {
const {
data: {
bulkImportSourceGroup: { validation_errors: validationErrors, ...group },
},
} = await client.query({ } = await client.query({
query: bulkImportSourceGroupQuery, query: bulkImportSourceGroupQuery,
variables: { id: sourceGroupId }, variables: { id: sourceGroupId },
}); });
const GROUP_BEING_SCHEDULED = makeGroup({ return {
...group,
validation_errors: validationErrors.filter(({ field: f }) => f !== field),
};
},
async importGroups(_, { sourceGroupIds }, { client }) {
const groups = await Promise.all(
sourceGroupIds.map((id) =>
client
.query({
query: bulkImportSourceGroupQuery,
variables: { id },
})
.then(({ data }) => data.bulkImportSourceGroup),
),
);
const GROUPS_BEING_SCHEDULED = sourceGroupIds.map((sourceGroupId) =>
makeGroup({
id: sourceGroupId, id: sourceGroupId,
progress: { progress: {
id: localProgressId(sourceGroupId), id: localProgressId(sourceGroupId),
status: STATUSES.SCHEDULING, status: STATUSES.SCHEDULING,
}, },
}); }),
);
const defaultErrorMessage = s__('BulkImport|Importing the group failed'); const defaultErrorMessage = s__('BulkImport|Importing the group failed');
axios axios
.post(endpoints.createBulkImport, { .post(endpoints.createBulkImport, {
bulk_import: [ bulk_import: groups.map((group) => ({
{
source_type: 'group_entity', source_type: 'group_entity',
source_full_path: group.full_path, source_full_path: group.full_path,
destination_namespace: group.import_target.target_namespace, destination_namespace: group.import_target.target_namespace,
destination_name: group.import_target.new_name, destination_name: group.import_target.new_name,
}, })),
],
}) })
.then(({ data: { id: jobId } }) => { .then(({ data: { id: jobId } }) => {
groupsManager.saveImportState(jobId, { groupsManager.createImportState(jobId, {
id: group.id,
importTarget: group.import_target,
status: STATUSES.CREATED, status: STATUSES.CREATED,
groups,
}); });
return { status: STATUSES.CREATED, jobId }; return { status: STATUSES.CREATED, jobId };
...@@ -217,14 +261,16 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ...@@ -217,14 +261,16 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
return { status: STATUSES.NONE }; return { status: STATUSES.NONE };
}) })
.then((newStatus) => .then((newStatus) =>
sourceGroupIds.forEach((sourceGroupId) =>
client.mutate({ client.mutate({
mutation: setImportProgressMutation, mutation: setImportProgressMutation,
variables: { sourceGroupId, ...newStatus }, variables: { sourceGroupId, ...newStatus },
}), }),
),
) )
.catch(() => createFlash({ message: defaultErrorMessage })); .catch(() => createFlash({ message: defaultErrorMessage }));
return GROUP_BEING_SCHEDULED; return GROUPS_BEING_SCHEDULED;
}, },
}, },
}; };
......
...@@ -12,4 +12,8 @@ fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup { ...@@ -12,4 +12,8 @@ fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup {
target_namespace target_namespace
new_name 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 importGroup($sourceGroupId: String!) { mutation importGroups($sourceGroupIds: [String!]!) {
importGroup(sourceGroupId: $sourceGroupId) @client { importGroups(sourceGroupIds: $sourceGroupIds) @client {
id id
progress { progress {
id id
......
mutation removeValidationError($sourceGroupId: String!, $field: String!) {
removeValidationError(sourceGroupId: $sourceGroupId, field: $field) @client {
id
validation_errors {
field
message
}
}
}
...@@ -13,25 +13,42 @@ export class SourceGroupsManager { ...@@ -13,25 +13,42 @@ export class SourceGroupsManager {
loadImportStatesFromStorage() { loadImportStatesFromStorage() {
try { try {
return JSON.parse(this.storage.getItem(KEY)) ?? {}; 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 { } catch {
return {}; return {};
} }
} }
saveImportState(importId, group) { createImportState(importId, jobConfig) {
const key = this.getStorageKey(importId); this.importStates[this.getStorageKey(importId)] = {
const oldState = this.importStates[key] ?? {}; status: jobConfig.status,
groups: jobConfig.groups.map((g) => ({ importTarget: g.import_target, id: g.id })),
};
this.saveImportStatesToStorage();
}
if (!oldState.id && !group.id) { updateImportProgress(importId, status) {
const currentState = this.importStates[this.getStorageKey(importId)];
if (!currentState) {
return; return;
} }
this.importStates[key] = { currentState.status = status;
...oldState,
...group,
status: group.status,
};
this.saveImportStatesToStorage(); this.saveImportStatesToStorage();
} }
...@@ -39,10 +56,15 @@ export class SourceGroupsManager { ...@@ -39,10 +56,15 @@ export class SourceGroupsManager {
const PREFIX = this.getStorageKey(''); const PREFIX = this.getStorageKey('');
const [jobId, importState] = const [jobId, importState] =
Object.entries(this.importStates).find( Object.entries(this.importStates).find(
([key, group]) => key.startsWith(PREFIX) && group.id === groupId, ([key, state]) => key.startsWith(PREFIX) && state.groups.some((g) => g.id === groupId),
) ?? []; ) ?? [];
return { jobId, importState }; if (!jobId) {
return null;
}
const group = importState.groups.find((g) => g.id === groupId);
return { jobId, importState: { ...group, status: importState.status } };
} }
getStorageKey(importId) { getStorageKey(importId) {
......
...@@ -18,6 +18,11 @@ type ClientBulkImportProgress { ...@@ -18,6 +18,11 @@ type ClientBulkImportProgress {
status: String! status: String!
} }
type ClientBulkImportValidationError {
field: String!
message: String!
}
type ClientBulkImportSourceGroup { type ClientBulkImportSourceGroup {
id: ID! id: ID!
web_url: String! web_url: String!
...@@ -25,6 +30,7 @@ type ClientBulkImportSourceGroup { ...@@ -25,6 +30,7 @@ type ClientBulkImportSourceGroup {
full_name: String! full_name: String!
progress: ClientBulkImportProgress! progress: ClientBulkImportProgress!
import_target: ClientBulkImportTarget! import_target: ClientBulkImportTarget!
validation_errors: [ClientBulkImportValidationError!]!
} }
type ClientBulkImportPageInfo { type ClientBulkImportPageInfo {
...@@ -45,9 +51,15 @@ extend type Query { ...@@ -45,9 +51,15 @@ extend type Query {
} }
extend type Mutation { extend type Mutation {
setNewName(newName: String, sourceGroupId: ID!): ClientTargetNamespace! setNewName(newName: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientTargetNamespace! setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
importGroup(id: ID!): ClientBulkImportSourceGroup! importGroups(sourceGroupIds: [ID!]!): [ClientBulkImportSourceGroup!]!
setImportProgress(id: ID, status: String!): ClientBulkImportSourceGroup! setImportProgress(id: ID, status: String!): ClientBulkImportSourceGroup!
updateImportProgress(id: ID, status: String!): ClientBulkImportProgress updateImportProgress(id: ID, status: String!): ClientBulkImportProgress
addValidationError(
sourceGroupId: ID!
field: String!
message: String!
): ClientBulkImportSourceGroup!
removeValidationError(sourceGroupId: ID!, field: String!): ClientBulkImportSourceGroup!
} }
...@@ -2,9 +2,6 @@ ...@@ -2,9 +2,6 @@
- add_page_specific_style 'page_bundles/import' - add_page_specific_style 'page_bundles/import'
- breadcrumb_title _('Import groups') - breadcrumb_title _('Import groups')
%h1.gl-my-0.gl-py-4.gl-font-size-h1.gl-border-solid.gl-border-gray-200.gl-border-0.gl-border-b-1
= s_('BulkImport|Import groups from GitLab')
#import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json), #import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json),
available_namespaces_path: import_available_namespaces_path(format: :json), available_namespaces_path: import_available_namespaces_path(format: :json),
create_bulk_import_path: import_bulk_imports_path(format: :json), create_bulk_import_path: import_bulk_imports_path(format: :json),
......
---
title: Implement bulk import for all groups on the page
merge_request: 61097
author:
type: added
...@@ -5451,6 +5451,9 @@ msgstr "" ...@@ -5451,6 +5451,9 @@ msgstr ""
msgid "BulkImport|From source group" msgid "BulkImport|From source group"
msgstr "" msgstr ""
msgid "BulkImport|Import %{groups}"
msgstr ""
msgid "BulkImport|Import failed: Destination cannot be a subgroup of the source group. Change the destination and try again." msgid "BulkImport|Import failed: Destination cannot be a subgroup of the source group. Change the destination and try again."
msgstr "" msgstr ""
...@@ -5463,9 +5466,15 @@ msgstr "" ...@@ -5463,9 +5466,15 @@ msgstr ""
msgid "BulkImport|Name already exists." msgid "BulkImport|Name already exists."
msgstr "" msgstr ""
msgid "BulkImport|No groups on this page are available for import"
msgstr ""
msgid "BulkImport|No parent" msgid "BulkImport|No parent"
msgstr "" msgstr ""
msgid "BulkImport|One or more groups has validation errors"
msgstr ""
msgid "BulkImport|Showing %{start}-%{end} of %{total}" msgid "BulkImport|Showing %{start}-%{end} of %{total}"
msgstr "" msgstr ""
......
...@@ -19,6 +19,7 @@ const getFakeGroup = (status) => ({ ...@@ -19,6 +19,7 @@ const getFakeGroup = (status) => ({
new_name: 'group1', new_name: 'group1',
}, },
id: 1, id: 1,
validation_errors: [],
progress: { status }, progress: { status },
}); });
...@@ -187,21 +188,25 @@ describe('import table row', () => { ...@@ -187,21 +188,25 @@ describe('import table row', () => {
expect(wrapper.text()).toContain('Please choose a group URL with no special characters.'); expect(wrapper.text()).toContain('Please choose a group URL with no special characters.');
}); });
it('Reports invalid group name if group already exists', async () => { it('Reports invalid group name if relevant validation error exists', async () => {
const FAKE_ERROR_MESSAGE = 'fake error';
createComponent({ createComponent({
group: { group: {
...getFakeGroup(STATUSES.NONE), ...getFakeGroup(STATUSES.NONE),
import_target: { validation_errors: [
target_namespace: EXISTING_GROUP_TARGET_NAMESPACE, {
new_name: EXISTING_GROUP_PATH, field: 'new_name',
message: FAKE_ERROR_MESSAGE,
}, },
],
}, },
}); });
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
await nextTick(); await nextTick();
expect(wrapper.text()).toContain('Name already exists.'); expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE);
}); });
}); });
}); });
import { import {
GlButton,
GlEmptyState, GlEmptyState,
GlLoadingIcon, GlLoadingIcon,
GlSearchBoxByClick, GlSearchBoxByClick,
...@@ -14,7 +15,7 @@ import waitForPromises from 'helpers/wait_for_promises'; ...@@ -14,7 +15,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { STATUSES } from '~/import_entities/constants'; import { STATUSES } from '~/import_entities/constants';
import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue';
import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue';
import importGroupMutation from '~/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql'; import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql'; import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql';
import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql'; import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
...@@ -40,6 +41,7 @@ describe('import table', () => { ...@@ -40,6 +41,7 @@ describe('import table', () => {
]; ];
const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 }; const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 };
const findImportAllButton = () => wrapper.find('h1').find(GlButton);
const findPaginationDropdown = () => wrapper.findComponent(GlDropdown); const findPaginationDropdown = () => wrapper.findComponent(GlDropdown);
const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text(); const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text();
...@@ -72,7 +74,6 @@ describe('import table', () => { ...@@ -72,7 +74,6 @@ describe('import table', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
it('renders loading icon while performing request', async () => { it('renders loading icon while performing request', async () => {
...@@ -141,7 +142,7 @@ describe('import table', () => { ...@@ -141,7 +142,7 @@ describe('import table', () => {
event | payload | mutation | variables event | payload | mutation | variables
${'update-target-namespace'} | ${'new-namespace'} | ${setTargetNamespaceMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace' }} ${'update-target-namespace'} | ${'new-namespace'} | ${setTargetNamespaceMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace' }}
${'update-new-name'} | ${'new-name'} | ${setNewNameMutation} | ${{ sourceGroupId: FAKE_GROUP.id, newName: 'new-name' }} ${'update-new-name'} | ${'new-name'} | ${setNewNameMutation} | ${{ sourceGroupId: FAKE_GROUP.id, newName: 'new-name' }}
${'import-group'} | ${undefined} | ${importGroupMutation} | ${{ sourceGroupId: FAKE_GROUP.id }} ${'import-group'} | ${undefined} | ${importGroupsMutation} | ${{ sourceGroupIds: [FAKE_GROUP.id] }}
`('correctly maps $event to mutation', async ({ event, payload, mutation, variables }) => { `('correctly maps $event to mutation', async ({ event, payload, mutation, variables }) => {
jest.spyOn(apolloProvider.defaultClient, 'mutate'); jest.spyOn(apolloProvider.defaultClient, 'mutate');
wrapper.find(ImportTableRow).vm.$emit(event, payload); wrapper.find(ImportTableRow).vm.$emit(event, payload);
...@@ -277,4 +278,66 @@ describe('import table', () => { ...@@ -277,4 +278,66 @@ describe('import table', () => {
); );
}); });
}); });
describe('import all button', () => {
it('does not exists when no groups available', () => {
createComponent({
bulkImportSourceGroups: () => new Promise(() => {}),
});
expect(findImportAllButton().exists()).toBe(false);
});
it('exists when groups are available for import', async () => {
createComponent({
bulkImportSourceGroups: () => ({
nodes: FAKE_GROUPS,
pageInfo: FAKE_PAGE_INFO,
}),
});
await waitForPromises();
expect(findImportAllButton().exists()).toBe(true);
});
it('counts only not-imported groups', async () => {
const NEW_GROUPS = [
generateFakeEntry({ id: 1, status: STATUSES.NONE }),
generateFakeEntry({ id: 2, status: STATUSES.NONE }),
generateFakeEntry({ id: 3, status: STATUSES.FINISHED }),
];
createComponent({
bulkImportSourceGroups: () => ({
nodes: NEW_GROUPS,
pageInfo: FAKE_PAGE_INFO,
}),
});
await waitForPromises();
expect(findImportAllButton().text()).toMatchInterpolatedText('Import 2 groups');
});
it('disables button when any group has validation errors', async () => {
const NEW_GROUPS = [
generateFakeEntry({ id: 1, status: STATUSES.NONE }),
generateFakeEntry({
id: 2,
status: STATUSES.NONE,
validation_errors: [{ field: 'new_name', message: 'test validation error' }],
}),
generateFakeEntry({ id: 3, status: STATUSES.FINISHED }),
];
createComponent({
bulkImportSourceGroups: () => ({
nodes: NEW_GROUPS,
pageInfo: FAKE_PAGE_INFO,
}),
});
await waitForPromises();
expect(findImportAllButton().props().disabled).toBe(true);
});
});
}); });
...@@ -8,7 +8,9 @@ import { ...@@ -8,7 +8,9 @@ import {
clientTypenames, clientTypenames,
createResolvers, createResolvers,
} from '~/import_entities/import_groups/graphql/client_factory'; } from '~/import_entities/import_groups/graphql/client_factory';
import importGroupMutation from '~/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql'; import addValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql';
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
import removeValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql';
import setImportProgressMutation from '~/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql'; import setImportProgressMutation from '~/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql';
import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql'; import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql';
import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql'; import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql';
...@@ -240,6 +242,7 @@ describe('Bulk import resolvers', () => { ...@@ -240,6 +242,7 @@ describe('Bulk import resolvers', () => {
target_namespace: 'root', target_namespace: 'root',
new_name: 'group1', new_name: 'group1',
}, },
validation_errors: [],
}, },
], ],
pageInfo: { pageInfo: {
...@@ -294,8 +297,8 @@ describe('Bulk import resolvers', () => { ...@@ -294,8 +297,8 @@ describe('Bulk import resolvers', () => {
axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(() => new Promise(() => {})); axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(() => new Promise(() => {}));
client.mutate({ client.mutate({
mutation: importGroupMutation, mutation: importGroupsMutation,
variables: { sourceGroupId: GROUP_ID }, variables: { sourceGroupIds: [GROUP_ID] },
}); });
await waitForPromises(); await waitForPromises();
...@@ -325,8 +328,8 @@ describe('Bulk import resolvers', () => { ...@@ -325,8 +328,8 @@ describe('Bulk import resolvers', () => {
it('sets import status to CREATED when request completes', async () => { it('sets import status to CREATED when request completes', async () => {
axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 }); axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 });
await client.mutate({ await client.mutate({
mutation: importGroupMutation, mutation: importGroupsMutation,
variables: { sourceGroupId: GROUP_ID }, variables: { sourceGroupIds: [GROUP_ID] },
}); });
await waitForPromises(); await waitForPromises();
...@@ -340,8 +343,8 @@ describe('Bulk import resolvers', () => { ...@@ -340,8 +343,8 @@ describe('Bulk import resolvers', () => {
client client
.mutate({ .mutate({
mutation: importGroupMutation, mutation: [importGroupsMutation],
variables: { sourceGroupId: GROUP_ID }, variables: { sourceGroupIds: [GROUP_ID] },
}) })
.catch(() => {}); .catch(() => {});
await waitForPromises(); await waitForPromises();
...@@ -357,8 +360,8 @@ describe('Bulk import resolvers', () => { ...@@ -357,8 +360,8 @@ describe('Bulk import resolvers', () => {
client client
.mutate({ .mutate({
mutation: importGroupMutation, mutation: importGroupsMutation,
variables: { sourceGroupId: GROUP_ID }, variables: { sourceGroupIds: [GROUP_ID] },
}) })
.catch(() => {}); .catch(() => {});
await waitForPromises(); await waitForPromises();
...@@ -375,8 +378,8 @@ describe('Bulk import resolvers', () => { ...@@ -375,8 +378,8 @@ describe('Bulk import resolvers', () => {
client client
.mutate({ .mutate({
mutation: importGroupMutation, mutation: importGroupsMutation,
variables: { sourceGroupId: GROUP_ID }, variables: { sourceGroupIds: [GROUP_ID] },
}) })
.catch(() => {}); .catch(() => {});
await waitForPromises(); await waitForPromises();
...@@ -418,5 +421,41 @@ describe('Bulk import resolvers', () => { ...@@ -418,5 +421,41 @@ describe('Bulk import resolvers', () => {
status: NEW_STATUS, status: NEW_STATUS,
}); });
}); });
it('addValidationError adds error to group', async () => {
const FAKE_FIELD = 'some-field';
const FAKE_MESSAGE = 'some-message';
const {
data: {
addValidationError: { validation_errors: validationErrors },
},
} = await client.mutate({
mutation: addValidationErrorMutation,
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE },
});
expect(validationErrors).toMatchObject([{ field: FAKE_FIELD, message: FAKE_MESSAGE }]);
});
it('removeValidationError removes error from group', async () => {
const FAKE_FIELD = 'some-field';
const FAKE_MESSAGE = 'some-message';
await client.mutate({
mutation: addValidationErrorMutation,
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE },
});
const {
data: {
removeValidationError: { validation_errors: validationErrors },
},
} = await client.mutate({
mutation: removeValidationErrorMutation,
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD },
});
expect(validationErrors).toMatchObject([]);
});
}); });
}); });
...@@ -14,6 +14,7 @@ export const generateFakeEntry = ({ id, status, ...rest }) => ({ ...@@ -14,6 +14,7 @@ export const generateFakeEntry = ({ id, status, ...rest }) => ({
id: `test-${id}`, id: `test-${id}`,
status, status,
}, },
validation_errors: [],
...rest, ...rest,
}); });
......
...@@ -22,33 +22,42 @@ describe('SourceGroupsManager', () => { ...@@ -22,33 +22,42 @@ describe('SourceGroupsManager', () => {
const IMPORT_ID = 1; const IMPORT_ID = 1;
const IMPORT_TARGET = { destination_name: 'demo', destination_namespace: 'foo' }; const IMPORT_TARGET = { destination_name: 'demo', destination_namespace: 'foo' };
const STATUS = 'FAKE_STATUS'; const STATUS = 'FAKE_STATUS';
const FAKE_GROUP = { id: 1, importTarget: IMPORT_TARGET, status: STATUS }; const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS };
it('loads state from storage on creation', () => { it('loads state from storage on creation', () => {
expect(storage.getItem).toHaveBeenCalledWith(KEY); expect(storage.getItem).toHaveBeenCalledWith(KEY);
}); });
it('saves to storage when saveImportState is called', () => { it('saves to storage when createImportState is called', () => {
manager.saveImportState(IMPORT_ID, FAKE_GROUP); const FAKE_STATUS = 'fake;';
manager.createImportState(IMPORT_ID, { status: FAKE_STATUS, groups: [FAKE_GROUP] });
const storedObject = JSON.parse(storage.setItem.mock.calls[0][1]); const storedObject = JSON.parse(storage.setItem.mock.calls[0][1]);
expect(Object.values(storedObject)[0]).toStrictEqual({ expect(Object.values(storedObject)[0]).toStrictEqual({
status: FAKE_STATUS,
groups: [
{
id: FAKE_GROUP.id, id: FAKE_GROUP.id,
importTarget: IMPORT_TARGET, importTarget: IMPORT_TARGET,
status: STATUS, },
],
}); });
}); });
it('updates storage when previous state is available', () => { it('updates storage when previous state is available', () => {
const CHANGED_STATUS = 'changed'; const CHANGED_STATUS = 'changed';
manager.saveImportState(IMPORT_ID, FAKE_GROUP); manager.createImportState(IMPORT_ID, { status: STATUS, groups: [FAKE_GROUP] });
manager.saveImportState(IMPORT_ID, { status: CHANGED_STATUS }); manager.updateImportProgress(IMPORT_ID, CHANGED_STATUS);
const storedObject = JSON.parse(storage.setItem.mock.calls[1][1]); const storedObject = JSON.parse(storage.setItem.mock.calls[1][1]);
expect(Object.values(storedObject)[0]).toStrictEqual({ expect(Object.values(storedObject)[0]).toStrictEqual({
status: CHANGED_STATUS,
groups: [
{
id: FAKE_GROUP.id, id: FAKE_GROUP.id,
importTarget: IMPORT_TARGET, importTarget: IMPORT_TARGET,
status: CHANGED_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