diff options
Diffstat (limited to 'app/assets/javascripts/import_entities/import_groups/components')
3 files changed, 364 insertions, 294 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 cb7e3ef9632..db44be2bcd7 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 @@ -10,20 +10,25 @@ import { GlSearchBoxByClick, GlSprintf, GlSafeHtmlDirective as SafeHtml, - GlTooltip, + GlTable, + GlFormCheckbox, } from '@gitlab/ui'; import { s__, __, n__ } from '~/locale'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import ImportStatus from '../../components/import_status.vue'; import { STATUSES } from '../../constants'; import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql'; -import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql'; -import setTargetNamespaceMutation from '../graphql/mutations/set_target_namespace.mutation.graphql'; +import setImportTargetMutation from '../graphql/mutations/set_import_target.mutation.graphql'; import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; -import ImportTableRow from './import_table_row.vue'; +import { isInvalid } from '../utils'; +import ImportTargetCell from './import_target_cell.vue'; const PAGE_SIZES = [20, 50, 100]; const DEFAULT_PAGE_SIZE = PAGE_SIZES[0]; +const DEFAULT_TH_CLASSES = + 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!'; +const DEFAULT_TD_CLASSES = 'gl-vertical-align-top!'; export default { components: { @@ -35,9 +40,11 @@ export default { GlLink, GlLoadingIcon, GlSearchBoxByClick, + GlFormCheckbox, GlSprintf, - GlTooltip, - ImportTableRow, + GlTable, + ImportStatus, + ImportTargetCell, PaginationLinks, }, directives: { @@ -53,6 +60,10 @@ export default { type: RegExp, required: true, }, + groupUrlErrorMessage: { + type: String, + required: true, + }, }, data() { @@ -60,6 +71,7 @@ export default { filter: '', page: 1, perPage: DEFAULT_PAGE_SIZE, + selectedGroups: [], }; }, @@ -73,21 +85,58 @@ export default { availableNamespaces: availableNamespacesQuery, }, + fields: [ + { + key: 'selected', + label: '', + // eslint-disable-next-line @gitlab/require-i18n-strings + thClass: `${DEFAULT_TH_CLASSES} gl-w-3 gl-pr-3!`, + // eslint-disable-next-line @gitlab/require-i18n-strings + tdClass: `${DEFAULT_TD_CLASSES} gl-pr-3!`, + }, + { + key: 'web_url', + 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', + label: s__('BulkImport|To new group'), + thClass: `${DEFAULT_TH_CLASSES} import-jobs-to-col`, + tdClass: DEFAULT_TD_CLASSES, + }, + { + key: 'progress', + label: __('Status'), + thClass: `${DEFAULT_TH_CLASSES} import-jobs-status-col`, + tdClass: DEFAULT_TD_CLASSES, + tdAttr: { 'data-qa-selector': 'import_status_indicator' }, + }, + { + key: 'actions', + label: '', + thClass: `${DEFAULT_TH_CLASSES} import-jobs-cta-col`, + tdClass: DEFAULT_TD_CLASSES, + }, + ], + computed: { groups() { return this.bulkImportSourceGroups?.nodes ?? []; }, - hasGroupsWithValidationError() { - return this.groups.some((g) => g.validation_errors.length); + hasSelectedGroups() { + return this.selectedGroups.length > 0; }, - availableGroupsForImport() { - return this.groups.filter((g) => g.progress.status === STATUSES.NONE); + hasAllAvailableGroupsSelected() { + return this.selectedGroups.length === this.availableGroupsForImport.length; }, - isImportAllButtonDisabled() { - return this.hasGroupsWithValidationError || this.availableGroupsForImport.length === 0; + availableGroupsForImport() { + return this.groups.filter((g) => g.progress.status === STATUSES.NONE && !this.isInvalid(g)); }, humanizedTotal() { @@ -117,7 +166,7 @@ export default { total: 0, }; const start = (page - 1) * perPage + 1; - const end = start + (this.bulkImportSourceGroups.nodes?.length ?? 0) - 1; + const end = start + this.groups.length - 1; return { start, end, total }; }, @@ -127,9 +176,39 @@ export default { filter() { this.page = 1; }, + groups() { + const table = this.getTableRef(); + this.groups.forEach((g, idx) => { + if (this.selectedGroups.includes(g)) { + this.$nextTick(() => { + table.selectRow(idx); + }); + } + }); + this.selectedGroups = []; + }, }, methods: { + qaRowAttributes(group, type) { + if (type === 'row') { + return { + 'data-qa-selector': 'import_item', + 'data-qa-source-group': group.full_path, + }; + } + + return {}; + }, + + isAlreadyImported(group) { + return group.progress.status !== STATUSES.NONE; + }, + + isInvalid(group) { + return isInvalid(group, this.groupPathRegex); + }, + groupsCount(count) { return n__('%d group', '%d groups', count); }, @@ -138,17 +217,10 @@ export default { this.page = page; }, - updateTargetNamespace(sourceGroupId, targetNamespace) { + updateImportTarget(sourceGroupId, targetNamespace, newName) { this.$apollo.mutate({ - mutation: setTargetNamespaceMutation, - variables: { sourceGroupId, targetNamespace }, - }); - }, - - updateNewName(sourceGroupId, newName) { - this.$apollo.mutate({ - mutation: setNewNameMutation, - variables: { sourceGroupId, newName }, + mutation: setImportTargetMutation, + variables: { sourceGroupId, targetNamespace, newName }, }); }, @@ -159,13 +231,33 @@ export default { }); }, - importAllGroups() { - this.importGroups(this.availableGroupsForImport.map((g) => g.id)); + importSelectedGroups() { + this.importGroups(this.selectedGroups.map((g) => g.id)); }, setPageSize(size) { this.perPage = size; }, + + getTableRef() { + // Acquire reference to BTable to manipulate selection + // issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531 + // refs are not reactive, so do not use computed here + return this.$refs.table?.$children[0]; + }, + + preventSelectingAlreadyImportedGroups(updatedSelection) { + if (updatedSelection) { + this.selectedGroups = updatedSelection; + } + + const table = this.getTableRef(); + this.groups.forEach((group, idx) => { + if (table.isRowSelected(idx) && (this.isAlreadyImported(group) || this.isInvalid(group))) { + table.unselectRow(idx); + } + }); + }, }, gitlabLogo: window.gon.gitlab_logo, @@ -180,28 +272,6 @@ export default { > <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" /> {{ s__('BulkImport|Import groups from GitLab') }} - <div ref="importAllButtonWrapper" class="gl-ml-auto"> - <gl-button - v-if="!$apollo.loading && hasGroups" - :disabled="isImportAllButtonDisabled" - variant="confirm" - @click="importAllGroups" - > - <gl-sprintf :message="s__('BulkImport|Import %{groups}')"> - <template #groups> - {{ groupsCount(availableGroupsForImport.length) }} - </template> - </gl-sprintf> - </gl-button> - </div> - <gl-tooltip v-if="isImportAllButtonDisabled" :target="() => $refs.importAllButtonWrapper"> - <template v-if="hasGroupsWithValidationError"> - {{ s__('BulkImport|One or more groups has validation errors') }} - </template> - <template v-else> - {{ s__('BulkImport|No groups on this page are available for import') }} - </template> - </gl-tooltip> </h1> <div class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" @@ -247,27 +317,92 @@ export default { :description="s__('Check your source instance permissions.')" /> <template v-else> - <table class="gl-w-full" data-qa-selector="import_table"> - <thead class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1"> - <th class="gl-py-4 import-jobs-from-col">{{ s__('BulkImport|From source group') }}</th> - <th class="gl-py-4 import-jobs-to-col">{{ s__('BulkImport|To new group') }}</th> - <th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th> - <th class="gl-py-4 import-jobs-cta-col"></th> - </thead> - <tbody class="gl-vertical-align-top"> - <template v-for="group in bulkImportSourceGroups.nodes"> - <import-table-row - :key="group.id" - :group="group" - :available-namespaces="availableNamespaces" - :group-path-regex="groupPathRegex" - @update-target-namespace="updateTargetNamespace(group.id, $event)" - @update-new-name="updateNewName(group.id, $event)" - @import-group="importGroups([group.id])" - /> + <div + class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-p-4 gl-display-flex gl-align-items-center" + > + <gl-sprintf :message="__('%{count} selected')"> + <template #count> + {{ selectedGroups.length }} </template> - </tbody> - </table> + </gl-sprintf> + <gl-button + category="primary" + variant="confirm" + class="gl-ml-4" + :disabled="!hasSelectedGroups" + @click="importSelectedGroups" + >{{ s__('BulkImport|Import selected') }}</gl-button + > + </div> + <gl-table + ref="table" + class="gl-w-full" + data-qa-selector="import_table" + tbody-tr-class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid" + :tbody-tr-attr="qaRowAttributes" + :items="groups" + :fields="$options.fields" + selectable + select-mode="multi" + selected-variant="primary" + @row-selected="preventSelectingAlreadyImportedGroups" + > + <template #head(selected)="{ selectAllRows, clearSelected }"> + <gl-form-checkbox + :key="`checkbox-${selectedGroups.length}`" + class="gl-h-7 gl-pt-3" + :checked="hasSelectedGroups" + :indeterminate="hasSelectedGroups && !hasAllAvailableGroupsSelected" + @change="hasAllAvailableGroupsSelected ? clearSelected() : selectAllRows()" + /> + </template> + <template #cell(selected)="{ rowSelected, selectRow, unselectRow, item: group }"> + <gl-form-checkbox + class="gl-h-7 gl-pt-3" + :checked="rowSelected" + :disabled="isAlreadyImported(group) || isInvalid(group)" + @change="rowSelected ? unselectRow() : selectRow()" + /> + </template> + <template #cell(web_url)="{ value: web_url, item: { full_path } }"> + <gl-link + :href="web_url" + target="_blank" + class="gl-display-inline-flex gl-align-items-center gl-h-7" + > + {{ full_path }} <gl-icon name="external-link" /> + </gl-link> + </template> + <template #cell(import_target)="{ 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) + " + /> + </template> + <template #cell(progress)="{ value: { status } }"> + <import-status :status="status" class="gl-line-height-32" /> + </template> + <template #cell(actions)="{ item: group }"> + <gl-button + v-if="!isAlreadyImported(group)" + :disabled="isInvalid(group)" + variant="confirm" + category="secondary" + data-qa-selector="import_group_button" + @click="importGroups([group.id])" + > + {{ __('Import') }} + </gl-button> + </template> + </gl-table> <div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center"> <pagination-links :change="setPage" diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue deleted file mode 100644 index 1c3ede769e0..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue +++ /dev/null @@ -1,227 +0,0 @@ -<script> -import { - GlButton, - GlDropdownDivider, - GlDropdownItem, - GlDropdownSectionHeader, - GlIcon, - GlLink, - GlFormInput, -} from '@gitlab/ui'; -import { joinPaths } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; -import ImportGroupDropdown from '../../components/group_dropdown.vue'; -import ImportStatus from '../../components/import_status.vue'; -import { STATUSES } from '../../constants'; -import addValidationErrorMutation from '../graphql/mutations/add_validation_error.mutation.graphql'; -import removeValidationErrorMutation from '../graphql/mutations/remove_validation_error.mutation.graphql'; -import groupAndProjectQuery from '../graphql/queries/groupAndProject.query.graphql'; - -const DEBOUNCE_INTERVAL = 300; - -export default { - components: { - ImportStatus, - ImportGroupDropdown, - GlButton, - GlDropdownDivider, - GlDropdownItem, - GlDropdownSectionHeader, - GlLink, - GlIcon, - GlFormInput, - }, - props: { - group: { - type: Object, - required: true, - }, - availableNamespaces: { - type: Array, - required: true, - }, - groupPathRegex: { - type: RegExp, - required: true, - }, - }, - - apollo: { - existingGroupAndProject: { - query: groupAndProjectQuery, - debounce: DEBOUNCE_INTERVAL, - variables() { - return { - fullPath: this.fullPath, - }; - }, - update({ existingGroup, existingProject }) { - const variables = { - field: 'new_name', - sourceGroupId: this.group.id, - }; - - if (!existingGroup && !existingProject) { - this.$apollo.mutate({ - mutation: removeValidationErrorMutation, - variables, - }); - } else { - this.$apollo.mutate({ - mutation: addValidationErrorMutation, - variables: { - ...variables, - message: this.$options.i18n.NAME_ALREADY_EXISTS, - }, - }); - } - }, - skip() { - return !this.isNameValid || this.isAlreadyImported; - }, - }, - }, - - computed: { - availableNamespaceNames() { - return this.availableNamespaces.map((ns) => ns.full_path); - }, - - importTarget() { - return this.group.import_target; - }, - - invalidNameValidationMessage() { - return this.group.validation_errors.find(({ field }) => field === 'new_name')?.message; - }, - - isInvalid() { - return Boolean(!this.isNameValid || this.invalidNameValidationMessage); - }, - - isNameValid() { - return this.groupPathRegex.test(this.importTarget.new_name); - }, - - isAlreadyImported() { - return this.group.progress.status !== STATUSES.NONE; - }, - - isFinished() { - return this.group.progress.status === STATUSES.FINISHED; - }, - - fullPath() { - return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`; - }, - - absolutePath() { - return joinPaths(gon.relative_url_root || '/', this.fullPath); - }, - }, - - i18n: { - NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'), - }, -}; -</script> - -<template> - <tr - class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid" - data-qa-selector="import_item" - :data-qa-source-group="group.full_path" - > - <td class="gl-p-4"> - <gl-link - :href="group.web_url" - target="_blank" - class="gl-display-flex gl-align-items-center gl-h-7" - > - {{ group.full_path }} <gl-icon name="external-link" /> - </gl-link> - </td> - <td class="gl-p-4"> - <gl-link - v-if="isFinished" - class="gl-display-flex gl-align-items-center gl-h-7" - :href="absolutePath" - > - {{ fullPath }} - </gl-link> - - <div - v-else - class="import-entities-target-select gl-display-flex gl-align-items-stretch" - :class="{ - disabled: isAlreadyImported, - }" - > - <import-group-dropdown - #default="{ namespaces }" - :text="importTarget.target_namespace" - :disabled="isAlreadyImported" - :namespaces="availableNamespaceNames" - toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" - class="import-entities-namespace-dropdown gl-h-7 gl-flex-grow-1" - data-qa-selector="target_namespace_selector_dropdown" - > - <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{ - s__('BulkImport|No parent') - }}</gl-dropdown-item> - <template v-if="namespaces.length"> - <gl-dropdown-divider /> - <gl-dropdown-section-header> - {{ s__('BulkImport|Existing groups') }} - </gl-dropdown-section-header> - <gl-dropdown-item - v-for="ns in namespaces" - :key="ns" - data-qa-selector="target_group_dropdown_item" - :data-qa-group-name="ns" - @click="$emit('update-target-namespace', ns)" - > - {{ ns }} - </gl-dropdown-item> - </template> - </import-group-dropdown> - <div - class="import-entities-target-select-separator 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" - > - / - </div> - <div class="gl-flex-grow-1"> - <gl-form-input - class="gl-rounded-top-left-none gl-rounded-bottom-left-none" - :class="{ 'is-invalid': isInvalid && !isAlreadyImported }" - :disabled="isAlreadyImported" - :value="importTarget.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"> - {{ __('Please choose a group URL with no special characters.') }} - </template> - <template v-else-if="invalidNameValidationMessage"> - {{ invalidNameValidationMessage }} - </template> - </p> - </div> - </div> - </td> - <td class="gl-p-4 gl-white-space-nowrap" data-qa-selector="import_status_indicator"> - <import-status :status="group.progress.status" class="gl-mt-2" /> - </td> - <td class="gl-p-4"> - <gl-button - v-if="!isAlreadyImported" - :disabled="isInvalid" - variant="confirm" - category="secondary" - data-qa-selector="import_group_button" - @click="$emit('import-group')" - >{{ __('Import') }}</gl-button - > - </td> - </tr> -</template> 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 new file mode 100644 index 00000000000..7359d4f239e --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue @@ -0,0 +1,162 @@ +<script> +import { + GlDropdownDivider, + GlDropdownItem, + GlDropdownSectionHeader, + GlLink, + GlFormInput, +} from '@gitlab/ui'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; +import ImportGroupDropdown from '../../components/group_dropdown.vue'; +import { STATUSES } from '../../constants'; +import { isInvalid, getInvalidNameValidationMessage, isNameValid } from '../utils'; + +export default { + components: { + ImportGroupDropdown, + GlDropdownDivider, + GlDropdownItem, + GlDropdownSectionHeader, + GlLink, + GlFormInput, + }, + props: { + group: { + type: Object, + required: true, + }, + availableNamespaces: { + 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; + }, + + invalidNameValidationMessage() { + return getInvalidNameValidationMessage(this.group); + }, + + isInvalid() { + return isInvalid(this.group, this.groupPathRegex); + }, + + isNameValid() { + return isNameValid(this.group, this.groupPathRegex); + }, + + isAlreadyImported() { + return this.group.progress.status !== STATUSES.NONE; + }, + + isFinished() { + return this.group.progress.status === STATUSES.FINISHED; + }, + + fullPath() { + return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`; + }, + + absolutePath() { + return joinPaths(gon.relative_url_root || '/', this.fullPath); + }, + }, + + i18n: { + NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'), + }, +}; +</script> + +<template> + <gl-link + v-if="isFinished" + class="gl-display-inline-flex gl-align-items-center gl-h-7" + :href="absolutePath" + > + {{ fullPath }} + </gl-link> + + <div + v-else + class="gl-display-flex gl-align-items-stretch" + :class="{ + disabled: isAlreadyImported, + }" + > + <import-group-dropdown + #default="{ namespaces }" + :text="importTarget.target_namespace" + :disabled="isAlreadyImported" + :namespaces="availableNamespaceNames" + 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', '')">{{ + s__('BulkImport|No parent') + }}</gl-dropdown-item> + <template v-if="namespaces.length"> + <gl-dropdown-divider /> + <gl-dropdown-section-header> + {{ s__('BulkImport|Existing groups') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="ns in namespaces" + :key="ns" + data-qa-selector="target_group_dropdown_item" + :data-qa-group-name="ns" + @click="$emit('update-target-namespace', ns)" + > + {{ ns }} + </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': isAlreadyImported, + 'gl-border-gray-200': !isAlreadyImported, + }" + > + / + </div> + <div class="gl-flex-grow-1"> + <gl-form-input + class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + :class="{ + 'gl-inset-border-1-gray-200!': !isAlreadyImported, + 'gl-inset-border-1-gray-100!': isAlreadyImported, + 'is-invalid': isInvalid && !isAlreadyImported, + }" + :disabled="isAlreadyImported" + :value="importTarget.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> + </div> + </div> +</template> |