diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-13 06:11:29 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-13 06:11:29 +0000 |
commit | e4372ce2ee58813303e4ac906800fbfdd0d5bcf5 (patch) | |
tree | bdd15d7b1e97e8eff4aead62bab05d46b34ce061 /app | |
parent | 395db070c9315441912258bfcbc2fac973f47e36 (diff) | |
download | gitlab-ce-e4372ce2ee58813303e4ac906800fbfdd0d5bcf5.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
20 files changed, 435 insertions, 127 deletions
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 new file mode 100644 index 00000000000..104c84173fc --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue @@ -0,0 +1,69 @@ +<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: { + GlIcon, + GlButton, + }, + directives: { + GlTooltip, + }, + props: { + group: { + type: Object, + required: true, + }, + groupPathRegex: { + type: RegExp, + 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); + }, + }, +}; +</script> + +<template> + <span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center"> + <gl-button + v-if="isAvailableForImport" + :disabled="isInvalid" + variant="confirm" + category="secondary" + data-qa-selector="import_group_button" + @click="$emit('import-group')" + > + {{ isFinished ? __('Re-import') : __('Import') }} + </gl-button> + <gl-icon + v-if="isFinished" + v-gl-tooltip + :size="16" + name="information-o" + :title=" + s__('BulkImports|Re-import creates a new group. It does not sync with the existing group.') + " + class="gl-ml-3" + /> + </span> +</template> 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 new file mode 100644 index 00000000000..2de9bd4f868 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue @@ -0,0 +1,53 @@ +<script> +import { GlLink, GlSprintf, GlIcon } from '@gitlab/ui'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { isFinished } from '../utils'; + +export default { + components: { + GlLink, + GlSprintf, + GlIcon, + }, + props: { + group: { + type: Object, + 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); + }, + isFinished() { + return isFinished(this.group); + }, + }, +}; +</script> + +<template> + <div> + <gl-link + :href="group.web_url" + target="_blank" + class="gl-display-inline-flex gl-align-items-center gl-h-7" + > + {{ group.full_path }} <gl-icon name="external-link" /> + </gl-link> + <div v-if="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">{{ + fullLastImportPath + }}</gl-link> + </template> + </gl-sprintf> + </div> + </div> +</template> 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 db44be2bcd7..04b037ecc2b 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 @@ -9,19 +9,19 @@ import { GlLoadingIcon, GlSearchBoxByClick, GlSprintf, - GlSafeHtmlDirective as SafeHtml, 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 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 availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; -import { isInvalid } from '../utils'; +import { isInvalid, isFinished, isAvailableForImport } from '../utils'; +import ImportActionsCell from './import_actions_cell.vue'; +import ImportSourceCell from './import_source_cell.vue'; import ImportTargetCell from './import_target_cell.vue'; const PAGE_SIZES = [20, 50, 100]; @@ -43,13 +43,12 @@ export default { GlFormCheckbox, GlSprintf, GlTable, - ImportStatus, + ImportSourceCell, ImportTargetCell, + ImportStatusCell, + ImportActionsCell, PaginationLinks, }, - directives: { - SafeHtml, - }, props: { sourceUrl: { @@ -136,7 +135,7 @@ export default { }, availableGroupsForImport() { - return this.groups.filter((g) => g.progress.status === STATUSES.NONE && !this.isInvalid(g)); + return this.groups.filter((g) => isAvailableForImport(g) && !this.isInvalid(g)); }, humanizedTotal() { @@ -190,6 +189,24 @@ export default { }, methods: { + isUnselectable(group) { + return !this.isAvailableForImport(group) || this.isInvalid(group); + }, + + rowClasses(group) { + const DEFAULT_CLASSES = [ + 'gl-border-gray-200', + 'gl-border-0', + 'gl-border-b-1', + 'gl-border-solid', + ]; + const result = [...DEFAULT_CLASSES]; + if (this.isUnselectable(group)) { + result.push('gl-cursor-default!'); + } + return result; + }, + qaRowAttributes(group, type) { if (type === 'row') { return { @@ -201,10 +218,8 @@ export default { return {}; }, - isAlreadyImported(group) { - return group.progress.status !== STATUSES.NONE; - }, - + isAvailableForImport, + isFinished, isInvalid(group) { return isInvalid(group, this.groupPathRegex); }, @@ -253,7 +268,7 @@ export default { const table = this.getTableRef(); this.groups.forEach((group, idx) => { - if (table.isRowSelected(idx) && (this.isAlreadyImported(group) || this.isInvalid(group))) { + if (table.isRowSelected(idx) && this.isUnselectable(group)) { table.unselectRow(idx); } }); @@ -291,7 +306,7 @@ export default { <strong>{{ filter }}</strong> </template> <template #link> - <gl-link class="gl-display-inline-block" :href="sourceUrl" target="_blank"> + <gl-link :href="sourceUrl" target="_blank"> {{ sourceUrl }} <gl-icon name="external-link" class="vertical-align-middle" /> </gl-link> </template> @@ -338,7 +353,7 @@ export default { 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-class="rowClasses" :tbody-tr-attr="qaRowAttributes" :items="groups" :fields="$options.fields" @@ -360,18 +375,12 @@ export default { <gl-form-checkbox class="gl-h-7 gl-pt-3" :checked="rowSelected" - :disabled="isAlreadyImported(group) || isInvalid(group)" + :disabled="!isAvailableForImport(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 #cell(web_url)="{ item: group }"> + <import-source-cell :group="group" /> </template> <template #cell(import_target)="{ item: group }"> <import-target-cell @@ -388,19 +397,14 @@ export default { /> </template> <template #cell(progress)="{ value: { status } }"> - <import-status :status="status" class="gl-line-height-32" /> + <import-status-cell :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> + <import-actions-cell + :group="group" + :group-path-regex="groupPathRegex" + @import-group="importGroups([group.id])" + /> </template> </gl-table> <div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center"> 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 7359d4f239e..daced740c94 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 @@ -3,14 +3,16 @@ 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'; +import { + isInvalid, + getInvalidNameValidationMessage, + isNameValid, + isAvailableForImport, +} from '../utils'; export default { components: { @@ -18,7 +20,6 @@ export default { GlDropdownDivider, GlDropdownItem, GlDropdownSectionHeader, - GlLink, GlFormInput, }, props: { @@ -61,20 +62,8 @@ export default { 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); + isAvailableForImport() { + return isAvailableForImport(this.group); }, }, @@ -85,25 +74,11 @@ export default { </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, - }" - > + <div class="gl-display-flex gl-align-items-stretch"> <import-group-dropdown #default="{ namespaces }" :text="importTarget.target_namespace" - :disabled="isAlreadyImported" + :disabled="!isAvailableForImport" :namespaces="availableNamespaceNames" toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" class="gl-h-7 gl-flex-grow-1" @@ -131,8 +106,8 @@ export default { <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, + 'gl-text-gray-400 gl-border-gray-100': !isAvailableForImport, + 'gl-border-gray-200': isAvailableForImport, }" > / @@ -141,11 +116,11 @@ export default { <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, + 'gl-inset-border-1-gray-200!': isAvailableForImport, + 'gl-inset-border-1-gray-100!': !isAvailableForImport, + 'is-invalid': isInvalid && isAvailableForImport, }" - :disabled="isAlreadyImported" + :disabled="!isAvailableForImport" :value="importTarget.new_name" @input="$emit('update-new-name', $event)" /> 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 57188441158..c08cf909a00 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 @@ -5,10 +5,13 @@ 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'; @@ -34,6 +37,7 @@ function makeGroup(data) { }; const NESTED_OBJECT_FIELDS = { import_target: clientTypenames.BulkImportTarget, + last_import_target: clientTypenames.BulkImportTarget, progress: clientTypenames.BulkImportProgress, }; @@ -55,6 +59,7 @@ async function checkImportTargetIsValid({ client, newName, targetNamespace, sour data: { existingGroup, existingProject }, } = await client.query({ query: groupAndProjectQuery, + fetchPolicy: 'no-cache', variables: { fullPath: `${targetNamespace}/${newName}`, }, @@ -82,6 +87,7 @@ async function checkImportTargetIsValid({ client, newName, targetNamespace, sour } const localProgressId = (id) => `not-started-${id}`; +const nextName = (name) => `${name}-1`; export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) { const groupsManager = new GroupsManager({ @@ -140,17 +146,28 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr 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: cachedImportState?.status ?? STATUSES.NONE, - }, - import_target: cachedImportState?.importTarget ?? { - new_name: group.full_path, - target_namespace: availableNamespaces[0]?.full_path ?? '', + status, }, + import_target: importTarget, + last_import_target: cachedImportState?.importTarget ?? null, }); }), pageInfo: { @@ -161,7 +178,7 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr setTimeout(() => { response.nodes.forEach((group) => { - if (group.progress.status === STATUSES.NONE) { + if (isAvailableForImport(group)) { checkImportTargetIsValid({ client, newName: group.import_target.new_name, @@ -193,32 +210,18 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr targetNamespace, newName, }); + return makeGroup({ id: sourceGroupId, import_target: { target_namespace: targetNamespace, new_name: newName, + id: sourceGroupId, }, }); }, - setTargetNamespace: (_, { targetNamespace, sourceGroupId }) => - makeGroup({ - id: sourceGroupId, - import_target: { - target_namespace: targetNamespace, - }, - }), - - setNewName: (_, { newName, sourceGroupId }) => - makeGroup({ - id: sourceGroupId, - import_target: { - new_name: newName, - }, - }), - - async setImportProgress(_, { sourceGroupId, status, jobId }) { + async setImportProgress(_, { sourceGroupId, status, jobId, importTarget }) { if (jobId) { groupsManager.updateImportProgress(jobId, status); } @@ -229,16 +232,46 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr id: jobId ?? localProgressId(sourceGroupId), status, }, + last_import_target: { + __typename: clientTypenames.BulkImportTarget, + ...importTarget, + }, }); }, - async updateImportStatus(_, { id, status }) { - groupsManager.updateImportProgress(id, status); + async updateImportStatus(_, { id, status: newStatus }, { client, getCacheKey }) { + groupsManager.updateImportProgress(id, newStatus); + + const progressItem = client.readFragment({ + fragment: bulkImportSourceGroupProgressFragment, + fragmentName: 'BulkImportSourceGroupProgress', + id: getCacheKey({ + __typename: clientTypenames.BulkImportProgress, + id, + }), + }); + + const isInProgress = Boolean(progressItem); + const { status: currentStatus } = progressItem ?? {}; + if (newStatus === STATUSES.FINISHED && isInProgress && currentStatus !== newStatus) { + const groups = groupsManager.getImportedGroupsByJobId(id); + + groups.forEach(async ({ id: groupId, importTarget }) => { + client.mutate({ + mutation: setImportTargetMutation, + variables: { + sourceGroupId: groupId, + targetNamespace: importTarget.target_namespace, + newName: nextName(importTarget.new_name), + }, + }); + }); + } return { __typename: clientTypenames.BulkImportProgress, id, - status, + status: newStatus, }; }, @@ -327,10 +360,10 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr return { status: STATUSES.NONE }; }) .then((newStatus) => - sourceGroupIds.forEach((sourceGroupId) => + sourceGroupIds.forEach((sourceGroupId, idx) => client.mutate({ mutation: setImportProgressMutation, - variables: { sourceGroupId, ...newStatus }, + variables: { sourceGroupId, ...newStatus, importTarget: groups[idx].import_target }, }), ), ) 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 47675cd1bd0..089340b3c48 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 @@ -12,6 +12,10 @@ fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup { 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/set_import_progress.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql index 2ec1269932a..43301554de3 100644 --- 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 @@ -1,9 +1,23 @@ -mutation setImportProgress($status: String!, $sourceGroupId: String!, $jobId: String) { - setImportProgress(status: $status, sourceGroupId: $sourceGroupId, jobId: $jobId) @client { +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/services/source_groups_manager.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js index 97dbdbf518a..7caa37d9ad4 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 @@ -35,15 +35,18 @@ export class SourceGroupsManager { } createImportState(importId, jobConfig) { - this.importStates[this.getStorageKey(importId)] = { + this.importStates[importId] = { status: jobConfig.status, - groups: jobConfig.groups.map((g) => ({ importTarget: g.import_target, id: g.id })), + groups: jobConfig.groups.map((g) => ({ + importTarget: { ...g.import_target }, + id: g.id, + })), }; this.saveImportStatesToStorage(); } updateImportProgress(importId, status) { - const currentState = this.importStates[this.getStorageKey(importId)]; + const currentState = this.importStates[importId]; if (!currentState) { return; } @@ -52,12 +55,15 @@ export class SourceGroupsManager { this.saveImportStatesToStorage(); } + getImportedGroupsByJobId(jobId) { + return this.importStates[jobId]?.groups ?? []; + } + getImportStateFromStorageByGroupId(groupId) { - const PREFIX = this.getStorageKey(''); const [jobId, importState] = - Object.entries(this.importStates).find( - ([key, state]) => key.startsWith(PREFIX) && state.groups.some((g) => g.id === groupId), - ) ?? []; + Object.entries(this.importStates) + .reverse() + .find(([, state]) => state.groups.some((g) => g.id === groupId)) ?? []; if (!jobId) { return null; @@ -67,10 +73,6 @@ export class SourceGroupsManager { return { jobId, importState: { ...group, status: importState.status } }; } - getStorageKey(importId) { - return `${this.sourceUrl}|${importId}`; - } - saveImportStatesToStorage = debounce(() => { try { // storage might be changed in other tab so fetch first 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 c830aaa75e6..6ef4bbafec0 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql @@ -30,6 +30,7 @@ type ClientBulkImportSourceGroup { full_name: String! progress: ClientBulkImportProgress! import_target: ClientBulkImportTarget! + last_import_target: ClientBulkImportTarget validation_errors: [ClientBulkImportValidationError!]! } @@ -50,11 +51,21 @@ extend type Query { availableNamespaces: [ClientBulkImportAvailableNamespace!]! } +input InputTargetInput { + target_namespace: String! + new_name: 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!): ClientBulkImportSourceGroup! + setImportProgress( + id: ID + status: String! + jobId: String + importTarget: ImportTargetInput! + ): ClientBulkImportSourceGroup! updateImportProgress(id: ID, status: String!): ClientBulkImportProgress addValidationError( sourceGroupId: ID! diff --git a/app/assets/javascripts/import_entities/import_groups/utils.js b/app/assets/javascripts/import_entities/import_groups/utils.js index b451008b6f9..a1baeaf39dd 100644 --- a/app/assets/javascripts/import_entities/import_groups/utils.js +++ b/app/assets/javascripts/import_entities/import_groups/utils.js @@ -1,3 +1,4 @@ +import { STATUSES } from '../constants'; import { NEW_NAME_FIELD } from './constants'; export function isNameValid(group, validationRegex) { @@ -11,3 +12,11 @@ export function getInvalidNameValidationMessage(group) { export function isInvalid(group, validationRegex) { return Boolean(!isNameValid(group, validationRegex) || getInvalidNameValidationMessage(group)); } + +export function isFinished(group) { + return group.progress.status === STATUSES.FINISHED; +} + +export function isAvailableForImport(group) { + return [STATUSES.NONE, STATUSES.FINISHED].some((status) => group.progress.status === status); +} diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index c3b5e1ac266..23254fcc2eb 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -102,7 +102,7 @@ Sidebar.prototype.toggleTodo = function (e) { }) .catch(() => createFlash({ - message: sprintf(__('There was an error %{message} todo.'), { + message: sprintf(__('There was an error %{message} to-do item.'), { message: ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'), }), diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb index 77f2105db7c..7fb0852b11e 100644 --- a/app/graphql/resolvers/concerns/resolves_pipelines.rb +++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb @@ -17,6 +17,11 @@ module ResolvesPipelines GraphQL::Types::String, required: false, description: "Filter pipelines by the sha of the commit they are run for." + + argument :source, + GraphQL::Types::String, + required: false, + description: "Filter pipelines by their source. Will be ignored if `dast_view_scans` feature flag is disabled." end class_methods do @@ -30,6 +35,8 @@ module ResolvesPipelines end def resolve_pipelines(project, params = {}) + params.delete(:source) unless Feature.enabled?(:dast_view_scans, project, default_enabled: :yaml) + Ci::PipelinesFinder.new(project, context[:current_user], params).execute end end diff --git a/app/graphql/types/customer_relations/contact_type.rb b/app/graphql/types/customer_relations/contact_type.rb new file mode 100644 index 00000000000..35b5bf45698 --- /dev/null +++ b/app/graphql/types/customer_relations/contact_type.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Types + module CustomerRelations + class ContactType < BaseObject + graphql_name 'CustomerRelationsContact' + + authorize :read_contact + + field :id, + GraphQL::Types::ID, + null: false, + description: 'Internal ID of the contact.' + + field :organization, Types::CustomerRelations::OrganizationType, + null: true, + description: "Organization of the contact." + + field :first_name, + GraphQL::Types::String, + null: false, + description: 'First name of the contact.' + + field :last_name, + GraphQL::Types::String, + null: false, + description: 'Last name of the contact.' + + field :phone, + GraphQL::Types::String, + null: true, + description: 'Phone number of the contact.' + + field :email, + GraphQL::Types::String, + null: true, + description: 'Email address of the contact.' + + field :description, + GraphQL::Types::String, + null: true, + description: 'Description or notes for the contact.' + + field :created_at, + Types::TimeType, + null: false, + description: 'Timestamp the contact was created.' + + field :updated_at, + Types::TimeType, + null: false, + description: 'Timestamp the contact was last updated.' + end + end +end diff --git a/app/graphql/types/customer_relations/organization_type.rb b/app/graphql/types/customer_relations/organization_type.rb index b5db821bc9c..b629c4c0566 100644 --- a/app/graphql/types/customer_relations/organization_type.rb +++ b/app/graphql/types/customer_relations/organization_type.rb @@ -29,12 +29,12 @@ module Types field :created_at, Types::TimeType, - null: true, + null: false, description: 'Timestamp the organization was created.' field :updated_at, Types::TimeType, - null: true, + null: false, description: 'Timestamp the organization was last updated.' end end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 0c1497d0a31..80b87044298 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -199,6 +199,10 @@ module Types null: true, description: "Find organizations of this group." + field :contacts, Types::CustomerRelations::ContactType.connection_type, + null: true, + description: "Find contacts of this group." + def avatar_url object.avatar_url(only_path: false) end diff --git a/app/models/group.rb b/app/models/group.rb index 1e83f271052..c6ab8ac7a64 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -760,6 +760,10 @@ class Group < Namespace ::CustomerRelations::Organization.where(group_id: self.id) end + def contacts + ::CustomerRelations::Contact.where(group_id: self.id) + end + private def max_member_access(user_ids) diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb index 90fae8ef35d..2da0e48c2da 100644 --- a/app/models/namespaces/traversal/linear_scopes.rb +++ b/app/models/namespaces/traversal/linear_scopes.rb @@ -15,6 +15,28 @@ module Namespaces select('namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id') end + def self_and_ancestors(include_self: true, hierarchy_order: nil) + return super unless use_traversal_ids_for_ancestor_scopes? + + records = unscoped + .without_sti_condition + .where(id: without_sti_condition.select('unnest(traversal_ids)')) + .order_by_depth(hierarchy_order) + .normal_select + + if include_self + records + else + records.where.not(id: all.as_ids) + end + end + + def self_and_ancestor_ids(include_self: true) + return super unless use_traversal_ids_for_ancestor_scopes? + + self_and_ancestors(include_self: include_self).as_ids + end + def self_and_descendants(include_self: true) return super unless use_traversal_ids? @@ -22,11 +44,7 @@ module Namespaces distinct = records.select('DISTINCT on(namespaces.id) namespaces.*') - # Produce a query of the form: SELECT * FROM namespaces; - # - # When we have queries that break this SELECT * format we can run in to errors. - # For example `SELECT DISTINCT on(...)` will fail when we chain a `.count` c - unscoped.without_sti_condition.from(distinct, :namespaces) + distinct.normal_select end def self_and_descendant_ids(include_self: true) @@ -42,12 +60,35 @@ module Namespaces unscope(where: :type) end + def order_by_depth(hierarchy_order) + return all unless hierarchy_order + + depth_order = hierarchy_order == :asc ? :desc : :asc + + all + .select(Arel.star, 'array_length(traversal_ids, 1) as depth') + .order(depth: depth_order, id: :asc) + end + + # Produce a query of the form: SELECT * FROM namespaces; + # + # When we have queries that break this SELECT * format we can run in to errors. + # For example `SELECT DISTINCT on(...)` will fail when we chain a `.count` c + def normal_select + unscoped.without_sti_condition.from(all, :namespaces) + end + private def use_traversal_ids? Feature.enabled?(:use_traversal_ids, default_enabled: :yaml) end + def use_traversal_ids_for_ancestor_scopes? + Feature.enabled?(:use_traversal_ids_for_ancestor_scopes, default_enabled: :yaml) && + use_traversal_ids? + end + def self_and_descendants_with_duplicates(include_self: true) base_ids = select(:id) diff --git a/app/models/namespaces/traversal/recursive_scopes.rb b/app/models/namespaces/traversal/recursive_scopes.rb index be49d5d9d55..6659cefe095 100644 --- a/app/models/namespaces/traversal/recursive_scopes.rb +++ b/app/models/namespaces/traversal/recursive_scopes.rb @@ -10,6 +10,22 @@ module Namespaces select('id') end + def self_and_ancestors(include_self: true, hierarchy_order: nil) + records = Gitlab::ObjectHierarchy.new(all).base_and_ancestors(hierarchy_order: hierarchy_order) + + if include_self + records + else + records.where.not(id: all.as_ids) + end + end + alias_method :recursive_self_and_ancestors, :self_and_ancestors + + def self_and_ancestor_ids(include_self: true) + self_and_ancestors(include_self: include_self).as_ids + end + alias_method :recursive_self_and_ancestor_ids, :self_and_ancestor_ids + def descendant_ids recursive_descendants.as_ids end diff --git a/app/policies/customer_relations/contact_policy.rb b/app/policies/customer_relations/contact_policy.rb new file mode 100644 index 00000000000..8367649b50c --- /dev/null +++ b/app/policies/customer_relations/contact_policy.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module CustomerRelations + class ContactPolicy < BasePolicy + delegate { @subject.group } + end +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 92a8cab01d9..ffe908593ae 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -113,6 +113,7 @@ class GroupPolicy < BasePolicy enable :read_custom_emoji enable :read_counts enable :read_organization + enable :read_contact end rule { ~public_group & ~has_access }.prevent :read_counts |