diff options
Diffstat (limited to 'app/assets/javascripts/import_entities/import_groups')
7 files changed, 324 insertions, 96 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 7c5f48dcafc..f337520b0db 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 @@ -1,13 +1,15 @@ <script> import { GlEmptyState, + GlDropdown, + GlDropdownItem, GlIcon, GlLink, GlLoadingIcon, GlSearchBoxByClick, GlSprintf, } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import importGroupMutation from '../graphql/mutations/import_group.mutation.graphql'; import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql'; @@ -16,9 +18,14 @@ import availableNamespacesQuery from '../graphql/queries/available_namespaces.qu import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; import ImportTableRow from './import_table_row.vue'; +const PAGE_SIZES = [20, 50, 100]; +const DEFAULT_PAGE_SIZE = PAGE_SIZES[0]; + export default { components: { GlEmptyState, + GlDropdown, + GlDropdownItem, GlIcon, GlLink, GlLoadingIcon, @@ -33,12 +40,17 @@ export default { type: String, required: true, }, + groupPathRegex: { + type: RegExp, + required: true, + }, }, data() { return { filter: '', page: 1, + perPage: DEFAULT_PAGE_SIZE, }; }, @@ -46,13 +58,17 @@ export default { bulkImportSourceGroups: { query: bulkImportSourceGroupsQuery, variables() { - return { page: this.page, filter: this.filter }; + return { page: this.page, filter: this.filter, perPage: this.perPage }; }, }, availableNamespaces: availableNamespacesQuery, }, computed: { + humanizedTotal() { + return this.paginationInfo.total >= 1000 ? __('1000+') : this.paginationInfo.total; + }, + hasGroups() { return this.bulkImportSourceGroups?.nodes?.length > 0; }, @@ -113,14 +129,20 @@ export default { variables: { sourceGroupId }, }); }, + + setPageSize(size) { + this.perPage = size; + }, }, + + PAGE_SIZES, }; </script> <template> <div> <div - class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex gl-align-items-center" + class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" > <span> <gl-sprintf v-if="!$apollo.loading && hasGroups" :message="statusMessage"> @@ -147,12 +169,17 @@ export default { </div> <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" /> <template v-else> - <gl-empty-state v-if="hasEmptyFilter" :title="__('Sorry, your filter produced no results')" /> + <gl-empty-state + v-if="hasEmptyFilter" + :title="__('Sorry, your filter produced no results')" + :description="__('To widen your search, change or remove filters above.')" + /> <gl-empty-state v-else-if="!hasGroups" - :title="s__('BulkImport|No groups available for import')" + :title="s__('BulkImport|You have no groups to import')" + :description="s__('Check your source instance permissions.')" /> - <div v-else class="gl-display-flex gl-flex-direction-column gl-align-items-center"> + <template v-else> <table class="gl-w-full"> <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> @@ -160,12 +187,13 @@ export default { <th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th> <th class="gl-py-4 import-jobs-cta-col"></th> </thead> - <tbody> + <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="importGroup(group.id)" @@ -173,12 +201,50 @@ export default { </template> </tbody> </table> - <pagination-links - :change="setPage" - :page-info="bulkImportSourceGroups.pageInfo" - class="gl-mt-3" - /> - </div> + <div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center"> + <pagination-links + :change="setPage" + :page-info="bulkImportSourceGroups.pageInfo" + class="gl-m-0" + /> + <gl-dropdown category="tertiary" class="gl-ml-auto"> + <template #button-content> + <span class="font-weight-bold"> + <gl-sprintf :message="__('%{count} items per page')"> + <template #count> + {{ perPage }} + </template> + </gl-sprintf> + </span> + <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" /> + </template> + <gl-dropdown-item + v-for="size in $options.PAGE_SIZES" + :key="size" + @click="setPageSize(size)" + > + <gl-sprintf :message="__('%{count} items per page')"> + <template #count> + {{ size }} + </template> + </gl-sprintf> + </gl-dropdown-item> + </gl-dropdown> + <div class="gl-ml-2"> + <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')"> + <template #start> + {{ paginationInfo.start }} + </template> + <template #end> + {{ paginationInfo.end }} + </template> + <template #total> + {{ humanizedTotal }} + </template> + </gl-sprintf> + </div> + </div> + </template> </template> </div> </template> 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 index 1707ab10c89..aed879e75fb 100644 --- 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 @@ -1,15 +1,29 @@ <script> -import { GlButton, GlIcon, GlLink, GlFormInput } from '@gitlab/ui'; +import { + GlButton, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlDropdownSectionHeader, + GlIcon, + GlLink, + GlFormInput, +} from '@gitlab/ui'; import { joinPaths } from '~/lib/utils/url_utility'; -import Select2Select from '~/vue_shared/components/select2_select.vue'; import ImportStatus from '../../components/import_status.vue'; import { STATUSES } from '../../constants'; +import groupQuery from '../graphql/queries/group.query.graphql'; + +const DEBOUNCE_INTERVAL = 300; export default { components: { - Select2Select, ImportStatus, GlButton, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlDropdownSectionHeader, GlLink, GlIcon, GlFormInput, @@ -23,9 +37,41 @@ export default { type: Array, required: true, }, + groupPathRegex: { + type: RegExp, + required: true, + }, + }, + + apollo: { + existingGroup: { + query: groupQuery, + debounce: DEBOUNCE_INTERVAL, + variables() { + return { + fullPath: this.fullPath, + }; + }, + skip() { + return !this.isNameValid || this.isAlreadyImported; + }, + }, }, + computed: { - isDisabled() { + importTarget() { + return this.group.import_target; + }, + + isInvalid() { + return Boolean(!this.isNameValid || this.existingGroup); + }, + + isNameValid() { + return this.groupPathRegex.test(this.importTarget.new_name); + }, + + isAlreadyImported() { return this.group.status !== STATUSES.NONE; }, @@ -33,61 +79,89 @@ export default { return this.group.status === STATUSES.FINISHED; }, - select2Options() { - return { - data: this.availableNamespaces.map((namespace) => ({ - id: namespace.full_path, - text: namespace.full_path, - })), - }; - }, - }, - methods: { - getPath(group) { - return `${group.import_target.target_namespace}/${group.import_target.new_name}`; + fullPath() { + return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`; }, - getFullPath(group) { - return joinPaths(gon.relative_url_root || '/', this.getPath(group)); + absolutePath() { + return joinPaths(gon.relative_url_root || '/', this.fullPath); }, }, }; </script> <template> - <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1"> + <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid"> <td class="gl-p-4"> - <gl-link :href="group.web_url" target="_blank"> + <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" :href="getFullPath(group)">{{ getPath(group) }}</gl-link> + <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: isDisabled, + disabled: isAlreadyImported, }" > - <select2-select - :disabled="isDisabled" - :options="select2Options" - :value="group.import_target.target_namespace" - @input="$emit('update-target-namespace', $event)" - /> + <gl-dropdown + :text="importTarget.target_namespace" + :disabled="isAlreadyImported" + toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + class="import-entities-namespace-dropdown gl-h-7 gl-flex-fill-1" + > + <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{ + s__('BulkImport|No parent') + }}</gl-dropdown-item> + <template v-if="availableNamespaces.length"> + <gl-dropdown-divider /> + <gl-dropdown-section-header> + {{ s__('BulkImport|Existing groups') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="ns in availableNamespaces" + :key="ns.full_path" + @click="$emit('update-target-namespace', ns.full_path)" + > + {{ ns.full_path }} + </gl-dropdown-item> + </template> + </gl-dropdown> <div - class="import-entities-target-select-separator gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1" + 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> - <gl-form-input - class="gl-rounded-top-left-none gl-rounded-bottom-left-none" - :disabled="isDisabled" - :value="group.import_target.new_name" - @input="$emit('update-new-name', $event)" - /> + <div class="gl-flex-fill-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="existingGroup"> + {{ s__('BulkImport|Name already exists.') }} + </template> + </p> + </div> </div> </td> <td class="gl-p-4 gl-white-space-nowrap"> @@ -95,7 +169,8 @@ export default { </td> <td class="gl-p-4"> <gl-button - v-if="!isDisabled" + v-if="!isAlreadyImported" + :disabled="isInvalid" variant="success" category="secondary" @click="$emit('import-group')" 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 8110934efc4..d444cc77aa7 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 @@ -15,52 +15,71 @@ export const clientTypenames = { BulkImportPageInfo: 'ClientBulkImportPageInfo', }; -export function createResolvers({ endpoints }) { +export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) { let statusPoller; + let sourceGroupManager; + const getGroupsManager = (client) => { + if (!sourceGroupManager) { + sourceGroupManager = new GroupsManager({ client, sourceUrl }); + } + return sourceGroupManager; + }; + return { Query: { async bulkImportSourceGroups(_, vars, { client }) { - const { - data: { availableNamespaces }, - } = await client.query({ query: availableNamespacesQuery }); - if (!statusPoller) { statusPoller = new StatusPoller({ - client, + groupManager: getGroupsManager(client), pollPath: endpoints.jobs, }); statusPoller.startPolling(); } - return axios - .get(endpoints.status, { + const groupsManager = getGroupsManager(client); + return Promise.all([ + axios.get(endpoints.status, { params: { page: vars.page, per_page: vars.perPage, filter: vars.filter, }, - }) - .then(({ headers, data }) => { + }), + client.query({ query: availableNamespacesQuery }), + ]).then( + ([ + { headers, data }, + { + data: { availableNamespaces }, + }, + ]) => { const pagination = parseIntPagination(normalizeHeaders(headers)); return { __typename: clientTypenames.BulkImportSourceGroupConnection, - nodes: data.importable_data.map((group) => ({ - __typename: clientTypenames.BulkImportSourceGroup, - ...group, - status: STATUSES.NONE, - import_target: { - new_name: group.full_path, - target_namespace: availableNamespaces[0].full_path, - }, - })), + nodes: data.importable_data.map((group) => { + const cachedImportState = groupsManager.getImportStateFromStorageByGroupId( + group.id, + ); + + return { + __typename: clientTypenames.BulkImportSourceGroup, + ...group, + status: cachedImportState?.status ?? STATUSES.NONE, + import_target: cachedImportState?.importTarget ?? { + new_name: group.full_path, + target_namespace: availableNamespaces[0]?.full_path ?? '', + }, + }; + }), pageInfo: { __typename: clientTypenames.BulkImportPageInfo, ...pagination, }, }; - }); + }, + ); }, availableNamespaces: () => @@ -73,21 +92,21 @@ export function createResolvers({ endpoints }) { }, Mutation: { setTargetNamespace(_, { targetNamespace, sourceGroupId }, { client }) { - new SourceGroupsManager({ client }).updateById(sourceGroupId, (sourceGroup) => { + getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => { // eslint-disable-next-line no-param-reassign sourceGroup.import_target.target_namespace = targetNamespace; }); }, setNewName(_, { newName, sourceGroupId }, { client }) { - new SourceGroupsManager({ client }).updateById(sourceGroupId, (sourceGroup) => { + getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => { // eslint-disable-next-line no-param-reassign sourceGroup.import_target.new_name = newName; }); }, async importGroup(_, { sourceGroupId }, { client }) { - const groupManager = new SourceGroupsManager({ client }); + const groupManager = getGroupsManager(client); const group = groupManager.findById(sourceGroupId); groupManager.setImportStatus(group, STATUSES.SCHEDULING); try { @@ -101,13 +120,10 @@ export function createResolvers({ endpoints }) { }, ], }); - groupManager.setImportStatus(group, STATUSES.STARTED); - SourceGroupsManager.attachImportId(group, response.data.id); + groupManager.startImport({ group, importId: response.data.id }); } catch (e) { - createFlash({ - message: s__('BulkImport|Importing the group failed'), - }); - + const message = e?.response?.data?.error ?? s__('BulkImport|Importing the group failed'); + createFlash({ message }); groupManager.setImportStatus(group, STATUSES.NONE); throw e; } @@ -116,5 +132,5 @@ export function createResolvers({ endpoints }) { }; } -export const createApolloClient = ({ endpoints }) => - createDefaultClient(createResolvers({ endpoints }), { assumeImmutableResults: true }); +export const createApolloClient = ({ sourceUrl, endpoints }) => + createDefaultClient(createResolvers({ sourceUrl, endpoints }), { assumeImmutableResults: true }); diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql new file mode 100644 index 00000000000..52df3581ac4 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql @@ -0,0 +1,5 @@ +query group($fullPath: ID!) { + existingGroup: group(fullPath: $fullPath) { + id + } +} 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 index 261e30edbbb..2c88d25358f 100644 --- 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 @@ -1,5 +1,7 @@ import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import produce from 'immer'; +import { debounce, merge } from 'lodash'; +import { STATUSES } from '../../../constants'; import ImportSourceGroupFragment from '../fragments/bulk_import_source_group_item.fragment.graphql'; function extractTypeConditionFromFragment(fragment) { @@ -13,15 +15,24 @@ function generateGroupId(id) { }); } +export const KEY = 'gl-bulk-imports-import-state'; +export const DEBOUNCE_INTERVAL = 200; + export class SourceGroupsManager { - static importMap = new Map(); + constructor({ client, sourceUrl, storage = window.localStorage }) { + this.client = client; + this.sourceUrl = sourceUrl; - static attachImportId(group, importId) { - SourceGroupsManager.importMap.set(importId, group.id); + this.storage = storage; + this.importStates = this.loadImportStatesFromStorage(); } - constructor({ client }) { - this.client = client; + loadImportStatesFromStorage() { + try { + return JSON.parse(this.storage.getItem(KEY)) ?? {}; + } catch { + return {}; + } } findById(id) { @@ -42,8 +53,48 @@ export class SourceGroupsManager { this.update(group, fn); } - findByImportId(importId) { - return this.findById(SourceGroupsManager.importMap.get(importId)); + saveImportState(importId, group) { + this.importStates[this.getStorageKey(importId)] = { + id: group.id, + importTarget: group.import_target, + status: group.status, + }; + this.saveImportStatesToStorage(); + } + + getImportStateFromStorage(importId) { + return this.importStates[this.getStorageKey(importId)]; + } + + getImportStateFromStorageByGroupId(groupId) { + const PREFIX = this.getStorageKey(''); + const [, importState] = + Object.entries(this.importStates).find( + ([key, group]) => key.startsWith(PREFIX) && group.id === groupId, + ) ?? []; + + return importState; + } + + getStorageKey(importId) { + return `${this.sourceUrl}|${importId}`; + } + + 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); + + startImport({ group, importId }) { + this.setImportStatus(group, STATUSES.CREATED); + this.saveImportState(importId, group); } setImportStatus(group, status) { @@ -52,4 +103,22 @@ export class SourceGroupsManager { sourceGroup.status = status; }); } + + setImportStatusByImportId(importId, status) { + const importState = this.getImportStateFromStorage(importId); + if (!importState) { + return; + } + + if (importState.status !== status) { + importState.status = status; + } + + const group = this.findById(importState.id); + if (group?.id) { + this.setImportStatus(group, status); + } + + this.saveImportStatesToStorage(); + } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js index 63cd6b48fc4..b80a575afce 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js @@ -3,12 +3,9 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; import { s__ } from '~/locale'; -import { SourceGroupsManager } from './source_groups_manager'; export class StatusPoller { - constructor({ client, pollPath }) { - this.client = client; - + constructor({ groupManager, pollPath }) { this.eTagPoll = new Poll({ resource: { fetchJobs: () => axios.get(pollPath), @@ -29,7 +26,7 @@ export class StatusPoller { } }); - this.groupManager = new SourceGroupsManager({ client }); + this.groupManager = groupManager; } startPolling() { @@ -38,10 +35,7 @@ export class StatusPoller { async updateImportsStatuses(importStatuses) { importStatuses.forEach(({ id, status_name: statusName }) => { - const group = this.groupManager.findByImportId(id); - if (group.id) { - this.groupManager.setImportStatus(group, statusName); - } + this.groupManager.setImportStatusByImportId(id, statusName); }); } } diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js index cd837a840e4..cc60c8cbdb0 100644 --- a/app/assets/javascripts/import_entities/import_groups/index.js +++ b/app/assets/javascripts/import_entities/import_groups/index.js @@ -16,9 +16,11 @@ export function mountImportGroupsApp(mountElement) { createBulkImportPath, jobsPath, sourceUrl, + groupPathRegex, } = mountElement.dataset; const apolloProvider = new VueApollo({ defaultClient: createApolloClient({ + sourceUrl, endpoints: { status: statusPath, availableNamespaces: availableNamespacesPath, @@ -35,6 +37,7 @@ export function mountImportGroupsApp(mountElement) { return createElement(ImportTable, { props: { sourceUrl, + groupPathRegex: new RegExp(`^(${groupPathRegex})$`), }, }); }, |