diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-18 16:16:36 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-18 16:16:36 +0300 |
commit | 311b0269b4eb9839fa63f80c8d7a58f32b8138a0 (patch) | |
tree | 07e7870bca8aed6d61fdcc810731c50d2c40af47 /app/assets/javascripts/import_entities | |
parent | 27909cef6c4170ed9205afa7426b8d3de47cbb0c (diff) |
Add latest changes from gitlab-org/gitlab@14-5-stable-eev14.5.0-rc42
Diffstat (limited to 'app/assets/javascripts/import_entities')
24 files changed, 537 insertions, 691 deletions
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..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')"> 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 c08cf909a00..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,133 +116,62 @@ 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 }); + }); }, }, }; } export const createApolloClient = ({ sourceUrl, endpoints }) => - createDefaultClient( - createResolvers({ sourceUrl, endpoints }), - { assumeImmutableResults: true }, - typeDefs, - ); + createDefaultClient(createResolvers({ sourceUrl, endpoints }), { typeDefs }); 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> |