Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-11-03 12:10:11 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-11-03 12:10:11 +0300
commitf3189d2a01f0b44915b5d7a2301af1445f759c9f (patch)
treeb2d476f044cd6fa9396771217a8c89089c424f57 /app/assets/javascripts
parent14c866bb2ff6550420257cee619828a7510433dc (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/api/namespaces_api.js (renamed from app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js)5
-rw-r--r--app/assets/javascripts/group.js4
-rw-r--r--app/assets/javascripts/import_entities/components/group_dropdown.vue2
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue33
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue14
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue308
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue84
-rw-r--r--app/assets/javascripts/import_entities/import_groups/constants.js15
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js400
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql23
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql23
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql13
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql2
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql7
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js74
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js87
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql52
-rw-r--r--app/assets/javascripts/import_entities/import_groups/index.js4
-rw-r--r--app/assets/javascripts/import_entities/import_groups/services/status_poller.js (renamed from app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js)4
-rw-r--r--app/assets/javascripts/import_entities/import_groups/utils.js23
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue6
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue10
-rw-r--r--app/assets/javascripts/pages/groups/new/group_path_validator.js4
-rw-r--r--app/assets/javascripts/rest_api.js1
28 files changed, 543 insertions, 691 deletions
diff --git a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js b/app/assets/javascripts/api/namespaces_api.js
index 301e0b4f7a2..166a95b749a 100644
--- a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js
+++ b/app/assets/javascripts/api/namespaces_api.js
@@ -3,10 +3,11 @@ import axios from '~/lib/utils/axios_utils';
const NAMESPACE_EXISTS_PATH = '/api/:version/namespaces/:id/exists';
-export default function fetchGroupPathAvailability(groupPath, parentId) {
+export function getGroupPathAvailability(groupPath, parentId, axiosOptions = {}) {
const url = buildApiUrl(NAMESPACE_EXISTS_PATH).replace(':id', encodeURIComponent(groupPath));
return axios.get(url, {
- params: { parent_id: parentId },
+ params: { parent_id: parentId, ...axiosOptions.params },
+ ...axiosOptions,
});
}
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index b6a1f41afb5..f255f8a084c 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -1,6 +1,6 @@
import createFlash from '~/flash';
import { __ } from '~/locale';
-import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability';
+import { getGroupPathAvailability } from '~/rest_api';
import { slugify } from './lib/utils/text_utility';
export default class Group {
@@ -51,7 +51,7 @@ export default class Group {
const slug = this.groupPaths[0]?.value || slugify(value);
if (!slug) return;
- fetchGroupPathAvailability(slug, this.parentId?.value)
+ getGroupPathAvailability(slug, this.parentId?.value)
.then(({ data }) => data)
.then(({ exists, suggests }) => {
if (exists && suggests.length) {
diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue
index 5ba910746ca..25d4037bbe5 100644
--- a/app/assets/javascripts/import_entities/components/group_dropdown.vue
+++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue
@@ -19,7 +19,7 @@ export default {
computed: {
filteredNamespaces() {
return this.namespaces.filter((ns) =>
- ns.toLowerCase().includes(this.searchTerm.toLowerCase()),
+ ns.fullPath.toLowerCase().includes(this.searchTerm.toLowerCase()),
);
},
},
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
index 104c84173fc..e004bc35087 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
@@ -1,7 +1,5 @@
<script>
import { GlButton, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
-import { joinPaths } from '~/lib/utils/url_utility';
-import { isFinished, isInvalid, isAvailableForImport } from '../utils';
export default {
components: {
@@ -12,32 +10,17 @@ export default {
GlTooltip,
},
props: {
- group: {
- type: Object,
+ isFinished: {
+ type: Boolean,
required: true,
},
- groupPathRegex: {
- type: RegExp,
+ isAvailableForImport: {
+ type: Boolean,
required: true,
},
- },
- computed: {
- fullLastImportPath() {
- return this.group.last_import_target
- ? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}`
- : null;
- },
- absoluteLastImportPath() {
- return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath);
- },
- isAvailableForImport() {
- return isAvailableForImport(this.group);
- },
- isFinished() {
- return isFinished(this.group);
- },
- isInvalid() {
- return isInvalid(this.group, this.groupPathRegex);
+ isInvalid: {
+ type: Boolean,
+ required: true,
},
},
};
@@ -56,7 +39,7 @@ export default {
{{ isFinished ? __('Re-import') : __('Import') }}
</gl-button>
<gl-icon
- v-if="isFinished"
+ v-if="isAvailableForImport && isFinished"
v-gl-tooltip
:size="16"
name="information-o"
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue
index 2de9bd4f868..cad1b983d61 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue
@@ -1,7 +1,6 @@
<script>
import { GlLink, GlSprintf, GlIcon } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
-import { isFinished } from '../utils';
export default {
components: {
@@ -17,16 +16,13 @@ export default {
},
computed: {
fullLastImportPath() {
- return this.group.last_import_target
- ? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}`
+ return this.group.lastImportTarget
+ ? `${this.group.lastImportTarget.targetNamespace}/${this.group.lastImportTarget.newName}`
: null;
},
absoluteLastImportPath() {
return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath);
},
- isFinished() {
- return isFinished(this.group);
- },
},
};
</script>
@@ -34,13 +30,13 @@ export default {
<template>
<div>
<gl-link
- :href="group.web_url"
+ :href="group.webUrl"
target="_blank"
class="gl-display-inline-flex gl-align-items-center gl-h-7"
>
- {{ group.full_path }} <gl-icon name="external-link" />
+ {{ group.fullPath }} <gl-icon name="external-link" />
</gl-link>
- <div v-if="isFinished && fullLastImportPath" class="gl-font-sm">
+ <div v-if="group.flags.isFinished && fullLastImportPath" class="gl-font-sm">
<gl-sprintf :message="s__('BulkImport|Last imported to %{link}')">
<template #link>
<gl-link :href="absoluteLastImportPath" class="gl-font-sm" target="_blank">{{
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
index 04b037ecc2b..eaa08cb1529 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -12,18 +12,28 @@ import {
GlTable,
GlFormCheckbox,
} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import createFlash from '~/flash';
import { s__, __, n__ } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
+import { getGroupPathAvailability } from '~/rest_api';
+import axios from '~/lib/utils/axios_utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+
+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 updateImportStatusMutation from '../graphql/mutations/update_import_status.mutation.graphql';
import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql';
import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql';
-import { isInvalid, isFinished, isAvailableForImport } from '../utils';
+import { NEW_NAME_FIELD, i18n } from '../constants';
+import { StatusPoller } from '../services/status_poller';
+import { isFinished, isAvailableForImport, isNameValid, isSameTarget } from '../utils';
import ImportActionsCell from './import_actions_cell.vue';
import ImportSourceCell from './import_source_cell.vue';
import ImportTargetCell from './import_target_cell.vue';
+const VALIDATION_DEBOUNCE_TIME = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
const PAGE_SIZES = [20, 50, 100];
const DEFAULT_PAGE_SIZE = PAGE_SIZES[0];
const DEFAULT_TH_CLASSES =
@@ -59,7 +69,7 @@ export default {
type: RegExp,
required: true,
},
- groupUrlErrorMessage: {
+ jobsPath: {
type: String,
required: true,
},
@@ -70,7 +80,9 @@ export default {
filter: '',
page: 1,
perPage: DEFAULT_PAGE_SIZE,
- selectedGroups: [],
+ selectedGroupsIds: [],
+ pendingGroupsIds: [],
+ importTargets: {},
};
},
@@ -94,14 +106,14 @@ export default {
tdClass: `${DEFAULT_TD_CLASSES} gl-pr-3!`,
},
{
- key: 'web_url',
+ key: 'webUrl',
label: s__('BulkImport|From source group'),
thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! import-jobs-from-col`,
// eslint-disable-next-line @gitlab/require-i18n-strings
tdClass: `${DEFAULT_TD_CLASSES} gl-pl-0!`,
},
{
- key: 'import_target',
+ key: 'importTarget',
label: s__('BulkImport|To new group'),
thClass: `${DEFAULT_TH_CLASSES} import-jobs-to-col`,
tdClass: DEFAULT_TD_CLASSES,
@@ -126,16 +138,39 @@ export default {
return this.bulkImportSourceGroups?.nodes ?? [];
},
+ groupsTableData() {
+ return this.groups.map((group) => {
+ const importTarget = this.getImportTarget(group);
+ const status = this.getStatus(group);
+
+ const flags = {
+ isInvalid: importTarget.validationErrors?.length > 0,
+ isAvailableForImport: isAvailableForImport(group) && status !== STATUSES.SCHEDULING,
+ isFinished: isFinished(group),
+ };
+
+ return {
+ ...group,
+ visibleStatus: status,
+ importTarget,
+ flags: {
+ ...flags,
+ isUnselectable: !flags.isAvailableForImport || flags.isInvalid,
+ },
+ };
+ });
+ },
+
hasSelectedGroups() {
- return this.selectedGroups.length > 0;
+ return this.selectedGroupsIds.length > 0;
},
hasAllAvailableGroupsSelected() {
- return this.selectedGroups.length === this.availableGroupsForImport.length;
+ return this.selectedGroupsIds.length === this.availableGroupsForImport.length;
},
availableGroupsForImport() {
- return this.groups.filter((g) => isAvailableForImport(g) && !this.isInvalid(g));
+ return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && g.flags.isInvalid);
},
humanizedTotal() {
@@ -175,25 +210,43 @@ export default {
filter() {
this.page = 1;
},
- groups() {
+
+ groupsTableData() {
const table = this.getTableRef();
- this.groups.forEach((g, idx) => {
- if (this.selectedGroups.includes(g)) {
+ const matches = new Set();
+ this.groupsTableData.forEach((g, idx) => {
+ if (this.selectedGroupsIds.includes(g.id)) {
+ matches.add(g.id);
this.$nextTick(() => {
table.selectRow(idx);
});
}
});
- this.selectedGroups = [];
+
+ this.selectedGroupsIds = this.selectedGroupsIds.filter((id) => matches.has(id));
},
},
- methods: {
- isUnselectable(group) {
- return !this.isAvailableForImport(group) || this.isInvalid(group);
- },
+ mounted() {
+ this.statusPoller = new StatusPoller({
+ pollPath: this.jobsPath,
+ updateImportStatus: (update) => {
+ this.$apollo.mutate({
+ mutation: updateImportStatusMutation,
+ variables: { id: update.id, status: update.status_name },
+ });
+ },
+ });
- rowClasses(group) {
+ this.statusPoller.startPolling();
+ },
+
+ beforeDestroy() {
+ this.statusPoller.stopPolling();
+ },
+
+ methods: {
+ rowClasses(groupTableItem) {
const DEFAULT_CLASSES = [
'gl-border-gray-200',
'gl-border-0',
@@ -201,7 +254,7 @@ export default {
'gl-border-solid',
];
const result = [...DEFAULT_CLASSES];
- if (this.isUnselectable(group)) {
+ if (groupTableItem.flags.isUnselectable) {
result.push('gl-cursor-default!');
}
return result;
@@ -211,19 +264,13 @@ export default {
if (type === 'row') {
return {
'data-qa-selector': 'import_item',
- 'data-qa-source-group': group.full_path,
+ 'data-qa-source-group': group.fullPath,
};
}
return {};
},
- isAvailableForImport,
- isFinished,
- isInvalid(group) {
- return isInvalid(group, this.groupPathRegex);
- },
-
groupsCount(count) {
return n__('%d group', '%d groups', count);
},
@@ -232,22 +279,64 @@ export default {
this.page = page;
},
- updateImportTarget(sourceGroupId, targetNamespace, newName) {
- this.$apollo.mutate({
- mutation: setImportTargetMutation,
- variables: { sourceGroupId, targetNamespace, newName },
- });
+ getStatus(group) {
+ if (this.pendingGroupsIds.includes(group.id)) {
+ return STATUSES.SCHEDULING;
+ }
+
+ return group.progress?.status || STATUSES.NONE;
},
- importGroups(sourceGroupIds) {
- this.$apollo.mutate({
- mutation: importGroupsMutation,
- variables: { sourceGroupIds },
+ updateImportTarget(group, changes) {
+ const newImportTarget = {
+ ...group.importTarget,
+ ...changes,
+ };
+ this.$set(this.importTargets, group.id, newImportTarget);
+ this.validateImportTarget(newImportTarget);
+ },
+
+ async importGroups(importRequests) {
+ const newPendingGroupsIds = importRequests.map((request) => request.sourceGroupId);
+ newPendingGroupsIds.forEach((id) => {
+ this.importTargets[id].validationErrors = [
+ { field: NEW_NAME_FIELD, message: i18n.ERROR_IMPORT_COMPLETED },
+ ];
+
+ if (!this.pendingGroupsIds.includes(id)) {
+ this.pendingGroupsIds.push(id);
+ }
});
+
+ try {
+ await this.$apollo.mutate({
+ mutation: importGroupsMutation,
+ variables: { importRequests },
+ });
+ } catch (error) {
+ const message = error?.networkError?.response?.data?.error ?? i18n.ERROR_IMPORT;
+ createFlash({
+ message,
+ captureError: true,
+ error,
+ });
+ } finally {
+ this.pendingGroupsIds = this.pendingGroupsIds.filter(
+ (id) => !newPendingGroupsIds.includes(id),
+ );
+ }
},
importSelectedGroups() {
- this.importGroups(this.selectedGroups.map((g) => g.id));
+ const importRequests = this.groupsTableData
+ .filter((group) => this.selectedGroupsIds.includes(group.id))
+ .map((group) => ({
+ sourceGroupId: group.id,
+ targetNamespace: group.importTarget.targetNamespace.fullPath,
+ newName: group.importTarget.newName,
+ }));
+
+ this.importGroups(importRequests);
},
setPageSize(size) {
@@ -263,16 +352,115 @@ export default {
preventSelectingAlreadyImportedGroups(updatedSelection) {
if (updatedSelection) {
- this.selectedGroups = updatedSelection;
+ this.selectedGroupsIds = updatedSelection.map((g) => g.id);
}
const table = this.getTableRef();
- this.groups.forEach((group, idx) => {
- if (table.isRowSelected(idx) && this.isUnselectable(group)) {
+ this.groupsTableData.forEach((group, idx) => {
+ if (table.isRowSelected(idx) && group.flags.isUnselectable) {
table.unselectRow(idx);
}
});
},
+
+ validateImportTarget: debounce(async function validate(importTarget) {
+ const newValidationErrors = [];
+ importTarget.cancellationToken?.cancel();
+ if (importTarget.newName === '') {
+ newValidationErrors.push({ field: NEW_NAME_FIELD, message: i18n.ERROR_REQUIRED });
+ } else if (!isNameValid(importTarget, this.groupPathRegex)) {
+ newValidationErrors.push({ field: NEW_NAME_FIELD, message: i18n.ERROR_INVALID_FORMAT });
+ } else if (Object.values(this.importTargets).find(isSameTarget(importTarget))) {
+ newValidationErrors.push({
+ field: NEW_NAME_FIELD,
+ message: i18n.ERROR_NAME_ALREADY_USED_IN_SUGGESTION,
+ });
+ } else {
+ try {
+ // eslint-disable-next-line no-param-reassign
+ importTarget.cancellationToken = axios.CancelToken.source();
+ const {
+ data: { exists },
+ } = await getGroupPathAvailability(
+ importTarget.newName,
+ importTarget.targetNamespace.id,
+ {
+ cancelToken: importTarget.cancellationToken?.token,
+ },
+ );
+
+ if (exists) {
+ newValidationErrors.push({
+ field: NEW_NAME_FIELD,
+ message: i18n.ERROR_NAME_ALREADY_EXISTS,
+ });
+ }
+ } catch (e) {
+ if (!axios.isCancel(e)) {
+ throw e;
+ }
+ }
+ }
+
+ // eslint-disable-next-line no-param-reassign
+ importTarget.validationErrors = newValidationErrors;
+ }, VALIDATION_DEBOUNCE_TIME),
+
+ getImportTarget(group) {
+ if (this.importTargets[group.id]) {
+ return this.importTargets[group.id];
+ }
+
+ const defaultTargetNamespace = this.availableNamespaces[0] ?? { fullPath: '', id: null };
+ let importTarget;
+ if (group.lastImportTarget) {
+ const targetNamespace = this.availableNamespaces.find(
+ (ns) => ns.fullPath === group.lastImportTarget.targetNamespace,
+ );
+
+ importTarget = {
+ targetNamespace: targetNamespace ?? defaultTargetNamespace,
+ newName: group.lastImportTarget.newName,
+ };
+ } else {
+ importTarget = {
+ targetNamespace: defaultTargetNamespace,
+ newName: group.fullPath,
+ };
+ }
+
+ const cancellationToken = axios.CancelToken.source();
+ this.$set(this.importTargets, group.id, {
+ ...importTarget,
+ cancellationToken,
+ validationErrors: [],
+ });
+
+ getGroupPathAvailability(importTarget.newName, importTarget.targetNamespace.id, {
+ cancelToken: cancellationToken.token,
+ })
+ .then(({ data: { exists, suggests: suggestions } }) => {
+ if (!exists) return;
+
+ let currentSuggestion = suggestions[0] ?? importTarget.newName;
+ const existingTargets = Object.values(this.importTargets)
+ .filter((t) => t.targetNamespace.id === importTarget.targetNamespace.id)
+ .map((t) => t.newName.toLowerCase());
+
+ while (existingTargets.includes(currentSuggestion.toLowerCase())) {
+ currentSuggestion = `${currentSuggestion}-1`;
+ }
+
+ Object.assign(this.importTargets[group.id], {
+ targetNamespace: importTarget.targetNamespace,
+ newName: currentSuggestion,
+ });
+ })
+ .catch(() => {
+ // empty catch intended
+ });
+ return this.importTargets[group.id];
+ },
},
gitlabLogo: window.gon.gitlab_logo,
@@ -337,7 +525,7 @@ export default {
>
<gl-sprintf :message="__('%{count} selected')">
<template #count>
- {{ selectedGroups.length }}
+ {{ selectedGroupsIds.length }}
</template>
</gl-sprintf>
<gl-button
@@ -355,7 +543,7 @@ export default {
data-qa-selector="import_table"
:tbody-tr-class="rowClasses"
:tbody-tr-attr="qaRowAttributes"
- :items="groups"
+ :items="groupsTableData"
:fields="$options.fields"
selectable
select-mode="multi"
@@ -364,7 +552,7 @@ export default {
>
<template #head(selected)="{ selectAllRows, clearSelected }">
<gl-form-checkbox
- :key="`checkbox-${selectedGroups.length}`"
+ :key="`checkbox-${selectedGroupsIds.length}`"
class="gl-h-7 gl-pt-3"
:checked="hasSelectedGroups"
:indeterminate="hasSelectedGroups && !hasAllAvailableGroupsSelected"
@@ -375,35 +563,39 @@ export default {
<gl-form-checkbox
class="gl-h-7 gl-pt-3"
:checked="rowSelected"
- :disabled="!isAvailableForImport(group) || isInvalid(group)"
+ :disabled="group.flags.isUnselectable"
@change="rowSelected ? unselectRow() : selectRow()"
/>
</template>
- <template #cell(web_url)="{ item: group }">
+ <template #cell(webUrl)="{ item: group }">
<import-source-cell :group="group" />
</template>
- <template #cell(import_target)="{ item: group }">
+ <template #cell(importTarget)="{ item: group }">
<import-target-cell
:group="group"
:available-namespaces="availableNamespaces"
:group-path-regex="groupPathRegex"
- :group-url-error-message="groupUrlErrorMessage"
- @update-target-namespace="
- updateImportTarget(group.id, $event, group.import_target.new_name)
- "
- @update-new-name="
- updateImportTarget(group.id, group.import_target.target_namespace, $event)
- "
+ @update-target-namespace="updateImportTarget(group, { targetNamespace: $event })"
+ @update-new-name="updateImportTarget(group, { newName: $event })"
/>
</template>
- <template #cell(progress)="{ value: { status } }">
- <import-status-cell :status="status" class="gl-line-height-32" />
+ <template #cell(progress)="{ item: group }">
+ <import-status-cell :status="group.visibleStatus" class="gl-line-height-32" />
</template>
<template #cell(actions)="{ item: group }">
<import-actions-cell
- :group="group"
- :group-path-regex="groupPathRegex"
- @import-group="importGroups([group.id])"
+ :is-finished="group.flags.isFinished"
+ :is-available-for-import="group.flags.isAvailableForImport"
+ :is-invalid="group.flags.isInvalid"
+ @import-group="
+ importGroups([
+ {
+ sourceGroupId: group.id,
+ targetNamespace: group.importTarget.targetNamespace.fullPath,
+ newName: group.importTarget.newName,
+ },
+ ])
+ "
/>
</template>
</gl-table>
@@ -413,7 +605,7 @@ export default {
:page-info="bulkImportSourceGroups.pageInfo"
class="gl-m-0"
/>
- <gl-dropdown category="tertiary" class="gl-ml-auto">
+ <gl-dropdown category="tertiary" :aria-label="__('Page size')" class="gl-ml-auto">
<template #button-content>
<span class="font-weight-bold">
<gl-sprintf :message="__('%{count} items per page')">
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue
index daced740c94..ca9ae9447d0 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue
@@ -7,12 +7,7 @@ import {
} from '@gitlab/ui';
import { s__ } from '~/locale';
import ImportGroupDropdown from '../../components/group_dropdown.vue';
-import {
- isInvalid,
- getInvalidNameValidationMessage,
- isNameValid,
- isAvailableForImport,
-} from '../utils';
+import { getInvalidNameValidationMessage } from '../utils';
export default {
components: {
@@ -31,44 +26,15 @@ export default {
type: Array,
required: true,
},
- groupPathRegex: {
- type: RegExp,
- required: true,
- },
- groupUrlErrorMessage: {
- type: String,
- required: true,
- },
},
computed: {
- availableNamespaceNames() {
- return this.availableNamespaces.map((ns) => ns.full_path);
- },
-
- importTarget() {
- return this.group.import_target;
+ fullPath() {
+ return this.group.importTarget.targetNamespace.fullPath || s__('BulkImport|No parent');
},
-
invalidNameValidationMessage() {
- return getInvalidNameValidationMessage(this.group);
+ return getInvalidNameValidationMessage(this.group.importTarget);
},
-
- isInvalid() {
- return isInvalid(this.group, this.groupPathRegex);
- },
-
- isNameValid() {
- return isNameValid(this.group, this.groupPathRegex);
- },
-
- isAvailableForImport() {
- return isAvailableForImport(this.group);
- },
- },
-
- i18n: {
- NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
},
};
</script>
@@ -77,14 +43,14 @@ export default {
<div class="gl-display-flex gl-align-items-stretch">
<import-group-dropdown
#default="{ namespaces }"
- :text="importTarget.target_namespace"
- :disabled="!isAvailableForImport"
- :namespaces="availableNamespaceNames"
+ :text="fullPath"
+ :disabled="!group.flags.isAvailableForImport"
+ :namespaces="availableNamespaces"
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
class="gl-h-7 gl-flex-grow-1"
data-qa-selector="target_namespace_selector_dropdown"
>
- <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{
+ <gl-dropdown-item @click="$emit('update-target-namespace', { fullPath: '', id: null })">{{
s__('BulkImport|No parent')
}}</gl-dropdown-item>
<template v-if="namespaces.length">
@@ -94,20 +60,20 @@ export default {
</gl-dropdown-section-header>
<gl-dropdown-item
v-for="ns in namespaces"
- :key="ns"
+ :key="ns.fullPath"
data-qa-selector="target_group_dropdown_item"
- :data-qa-group-name="ns"
+ :data-qa-group-name="ns.fullPath"
@click="$emit('update-target-namespace', ns)"
>
- {{ ns }}
+ {{ ns.fullPath }}
</gl-dropdown-item>
</template>
</import-group-dropdown>
<div
class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10"
:class="{
- 'gl-text-gray-400 gl-border-gray-100': !isAvailableForImport,
- 'gl-border-gray-200': isAvailableForImport,
+ 'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport,
+ 'gl-border-gray-200': group.flags.isAvailableForImport,
}"
>
/
@@ -116,21 +82,21 @@ export default {
<gl-form-input
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:class="{
- 'gl-inset-border-1-gray-200!': isAvailableForImport,
- 'gl-inset-border-1-gray-100!': !isAvailableForImport,
- 'is-invalid': isInvalid && isAvailableForImport,
+ 'gl-inset-border-1-gray-200!': group.flags.isAvailableForImport,
+ 'gl-inset-border-1-gray-100!': !group.flags.isAvailableForImport,
+ 'is-invalid': group.flags.isInvalid && group.flags.isAvailableForImport,
}"
- :disabled="!isAvailableForImport"
- :value="importTarget.new_name"
+ debounce="500"
+ :disabled="!group.flags.isAvailableForImport"
+ :value="group.importTarget.newName"
+ :aria-label="__('New name')"
@input="$emit('update-new-name', $event)"
/>
- <p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2">
- <template v-if="!isNameValid">
- {{ groupUrlErrorMessage }}
- </template>
- <template v-else-if="invalidNameValidationMessage">
- {{ invalidNameValidationMessage }}
- </template>
+ <p
+ v-if="group.flags.isAvailableForImport && group.flags.isInvalid"
+ class="gl-text-red-500 gl-m-0 gl-mt-2"
+ >
+ {{ invalidNameValidationMessage }}
</p>
</div>
</div>
diff --git a/app/assets/javascripts/import_entities/import_groups/constants.js b/app/assets/javascripts/import_entities/import_groups/constants.js
index b2c3d85e280..aa9cf3897e6 100644
--- a/app/assets/javascripts/import_entities/import_groups/constants.js
+++ b/app/assets/javascripts/import_entities/import_groups/constants.js
@@ -1,7 +1,16 @@
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
export const i18n = {
- NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
+ ERROR_INVALID_FORMAT: s__(
+ 'GroupSettings|Please choose a group URL with no special characters or spaces.',
+ ),
+ ERROR_NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
+ ERROR_REQUIRED: __('This field is required.'),
+ ERROR_NAME_ALREADY_USED_IN_SUGGESTION: s__(
+ 'BulkImport|Name already used as a target for another group.',
+ ),
+ ERROR_IMPORT: s__('BulkImport|Importing the group failed.'),
+ ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'),
};
-export const NEW_NAME_FIELD = 'new_name';
+export const NEW_NAME_FIELD = 'newName';
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
index 6eca530b49c..bce6e7bcb1f 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
@@ -1,23 +1,10 @@
-import createFlash from '~/flash';
import createDefaultClient from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
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';
-import groupAndProjectQuery from './queries/group_and_project.query.graphql';
-import { SourceGroupsManager } from './services/source_groups_manager';
-import { StatusPoller } from './services/status_poller';
+import { LocalStorageCache } from './services/local_storage_cache';
import typeDefs from './typedefs.graphql';
export const clientTypenames = {
@@ -27,221 +14,99 @@ export const clientTypenames = {
BulkImportPageInfo: 'ClientBulkImportPageInfo',
BulkImportTarget: 'ClientBulkImportTarget',
BulkImportProgress: 'ClientBulkImportProgress',
- BulkImportValidationError: 'ClientBulkImportValidationError',
};
-function makeGroup(data) {
- const result = {
- __typename: clientTypenames.BulkImportSourceGroup,
+function makeLastImportTarget(data) {
+ return {
+ __typename: clientTypenames.BulkImportTarget,
...data,
};
- const NESTED_OBJECT_FIELDS = {
- import_target: clientTypenames.BulkImportTarget,
- last_import_target: clientTypenames.BulkImportTarget,
- progress: clientTypenames.BulkImportProgress,
- };
-
- Object.entries(NESTED_OBJECT_FIELDS).forEach(([field, type]) => {
- if (!data[field]) {
- return;
- }
- result[field] = {
- __typename: type,
- ...data[field],
- };
- });
-
- return result;
}
-async function checkImportTargetIsValid({ client, newName, targetNamespace, sourceGroupId }) {
- const {
- data: { existingGroup, existingProject },
- } = await client.query({
- query: groupAndProjectQuery,
- fetchPolicy: 'no-cache',
- variables: {
- fullPath: `${targetNamespace}/${newName}`,
- },
- });
-
- const variables = {
- field: NEW_NAME_FIELD,
- sourceGroupId,
+function makeProgress(data) {
+ return {
+ __typename: clientTypenames.BulkImportProgress,
+ ...data,
};
-
- if (!existingGroup && !existingProject) {
- client.mutate({
- mutation: removeValidationErrorMutation,
- variables,
- });
- } else {
- client.mutate({
- mutation: addValidationErrorMutation,
- variables: {
- ...variables,
- message: i18n.NAME_ALREADY_EXISTS,
- },
- });
- }
}
-const localProgressId = (id) => `not-started-${id}`;
-const nextName = (name) => `${name}-1`;
+function makeGroup(data) {
+ return {
+ __typename: clientTypenames.BulkImportSourceGroup,
+ ...data,
+ progress: data.progress
+ ? makeProgress({
+ id: `LOCAL-PROGRESS-${data.id}`,
+ ...data.progress,
+ })
+ : null,
+ lastImportTarget: data.lastImportTarget
+ ? makeLastImportTarget({
+ id: data.id,
+ ...data.lastImportTarget,
+ })
+ : null,
+ };
+}
-export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) {
- const groupsManager = new GroupsManager({
- sourceUrl,
+function getGroupFromCache({ client, id, getCacheKey }) {
+ return client.readFragment({
+ fragment: bulkImportSourceGroupItemFragment,
+ fragmentName: 'BulkImportSourceGroupItem',
+ id: getCacheKey({
+ __typename: clientTypenames.BulkImportSourceGroup,
+ id,
+ }),
});
+}
- let statusPoller;
+export function createResolvers({ endpoints }) {
+ const localStorageCache = new LocalStorageCache();
return {
Query: {
- async bulkImportSourceGroup(_, { id }, { client, getCacheKey }) {
- return client.readFragment({
- fragment: bulkImportSourceGroupItemFragment,
- fragmentName: 'BulkImportSourceGroupItem',
- id: getCacheKey({
- __typename: clientTypenames.BulkImportSourceGroup,
- id,
- }),
+ async bulkImportSourceGroups(_, vars) {
+ const { headers, data } = await axios.get(endpoints.status, {
+ params: {
+ page: vars.page,
+ per_page: vars.perPage,
+ filter: vars.filter,
+ },
});
- },
- async bulkImportSourceGroups(_, vars, { client }) {
- if (!statusPoller) {
- statusPoller = new StatusPoller({
- updateImportStatus: ({ id, status_name: status }) =>
- client.mutate({
- mutation: updateImportStatusMutation,
- variables: { id, status },
- }),
- pollPath: endpoints.jobs,
- });
- statusPoller.startPolling();
- }
-
- return Promise.all([
- axios.get(endpoints.status, {
- params: {
- page: vars.page,
- per_page: vars.perPage,
- filter: vars.filter,
- },
- }),
- client.query({ query: availableNamespacesQuery }),
- ]).then(
- ([
- { headers, data },
- {
- data: { availableNamespaces },
- },
- ]) => {
- const pagination = parseIntPagination(normalizeHeaders(headers));
-
- const response = {
- __typename: clientTypenames.BulkImportSourceGroupConnection,
- nodes: data.importable_data.map((group) => {
- 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,
- },
- import_target: importTarget,
- last_import_target: cachedImportState?.importTarget ?? null,
- });
- }),
- pageInfo: {
- __typename: clientTypenames.BulkImportPageInfo,
- ...pagination,
- },
- };
-
- setTimeout(() => {
- response.nodes.forEach((group) => {
- if (isAvailableForImport(group)) {
- checkImportTargetIsValid({
- client,
- newName: group.import_target.new_name,
- targetNamespace: group.import_target.target_namespace,
- sourceGroupId: group.id,
- });
- }
- });
+ const pagination = parseIntPagination(normalizeHeaders(headers));
+
+ const response = {
+ __typename: clientTypenames.BulkImportSourceGroupConnection,
+ nodes: data.importable_data.map((group) => {
+ return makeGroup({
+ id: group.id,
+ webUrl: group.web_url,
+ fullPath: group.full_path,
+ fullName: group.full_name,
+ ...group,
+ ...localStorageCache.get(group.web_url),
});
-
- return response;
+ }),
+ pageInfo: {
+ __typename: clientTypenames.BulkImportPageInfo,
+ ...pagination,
},
- );
+ };
+ return response;
},
availableNamespaces: () =>
axios.get(endpoints.availableNamespaces).then(({ data }) =>
data.map((namespace) => ({
__typename: clientTypenames.AvailableNamespace,
- ...namespace,
+ id: namespace.id,
+ fullPath: namespace.full_path,
})),
),
},
Mutation: {
- setImportTarget(_, { targetNamespace, newName, sourceGroupId }, { client }) {
- checkImportTargetIsValid({
- client,
- sourceGroupId,
- targetNamespace,
- newName,
- });
-
- return makeGroup({
- id: sourceGroupId,
- import_target: {
- target_namespace: targetNamespace,
- new_name: newName,
- id: sourceGroupId,
- },
- });
- },
-
- async setImportProgress(_, { sourceGroupId, status, jobId, importTarget }) {
- if (jobId) {
- groupsManager.updateImportProgress(jobId, status);
- }
-
- return makeGroup({
- id: sourceGroupId,
- progress: {
- id: jobId ?? localProgressId(sourceGroupId),
- status,
- },
- last_import_target: {
- __typename: clientTypenames.BulkImportTarget,
- ...importTarget,
- },
- });
- },
-
async updateImportStatus(_, { id, status: newStatus }, { client, getCacheKey }) {
- groupsManager.updateImportProgress(id, newStatus);
-
const progressItem = client.readFragment({
fragment: bulkImportSourceGroupProgressFragment,
fragmentName: 'BulkImportSourceGroupProgress',
@@ -251,125 +116,58 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
}),
});
- const isInProgress = Boolean(progressItem);
- const { status: currentStatus } = progressItem ?? {};
- if (newStatus === STATUSES.FINISHED && isInProgress && currentStatus !== newStatus) {
- const groups = groupsManager.getImportedGroupsByJobId(id);
+ if (!progressItem) return null;
- groups.forEach(async ({ id: groupId, importTarget }) => {
- client.mutate({
- mutation: setImportTargetMutation,
- variables: {
- sourceGroupId: groupId,
- targetNamespace: importTarget.target_namespace,
- newName: nextName(importTarget.new_name),
- },
- });
- });
- }
+ localStorageCache.updateStatusByJobId(id, newStatus);
return {
__typename: clientTypenames.BulkImportProgress,
+ ...progressItem,
id,
status: newStatus,
};
},
- async addValidationError(_, { sourceGroupId, field, message }, { client }) {
- const {
- data: {
- bulkImportSourceGroup: { validation_errors: validationErrors, ...group },
- },
- } = await client.query({
- query: bulkImportSourceGroupQuery,
- variables: { id: sourceGroupId },
- });
+ async importGroups(_, { importRequests }, { client, getCacheKey }) {
+ const importOperations = importRequests.map((importRequest) => {
+ const group = getGroupFromCache({
+ client,
+ getCacheKey,
+ id: importRequest.sourceGroupId,
+ });
- return {
- ...group,
- validation_errors: [
- ...validationErrors.filter(({ field: f }) => f !== field),
- {
- __typename: clientTypenames.BulkImportValidationError,
- field,
- message,
- },
- ],
- };
- },
+ return {
+ group,
+ ...importRequest,
+ };
+ });
- async removeValidationError(_, { sourceGroupId, field }, { client }) {
const {
- data: {
- bulkImportSourceGroup: { validation_errors: validationErrors, ...group },
- },
- } = await client.query({
- query: bulkImportSourceGroupQuery,
- variables: { id: sourceGroupId },
+ data: { id: jobId },
+ } = await axios.post(endpoints.createBulkImport, {
+ bulk_import: importOperations.map((op) => ({
+ source_type: 'group_entity',
+ source_full_path: op.group.fullPath,
+ destination_namespace: op.targetNamespace,
+ destination_name: op.newName,
+ })),
});
- 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),
- ),
- );
+ return importOperations.map((op) => {
+ const lastImportTarget = {
+ targetNamespace: op.targetNamespace,
+ newName: op.newName,
+ };
- const GROUPS_BEING_SCHEDULED = sourceGroupIds.map((sourceGroupId) =>
- makeGroup({
- id: sourceGroupId,
- progress: {
- id: localProgressId(sourceGroupId),
- status: STATUSES.SCHEDULING,
- },
- }),
- );
-
- const defaultErrorMessage = s__('BulkImport|Importing the group failed');
- axios
- .post(endpoints.createBulkImport, {
- bulk_import: groups.map((group) => ({
- source_type: 'group_entity',
- source_full_path: group.full_path,
- destination_namespace: group.import_target.target_namespace,
- destination_name: group.import_target.new_name,
- })),
- })
- .then(({ data: { id: jobId } }) => {
- groupsManager.createImportState(jobId, {
- status: STATUSES.CREATED,
- groups,
- });
+ const progress = {
+ id: jobId,
+ status: STATUSES.CREATED,
+ };
- return { status: STATUSES.CREATED, jobId };
- })
- .catch((e) => {
- const message = e?.response?.data?.error ?? defaultErrorMessage;
- createFlash({ message });
- return { status: STATUSES.NONE };
- })
- .then((newStatus) =>
- sourceGroupIds.forEach((sourceGroupId, idx) =>
- client.mutate({
- mutation: setImportProgressMutation,
- variables: { sourceGroupId, ...newStatus, importTarget: groups[idx].import_target },
- }),
- ),
- )
- .catch(() => createFlash({ message: defaultErrorMessage }));
+ localStorageCache.set(op.group.webUrl, { progress, lastImportTarget });
- return GROUPS_BEING_SCHEDULED;
+ return makeGroup({ ...op.group, progress, lastImportTarget });
+ });
},
},
};
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql
index 089340b3c48..0d83be7c0e8 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql
@@ -2,22 +2,15 @@
fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup {
id
- web_url
- full_path
- full_name
+ webUrl
+ fullPath
+ fullName
+ lastImportTarget {
+ id
+ targetNamespace
+ newName
+ }
progress {
...BulkImportSourceGroupProgress
}
- import_target {
- target_namespace
- new_name
- }
- last_import_target {
- target_namespace
- new_name
- }
- validation_errors {
- field
- message
- }
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql
deleted file mode 100644
index d95c460c046..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql
+++ /dev/null
@@ -1,9 +0,0 @@
-mutation addValidationError($sourceGroupId: String!, $field: String!, $message: String!) {
- addValidationError(sourceGroupId: $sourceGroupId, field: $field, message: $message) @client {
- id
- validation_errors {
- field
- message
- }
- }
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql
index d8e46329e38..75215471d0f 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql
@@ -1,6 +1,11 @@
-mutation importGroups($sourceGroupIds: [String!]!) {
- importGroups(sourceGroupIds: $sourceGroupIds) @client {
+mutation importGroups($importRequests: [ImportGroupInput!]!) {
+ importGroups(importRequests: $importRequests) @client {
id
+ lastImportTarget {
+ id
+ targetNamespace
+ newName
+ }
progress {
id
status
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql
deleted file mode 100644
index 940bf4dfaac..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql
+++ /dev/null
@@ -1,9 +0,0 @@
-mutation removeValidationError($sourceGroupId: String!, $field: String!) {
- removeValidationError(sourceGroupId: $sourceGroupId, field: $field) @client {
- id
- validation_errors {
- field
- message
- }
- }
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql
deleted file mode 100644
index 43301554de3..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql
+++ /dev/null
@@ -1,23 +0,0 @@
-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
- }
- }
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql
deleted file mode 100644
index 793b60ee378..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-mutation setImportTarget($newName: String!, $targetNamespace: String!, $sourceGroupId: String!) {
- setImportTarget(
- newName: $newName
- targetNamespace: $targetNamespace
- sourceGroupId: $sourceGroupId
- ) @client {
- id
- import_target {
- new_name
- target_namespace
- }
- }
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql
index 5ab9796b50a..b0741dfbe5c 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql
@@ -1,6 +1,6 @@
query availableNamespaces {
availableNamespaces @client {
id
- full_path
+ fullPath
}
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql
deleted file mode 100644
index 0aff23af96d..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql
+++ /dev/null
@@ -1,7 +0,0 @@
-#import "../fragments/bulk_import_source_group_item.fragment.graphql"
-
-query bulkImportSourceGroup($id: ID!) {
- bulkImportSourceGroup(id: $id) @client {
- ...BulkImportSourceGroupItem
- }
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql
deleted file mode 100644
index d6124f84025..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql
+++ /dev/null
@@ -1,9 +0,0 @@
-query groupAndProject($fullPath: ID!) {
- existingGroup: group(fullPath: $fullPath) {
- id
- }
-
- existingProject: project(fullPath: $fullPath) {
- id
- }
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js
new file mode 100644
index 00000000000..09bc7b33692
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js
@@ -0,0 +1,74 @@
+import { debounce, merge } from 'lodash';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+
+const OLD_KEY = 'gl-bulk-imports-import-state';
+export const KEY = 'gl-bulk-imports-import-state-v2';
+export const DEBOUNCE_INTERVAL = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
+
+export class LocalStorageCache {
+ constructor({ storage = window.localStorage } = {}) {
+ this.storage = storage;
+ this.cache = this.loadCacheFromStorage();
+ try {
+ // remove old storage data
+ this.storage.removeItem(OLD_KEY);
+ } catch {
+ // empty catch intended
+ }
+
+ // cache for searching data by jobid
+ this.jobsLookupCache = {};
+ }
+
+ loadCacheFromStorage() {
+ try {
+ return JSON.parse(this.storage.getItem(KEY)) ?? {};
+ } catch {
+ return {};
+ }
+ }
+
+ set(webUrl, data) {
+ this.cache[webUrl] = data;
+ this.saveCacheToStorage();
+ // There are changes to jobIds, drop cache
+ this.jobsLookupCache = {};
+ }
+
+ get(webUrl) {
+ return this.cache[webUrl];
+ }
+
+ getCacheKeysByJobId(jobId) {
+ // this is invoked by polling, so we would like to cache results
+ if (!this.jobsLookupCache[jobId]) {
+ this.jobsLookupCache[jobId] = Object.keys(this.cache).filter(
+ (url) => this.cache[url]?.progress.id === jobId,
+ );
+ }
+
+ return this.jobsLookupCache[jobId];
+ }
+
+ updateStatusByJobId(jobId, status) {
+ this.getCacheKeysByJobId(jobId).forEach((webUrl) =>
+ this.set(webUrl, {
+ ...(this.get(webUrl) ?? {}),
+ progress: {
+ id: jobId,
+ status,
+ },
+ }),
+ );
+ this.saveCacheToStorage();
+ }
+
+ saveCacheToStorage = debounce(() => {
+ try {
+ // storage might be changed in other tab so fetch first
+ this.storage.setItem(KEY, JSON.stringify(merge({}, this.loadCacheFromStorage(), this.cache)));
+ } catch {
+ // empty catch intentional: storage might be unavailable or full
+ }
+ }, DEBOUNCE_INTERVAL);
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
deleted file mode 100644
index 7caa37d9ad4..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import { debounce, merge } from 'lodash';
-
-export const KEY = 'gl-bulk-imports-import-state';
-export const DEBOUNCE_INTERVAL = 200;
-
-export class SourceGroupsManager {
- constructor({ sourceUrl, storage = window.localStorage }) {
- this.sourceUrl = sourceUrl;
-
- this.storage = storage;
- this.importStates = this.loadImportStatesFromStorage();
- }
-
- loadImportStatesFromStorage() {
- try {
- return Object.fromEntries(
- Object.entries(JSON.parse(this.storage.getItem(KEY)) ?? {}).map(([jobId, config]) => {
- // new format of storage
- if (config.groups) {
- return [jobId, config];
- }
-
- return [
- jobId,
- {
- status: config.status,
- groups: [{ id: config.id, importTarget: config.importTarget }],
- },
- ];
- }),
- );
- } catch {
- return {};
- }
- }
-
- createImportState(importId, jobConfig) {
- this.importStates[importId] = {
- status: jobConfig.status,
- groups: jobConfig.groups.map((g) => ({
- importTarget: { ...g.import_target },
- id: g.id,
- })),
- };
- this.saveImportStatesToStorage();
- }
-
- updateImportProgress(importId, status) {
- const currentState = this.importStates[importId];
- if (!currentState) {
- return;
- }
-
- currentState.status = status;
- this.saveImportStatesToStorage();
- }
-
- getImportedGroupsByJobId(jobId) {
- return this.importStates[jobId]?.groups ?? [];
- }
-
- getImportStateFromStorageByGroupId(groupId) {
- const [jobId, importState] =
- Object.entries(this.importStates)
- .reverse()
- .find(([, state]) => state.groups.some((g) => g.id === groupId)) ?? [];
-
- if (!jobId) {
- return null;
- }
-
- const group = importState.groups.find((g) => g.id === groupId);
- return { jobId, importState: { ...group, status: importState.status } };
- }
-
- saveImportStatesToStorage = debounce(() => {
- try {
- // storage might be changed in other tab so fetch first
- this.storage.setItem(
- KEY,
- JSON.stringify(merge({}, this.loadImportStatesFromStorage(), this.importStates)),
- );
- } catch {
- // empty catch intentional: storage might be unavailable or full
- }
- }, DEBOUNCE_INTERVAL);
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
index 6ef4bbafec0..b8dd79a5000 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
@@ -1,11 +1,11 @@
type ClientBulkImportAvailableNamespace {
id: ID!
- full_path: String!
+ fullPath: String!
}
type ClientBulkImportTarget {
- target_namespace: String!
- new_name: String!
+ targetNamespace: String!
+ newName: String!
}
type ClientBulkImportSourceGroupConnection {
@@ -14,7 +14,7 @@ type ClientBulkImportSourceGroupConnection {
}
type ClientBulkImportProgress {
- id: ID
+ id: ID!
status: String!
}
@@ -25,13 +25,11 @@ type ClientBulkImportValidationError {
type ClientBulkImportSourceGroup {
id: ID!
- web_url: String!
- full_path: String!
- full_name: String!
- progress: ClientBulkImportProgress!
- import_target: ClientBulkImportTarget!
- last_import_target: ClientBulkImportTarget
- validation_errors: [ClientBulkImportValidationError!]!
+ webUrl: String!
+ fullPath: String!
+ fullName: String!
+ lastImportTarget: ClientBulkImportTarget
+ progress: ClientBulkImportProgress
}
type ClientBulkImportPageInfo {
@@ -41,8 +39,13 @@ type ClientBulkImportPageInfo {
totalPages: Int!
}
+type ClientBulkImportNamespaceSuggestion {
+ id: ID!
+ exists: Boolean!
+ suggestions: [String!]!
+}
+
extend type Query {
- bulkImportSourceGroup(id: ID!): ClientBulkImportSourceGroup
bulkImportSourceGroups(
page: Int!
perPage: Int!
@@ -51,26 +54,13 @@ extend type Query {
availableNamespaces: [ClientBulkImportAvailableNamespace!]!
}
-input InputTargetInput {
- target_namespace: String!
- new_name: String!
+input ImportRequestInput {
+ sourceGroupId: ID!
+ targetNamespace: String!
+ newName: String!
}
extend type Mutation {
- setNewName(newName: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
- setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
- importGroups(sourceGroupIds: [ID!]!): [ClientBulkImportSourceGroup!]!
- setImportProgress(
- id: ID
- status: String!
- jobId: String
- importTarget: ImportTargetInput!
- ): ClientBulkImportSourceGroup!
- updateImportProgress(id: ID, status: String!): ClientBulkImportProgress
- addValidationError(
- sourceGroupId: ID!
- field: String!
- message: String!
- ): ClientBulkImportSourceGroup!
- removeValidationError(sourceGroupId: ID!, field: String!): ClientBulkImportSourceGroup!
+ importGroups(importRequests: [ImportRequestInput!]!): [ClientBulkImportSourceGroup!]!
+ updateImportStatus(id: ID, status: String!): ClientBulkImportProgress
}
diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js
index 07b839c5c82..67a7258d504 100644
--- a/app/assets/javascripts/import_entities/import_groups/index.js
+++ b/app/assets/javascripts/import_entities/import_groups/index.js
@@ -17,7 +17,6 @@ export function mountImportGroupsApp(mountElement) {
jobsPath,
sourceUrl,
groupPathRegex,
- groupUrlErrorMessage,
} = mountElement.dataset;
const apolloProvider = new VueApollo({
defaultClient: createApolloClient({
@@ -26,7 +25,6 @@ export function mountImportGroupsApp(mountElement) {
status: statusPath,
availableNamespaces: availableNamespacesPath,
createBulkImport: createBulkImportPath,
- jobs: jobsPath,
},
}),
});
@@ -38,8 +36,8 @@ export function mountImportGroupsApp(mountElement) {
return createElement(ImportTable, {
props: {
sourceUrl,
+ jobsPath,
groupPathRegex: new RegExp(`^(${groupPathRegex})$`),
- groupUrlErrorMessage,
},
});
},
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/services/status_poller.js
index 0297b3d3428..ba0f2bb947a 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
+++ b/app/assets/javascripts/import_entities/import_groups/services/status_poller.js
@@ -32,4 +32,8 @@ export class StatusPoller {
startPolling() {
this.eTagPoll.makeRequest();
}
+
+ stopPolling() {
+ this.eTagPoll.stop();
+ }
}
diff --git a/app/assets/javascripts/import_entities/import_groups/utils.js b/app/assets/javascripts/import_entities/import_groups/utils.js
index a1baeaf39dd..1d0ab75e1cb 100644
--- a/app/assets/javascripts/import_entities/import_groups/utils.js
+++ b/app/assets/javascripts/import_entities/import_groups/utils.js
@@ -1,22 +1,25 @@
import { STATUSES } from '../constants';
import { NEW_NAME_FIELD } from './constants';
-export function isNameValid(group, validationRegex) {
- return validationRegex.test(group.import_target[NEW_NAME_FIELD]);
+export function isNameValid(importTarget, validationRegex) {
+ return validationRegex.test(importTarget[NEW_NAME_FIELD]);
}
-export function getInvalidNameValidationMessage(group) {
- return group.validation_errors.find(({ field }) => field === NEW_NAME_FIELD)?.message;
-}
-
-export function isInvalid(group, validationRegex) {
- return Boolean(!isNameValid(group, validationRegex) || getInvalidNameValidationMessage(group));
+export function getInvalidNameValidationMessage(importTarget) {
+ return importTarget.validationErrors?.find(({ field }) => field === NEW_NAME_FIELD)?.message;
}
export function isFinished(group) {
- return group.progress.status === STATUSES.FINISHED;
+ return [STATUSES.FINISHED, STATUSES.FAILED].includes(group.progress?.status);
}
export function isAvailableForImport(group) {
- return [STATUSES.NONE, STATUSES.FINISHED].some((status) => group.progress.status === status);
+ return !group.progress || isFinished(group);
+}
+
+export function isSameTarget(importTarget) {
+ return (target) =>
+ target !== importTarget &&
+ target.newName.toLowerCase() === importTarget.newName.toLowerCase() &&
+ target.targetNamespace.id === importTarget.targetNamespace.id;
}
diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index 0cd3519bcec..b9f0b5012ac 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -46,10 +46,6 @@ export default {
return `${this.filter}-${this.repositories.length}-${this.pageInfo.page}`;
},
- availableNamespaces() {
- return this.namespaces.map(({ fullPath }) => fullPath);
- },
-
importAllButtonText() {
if (this.isImportingAnyRepo) {
return n__('Importing %d repository', 'Importing %d repositories', this.importingRepoCount);
@@ -167,7 +163,7 @@ export default {
<provider-repo-table-row
:key="repo.importSource.providerLink"
:repo="repo"
- :available-namespaces="availableNamespaces"
+ :available-namespaces="namespaces"
:user-namespace="defaultTargetNamespace"
/>
</template>
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index a97af5367fb..c3d0ca4ed8c 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -128,17 +128,17 @@ export default {
<gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="ns in namespaces"
- :key="ns"
+ :key="ns.fullPath"
data-qa-selector="target_group_dropdown_item"
- :data-qa-group-name="ns"
- @click="updateImportTarget({ targetNamespace: ns })"
+ :data-qa-group-name="ns.fullPath"
+ @click="updateImportTarget({ targetNamespace: ns.fullPath })"
>
- {{ ns }}
+ {{ ns.fullPath }}
</gl-dropdown-item>
<gl-dropdown-divider />
</template>
<gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
- <gl-dropdown-item @click="updateImportTarget({ targetNamespace: ns })">{{
+ <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{
userNamespace
}}</gl-dropdown-item>
</import-group-dropdown>
diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js
index c58be202043..8ce73be6e74 100644
--- a/app/assets/javascripts/pages/groups/new/group_path_validator.js
+++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js
@@ -3,7 +3,7 @@ import { debounce } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
import InputValidator from '~/validators/input_validator';
-import fetchGroupPathAvailability from './fetch_group_path_availability';
+import { getGroupPathAvailability } from '~/rest_api';
const debounceTimeoutDuration = 1000;
const invalidInputClass = 'gl-field-error-outline';
@@ -45,7 +45,7 @@ export default class GroupPathValidator extends InputValidator {
if (inputDomElement.checkValidity() && groupPath.length > 1) {
GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector);
- fetchGroupPathAvailability(groupPath, parentId)
+ getGroupPathAvailability(groupPath, parentId)
.then(({ data }) => data)
.then((data) => {
GroupPathValidator.setInputState(inputDomElement, !data.exists);
diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js
index 29642b6633f..48a15954035 100644
--- a/app/assets/javascripts/rest_api.js
+++ b/app/assets/javascripts/rest_api.js
@@ -3,6 +3,7 @@ export * from './api/projects_api';
export * from './api/user_api';
export * from './api/markdown_api';
export * from './api/bulk_imports_api';
+export * from './api/namespaces_api';
// Note: It's not possible to spy on methods imported from this file in
// Jest tests.