summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/import_entities/import_groups/components/import_table.vue')
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue310
1 files changed, 251 insertions, 59 deletions
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..ec6025c84bb 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,
@@ -329,7 +517,7 @@ export default {
<gl-empty-state
v-else-if="!hasGroups"
:title="s__('BulkImport|You have no groups to import')"
- :description="s__('Check your source instance permissions.')"
+ :description="__('Check your source instance permissions.')"
/>
<template v-else>
<div
@@ -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')">