diff options
Diffstat (limited to 'app/assets/javascripts/import_entities/import_groups')
12 files changed, 453 insertions, 0 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 new file mode 100644 index 00000000000..153c58b556e --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -0,0 +1,78 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; +import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; +import setTargetNamespaceMutation from '../graphql/mutations/set_target_namespace.mutation.graphql'; +import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql'; +import importGroupMutation from '../graphql/mutations/import_group.mutation.graphql'; +import ImportTableRow from './import_table_row.vue'; + +const mapApolloMutations = mutations => + Object.fromEntries( + Object.entries(mutations).map(([key, mutation]) => [ + key, + function mutate(config) { + return this.$apollo.mutate({ + mutation, + ...config, + }); + }, + ]), + ); + +export default { + components: { + GlLoadingIcon, + ImportTableRow, + }, + + apollo: { + bulkImportSourceGroups: bulkImportSourceGroupsQuery, + availableNamespaces: availableNamespacesQuery, + }, + + methods: { + ...mapApolloMutations({ + setTargetNamespace: setTargetNamespaceMutation, + setNewName: setNewNameMutation, + importGroup: importGroupMutation, + }), + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" /> + <div v-else-if="bulkImportSourceGroups.length"> + <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> + <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> + <template v-for="group in bulkImportSourceGroups"> + <import-table-row + :key="group.id" + :group="group" + :available-namespaces="availableNamespaces" + @update-target-namespace=" + setTargetNamespace({ + variables: { sourceGroupId: group.id, targetNamespace: $event }, + }) + " + @update-new-name=" + setNewName({ + variables: { sourceGroupId: group.id, newName: $event }, + }) + " + @import-group="importGroup({ variables: { sourceGroupId: group.id } })" + /> + </template> + </tbody> + </table> + </div> + </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 new file mode 100644 index 00000000000..07603d89f0f --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue @@ -0,0 +1,106 @@ +<script> +import { GlButton, 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'; + +export default { + components: { + Select2Select, + ImportStatus, + GlButton, + GlLink, + GlIcon, + GlFormInput, + }, + props: { + group: { + type: Object, + required: true, + }, + availableNamespaces: { + type: Array, + required: true, + }, + }, + computed: { + isDisabled() { + return this.group.status !== STATUSES.NONE; + }, + + isFinished() { + 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}`; + }, + + getFullPath(group) { + return joinPaths(gon.relative_url_root || '/', this.getPath(group)); + }, + }, +}; +</script> + +<template> + <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1"> + <td class="gl-p-4"> + <gl-link :href="group.web_url" target="_blank"> + {{ 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> + + <div + v-else + class="import-entities-target-select gl-display-flex gl-align-items-stretch" + :class="{ + disabled: isDisabled, + }" + > + <select2-select + :disabled="isDisabled" + :options="select2Options" + :value="group.import_target.target_namespace" + @input="$emit('update-target-namespace', $event)" + /> + <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" + > + / + </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> + </td> + <td class="gl-p-4 gl-white-space-nowrap"> + <import-status :status="group.status" /> + </td> + <td class="gl-p-4"> + <gl-button + v-if="!isDisabled" + variant="success" + category="secondary" + @click="$emit('import-group')" + >{{ __('Import') }}</gl-button + > + </td> + </tr> +</template> 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 new file mode 100644 index 00000000000..4fcaa1b55fc --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js @@ -0,0 +1,95 @@ +import axios from '~/lib/utils/axios_utils'; +import createDefaultClient from '~/lib/graphql'; +import { s__ } from '~/locale'; +import createFlash from '~/flash'; +import { STATUSES } from '../../constants'; +import availableNamespacesQuery from './queries/available_namespaces.query.graphql'; +import { SourceGroupsManager } from './services/source_groups_manager'; +import { StatusPoller } from './services/status_poller'; + +export const clientTypenames = { + BulkImportSourceGroup: 'ClientBulkImportSourceGroup', + AvailableNamespace: 'ClientAvailableNamespace', +}; + +export function createResolvers({ endpoints }) { + let statusPoller; + + return { + Query: { + async bulkImportSourceGroups(_, __, { client }) { + const { + data: { availableNamespaces }, + } = await client.query({ query: availableNamespacesQuery }); + + return axios.get(endpoints.status).then(({ data }) => { + return 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, + }, + })); + }); + }, + + availableNamespaces: () => + axios.get(endpoints.availableNamespaces).then(({ data }) => + data.map(namespace => ({ + __typename: clientTypenames.AvailableNamespace, + ...namespace, + })), + ), + }, + Mutation: { + setTargetNamespace(_, { targetNamespace, sourceGroupId }, { client }) { + new SourceGroupsManager({ 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 => { + // eslint-disable-next-line no-param-reassign + sourceGroup.import_target.new_name = newName; + }); + }, + + async importGroup(_, { sourceGroupId }, { client }) { + const groupManager = new SourceGroupsManager({ client }); + const group = groupManager.findById(sourceGroupId); + groupManager.setImportStatus(group, STATUSES.SCHEDULING); + try { + await axios.post(endpoints.createBulkImport, { + bulk_import: [ + { + source_type: 'group_entity', + source_full_path: group.full_path, + destination_namespace: group.import_target.target_namespace, + destination_name: group.import_target.new_name, + }, + ], + }); + groupManager.setImportStatus(group, STATUSES.STARTED); + if (!statusPoller) { + statusPoller = new StatusPoller({ client, interval: 3000 }); + statusPoller.startPolling(); + } + } catch (e) { + createFlash({ + message: s__('BulkImport|Importing the group failed'), + }); + + groupManager.setImportStatus(group, STATUSES.NONE); + throw e; + } + }, + }, + }; +} + +export const createApolloClient = ({ endpoints }) => + createDefaultClient(createResolvers({ endpoints }), { assumeImmutableResults: true }); 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 new file mode 100644 index 00000000000..50774e36599 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql @@ -0,0 +1,8 @@ +fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup { + id + web_url + full_path + full_name + status + import_target +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql new file mode 100644 index 00000000000..412608d3faf --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql @@ -0,0 +1,3 @@ +mutation importGroup($sourceGroupId: String!) { + importGroup(sourceGroupId: $sourceGroupId) @client +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql new file mode 100644 index 00000000000..2bc19891401 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql @@ -0,0 +1,3 @@ +mutation setNewName($newName: String!, $sourceGroupId: String!) { + setNewName(newName: $newName, sourceGroupId: $sourceGroupId) @client +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql new file mode 100644 index 00000000000..fc98a1652c1 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql @@ -0,0 +1,3 @@ +mutation setTargetNamespace($targetNamespace: String!, $sourceGroupId: String!) { + setTargetNamespace(targetNamespace: $targetNamespace, sourceGroupId: $sourceGroupId) @client +} 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 new file mode 100644 index 00000000000..5ab9796b50a --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql @@ -0,0 +1,6 @@ +query availableNamespaces { + availableNamespaces @client { + id + full_path + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql new file mode 100644 index 00000000000..8d52d94925c --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql @@ -0,0 +1,7 @@ +#import "../fragments/bulk_import_source_group_item.fragment.graphql" + +query bulkImportSourceGroups { + bulkImportSourceGroups @client { + ...BulkImportSourceGroupItem + } +} 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 new file mode 100644 index 00000000000..f752ecc8cd6 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js @@ -0,0 +1,45 @@ +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import produce from 'immer'; +import ImportSourceGroupFragment from '../fragments/bulk_import_source_group_item.fragment.graphql'; + +function extractTypeConditionFromFragment(fragment) { + return fragment.definitions[0]?.typeCondition.name.value; +} + +function generateGroupId(id) { + return defaultDataIdFromObject({ + __typename: extractTypeConditionFromFragment(ImportSourceGroupFragment), + id, + }); +} + +export class SourceGroupsManager { + constructor({ client }) { + this.client = client; + } + + findById(id) { + const cacheId = generateGroupId(id); + return this.client.readFragment({ fragment: ImportSourceGroupFragment, id: cacheId }); + } + + update(group, fn) { + this.client.writeFragment({ + fragment: ImportSourceGroupFragment, + id: generateGroupId(group.id), + data: produce(group, fn), + }); + } + + updateById(id, fn) { + const group = this.findById(id); + this.update(group, fn); + } + + setImportStatus(group, status) { + this.update(group, sourceGroup => { + // eslint-disable-next-line no-param-reassign + sourceGroup.status = status; + }); + } +} 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 new file mode 100644 index 00000000000..5d2922b0ba8 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js @@ -0,0 +1,68 @@ +import gql from 'graphql-tag'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import bulkImportSourceGroupsQuery from '../queries/bulk_import_source_groups.query.graphql'; +import { STATUSES } from '../../../constants'; +import { SourceGroupsManager } from './source_groups_manager'; + +const groupId = i => `group${i}`; + +function generateGroupsQuery(groups) { + return gql`{ + ${groups + .map( + (g, idx) => + `${groupId(idx)}: group(fullPath: "${g.import_target.target_namespace}/${ + g.import_target.new_name + }") { id }`, + ) + .join('\n')} + }`; +} + +export class StatusPoller { + constructor({ client, interval }) { + this.client = client; + this.interval = interval; + this.timeoutId = null; + this.groupManager = new SourceGroupsManager({ client }); + } + + startPolling() { + if (this.timeoutId) { + return; + } + + this.checkPendingImports(); + } + + stopPolling() { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + + async checkPendingImports() { + try { + const { bulkImportSourceGroups } = this.client.readQuery({ + query: bulkImportSourceGroupsQuery, + }); + const groupsInProgress = bulkImportSourceGroups.filter(g => g.status === STATUSES.STARTED); + if (groupsInProgress.length) { + const { data: results } = await this.client.query({ + query: generateGroupsQuery(groupsInProgress), + fetchPolicy: 'no-cache', + }); + const completedGroups = groupsInProgress.filter((_, idx) => Boolean(results[groupId(idx)])); + completedGroups.forEach(group => { + this.groupManager.setImportStatus(group, STATUSES.FINISHED); + }); + } + } catch (e) { + createFlash({ + message: s__('BulkImport|Update of import statuses with realtime changes failed'), + }); + } finally { + this.timeoutId = setTimeout(() => this.checkPendingImports(), this.interval); + } + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js new file mode 100644 index 00000000000..bf427075564 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import Translate from '~/vue_shared/translate'; +import { createApolloClient } from './graphql/client_factory'; +import ImportTable from './components/import_table.vue'; + +Vue.use(Translate); +Vue.use(VueApollo); + +export function mountImportGroupsApp(mountElement) { + if (!mountElement) return undefined; + + const { statusPath, availableNamespacesPath, createBulkImportPath } = mountElement.dataset; + const apolloProvider = new VueApollo({ + defaultClient: createApolloClient({ + endpoints: { + status: statusPath, + availableNamespaces: availableNamespacesPath, + createBulkImport: createBulkImportPath, + }, + }), + }); + + return new Vue({ + el: mountElement, + apolloProvider, + render(createElement) { + return createElement(ImportTable); + }, + }); +} |