diff options
Diffstat (limited to 'app/assets/javascripts/projects/settings')
22 files changed, 738 insertions, 118 deletions
diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index 060178a3cfb..335545c802a 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -1,7 +1,7 @@ /* eslint-disable no-underscore-dangle, class-methods-use-this */ import { escape, find, countBy } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { n__, s__, __, sprintf } from '~/locale'; import { getUsers, getGroups, getDeployKeys } from './api/access_dropdown_api'; import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants'; @@ -326,12 +326,12 @@ export default class AccessDropdown { ); }) .catch(() => { - createFlash({ message: __('Failed to load groups, users and deploy keys.') }); + createAlert({ message: __('Failed to load groups, users and deploy keys.') }); }); } else { getDeployKeys(query) .then((deployKeysResponse) => callback(this.consolidateData(deployKeysResponse.data))) - .catch(() => createFlash({ message: __('Failed to load deploy keys.') })); + .catch(() => createAlert({ message: __('Failed to load deploy keys.') })); } } diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue index 6ba2ef7da99..f2b1c749abc 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue @@ -10,7 +10,7 @@ import { import { createAlert } from '~/flash'; import { s__, sprintf } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; -import branchesQuery from '../queries/branches.query.graphql'; +import branchesQuery from '../../queries/branches.query.graphql'; export const i18n = { fetchBranchesError: s__('BranchRules|An error occurred while fetching branches.'), diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/index.vue index ad3eb7d2899..ad3eb7d2899 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/index.vue diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/index.vue index bcc0f64d667..bcc0f64d667 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/index.vue diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/merge_protections.vue index 85f168af4a8..85f168af4a8 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/merge_protections.vue diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue index 541923bb735..541923bb735 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js new file mode 100644 index 00000000000..264c2629433 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js @@ -0,0 +1,42 @@ +import { s__ } from '~/locale'; + +export const I18N = { + manageProtectionsLinkTitle: s__('BranchRules|Manage in Protected Branches'), + targetBranch: s__('BranchRules|Target Branch'), + branchNameOrPattern: s__('BranchRules|Branch name or pattern'), + branch: s__('BranchRules|Target Branch'), + allBranches: s__('BranchRules|All branches'), + protectBranchTitle: s__('BranchRules|Protect branch'), + protectBranchDescription: s__( + 'BranchRules|Keep stable branches secure and force developers to use merge requests. %{linkStart}What are protected branches?%{linkEnd}', + ), + wildcardsHelpText: s__( + 'BranchRules|%{linkStart}Wildcards%{linkEnd} such as *-stable or production/ are supported', + ), + forcePushTitle: s__('BranchRules|Force push'), + allowForcePushDescription: s__( + 'BranchRules|All users with push access are allowed to force push.', + ), + disallowForcePushDescription: s__('BranchRules|Force push is not allowed.'), + approvalsTitle: s__('BranchRules|Approvals'), + manageApprovalsLinkTitle: s__('BranchRules|Manage in Merge Request Approvals'), + approvalsDescription: s__( + 'BranchRules|Approvals to ensure separation of duties for new merge requests. %{linkStart}Lean more.%{linkEnd}', + ), + statusChecksTitle: s__('BranchRules|Status checks'), + allowedToPushHeader: s__('BranchRules|Allowed to push (%{total})'), + allowedToMergeHeader: s__('BranchRules|Allowed to merge (%{total})'), + approvalsHeader: s__('BranchRules|Required approvals (%{total})'), + noData: s__('BranchRules|No data to display'), +}; + +export const BRANCH_PARAM_NAME = 'branch'; + +export const ALL_BRANCHES_WILDCARD = '*'; + +export const WILDCARDS_HELP_PATH = + 'user/project/protected_branches#configure-multiple-protected-branches-by-using-a-wildcard'; + +export const PROTECTED_BRANCHES_HELP_PATH = 'user/project/protected_branches'; + +export const APPROVALS_HELP_PATH = 'user/project/merge_requests/approvals/index.md'; diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue new file mode 100644 index 00000000000..318940478a8 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue @@ -0,0 +1,207 @@ +<script> +import { GlSprintf, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import branchRulesQuery from '../../queries/branch_rules_details.query.graphql'; +import Protection from './protection.vue'; +import { + I18N, + ALL_BRANCHES_WILDCARD, + BRANCH_PARAM_NAME, + WILDCARDS_HELP_PATH, + PROTECTED_BRANCHES_HELP_PATH, + APPROVALS_HELP_PATH, +} from './constants'; + +const wildcardsHelpDocLink = helpPagePath(WILDCARDS_HELP_PATH); +const protectedBranchesHelpDocLink = helpPagePath(PROTECTED_BRANCHES_HELP_PATH); +const approvalsHelpDocLink = helpPagePath(APPROVALS_HELP_PATH); + +export default { + name: 'RuleView', + i18n: I18N, + wildcardsHelpDocLink, + protectedBranchesHelpDocLink, + approvalsHelpDocLink, + components: { Protection, GlSprintf, GlLink, GlLoadingIcon }, + inject: { + projectPath: { + default: '', + }, + protectedBranchesPath: { + default: '', + }, + approvalRulesPath: { + default: '', + }, + }, + apollo: { + project: { + query: branchRulesQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update({ project: { branchRules } }) { + this.branchProtection = branchRules.nodes.find( + (rule) => rule.name === this.branch, + )?.branchProtection; + }, + }, + }, + data() { + return { + branch: getParameterByName(BRANCH_PARAM_NAME), + branchProtection: { + approvalRules: {}, + }, + }; + }, + computed: { + forcePushDescription() { + return this.branchProtection?.allowForcePush + ? this.$options.i18n.allowForcePushDescription + : this.$options.i18n.disallowForcePushDescription; + }, + mergeAccessLevels() { + const { mergeAccessLevels } = this.branchProtection || {}; + return this.getAccessLevels(mergeAccessLevels); + }, + pushAccessLevels() { + const { pushAccessLevels } = this.branchProtection || {}; + return this.getAccessLevels(pushAccessLevels); + }, + allowedToMergeHeader() { + return sprintf(this.$options.i18n.allowedToMergeHeader, { + total: this.mergeAccessLevels.total, + }); + }, + allowedToPushHeader() { + return sprintf(this.$options.i18n.allowedToPushHeader, { + total: this.pushAccessLevels.total, + }); + }, + approvalsHeader() { + const total = this.approvals.reduce( + (sum, { approvalsRequired }) => sum + approvalsRequired, + 0, + ); + return sprintf(this.$options.i18n.approvalsHeader, { + total, + }); + }, + allBranches() { + return this.branch === ALL_BRANCHES_WILDCARD; + }, + allBranchesLabel() { + return this.$options.i18n.allBranches; + }, + branchTitle() { + return this.allBranches + ? this.$options.i18n.targetBranch + : this.$options.i18n.branchNameOrPattern; + }, + approvals() { + return this.branchProtection?.approvalRules?.nodes || []; + }, + }, + methods: { + getAccessLevels(accessLevels = {}) { + const total = accessLevels.edges?.length; + const accessLevelTypes = { total, users: [], groups: [], roles: [] }; + + accessLevels.edges?.forEach(({ node }) => { + if (node.user) { + const src = node.user.avatarUrl; + accessLevelTypes.users.push({ src, ...node.user }); + } else if (node.group) { + accessLevelTypes.groups.push(node); + } else { + accessLevelTypes.roles.push(node); + } + }); + + return accessLevelTypes; + }, + }, +}; +</script> + +<template> + <gl-loading-icon v-if="$apollo.loading" /> + <div v-else-if="!branchProtection">{{ $options.i18n.noData }}</div> + <div v-else> + <strong data-testid="branch-title">{{ branchTitle }}</strong> + <p v-if="!allBranches" class="gl-mb-3 gl-text-gray-400"> + <gl-sprintf :message="$options.i18n.wildcardsHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.wildcardsHelpDocLink"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + + <div v-if="allBranches" class="gl-mt-2" data-testid="branch"> + {{ allBranchesLabel }} + </div> + <code v-else class="gl-mt-2" data-testid="branch">{{ branch }}</code> + + <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.protectBranchTitle }}</h4> + <gl-sprintf :message="$options.i18n.protectBranchDescription"> + <template #link="{ content }"> + <gl-link :href="$options.protectedBranchesHelpDocLink"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + + <!-- Allowed to push --> + <protection + class="gl-mt-3" + :header="allowedToPushHeader" + :header-link-title="$options.i18n.manageProtectionsLinkTitle" + :header-link-href="protectedBranchesPath" + :roles="pushAccessLevels.roles" + :users="pushAccessLevels.users" + :groups="pushAccessLevels.groups" + /> + + <!-- Force push --> + <strong>{{ $options.i18n.forcePushTitle }}</strong> + <p>{{ forcePushDescription }}</p> + + <!-- Allowed to merge --> + <protection + :header="allowedToMergeHeader" + :header-link-title="$options.i18n.manageProtectionsLinkTitle" + :header-link-href="protectedBranchesPath" + :roles="mergeAccessLevels.roles" + :users="mergeAccessLevels.users" + :groups="mergeAccessLevels.groups" + /> + + <!-- Approvals --> + <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.approvalsTitle }}</h4> + <gl-sprintf :message="$options.i18n.approvalsDescription"> + <template #link="{ content }"> + <gl-link :href="$options.approvalsHelpDocLink"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + + <protection + class="gl-mt-3" + :header="approvalsHeader" + :header-link-title="$options.i18n.manageApprovalsLinkTitle" + :header-link-href="approvalRulesPath" + :approvals="approvals" + /> + + <!-- Status checks --> + <!-- Follow-up: add status checks section (https://gitlab.com/gitlab-org/gitlab/-/issues/372362) --> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection.vue new file mode 100644 index 00000000000..cfe2df0dbda --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection.vue @@ -0,0 +1,99 @@ +<script> +import { GlCard, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ProtectionRow from './protection_row.vue'; + +export const i18n = { + rolesTitle: s__('BranchRules|Roles'), + usersTitle: s__('BranchRules|Users'), + groupsTitle: s__('BranchRules|Groups'), +}; + +export default { + name: 'ProtectionDetail', + i18n, + components: { GlCard, GlLink, ProtectionRow }, + props: { + header: { + type: String, + required: true, + }, + headerLinkTitle: { + type: String, + required: true, + }, + headerLinkHref: { + type: String, + required: true, + }, + roles: { + type: Array, + required: false, + default: () => [], + }, + users: { + type: Array, + required: false, + default: () => [], + }, + groups: { + type: Array, + required: false, + default: () => [], + }, + approvals: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + showUsersDivider() { + return Boolean(this.roles.length); + }, + showGroupsDivider() { + return Boolean(this.roles.length || this.users.length); + }, + }, +}; +</script> + +<template> + <gl-card class="gl-mb-5" body-class="gl-py-0"> + <template #header> + <div class="gl-display-flex gl-justify-content-space-between"> + <strong>{{ header }}</strong> + <gl-link :href="headerLinkHref">{{ headerLinkTitle }}</gl-link> + </div> + </template> + + <!-- Roles --> + <protection-row v-if="roles.length" :title="$options.i18n.rolesTitle" :access-levels="roles" /> + + <!-- Users --> + <protection-row + v-if="users.length" + :show-divider="showUsersDivider" + :users="users" + :title="$options.i18n.usersTitle" + /> + + <!-- Groups --> + <protection-row + v-if="groups.length" + :show-divider="showGroupsDivider" + :title="$options.i18n.groupsTitle" + :access-levels="groups" + /> + + <!-- Approvals --> + <protection-row + v-for="(approval, index) in approvals" + :key="approval.name" + :show-divider="index !== 0" + :title="approval.name" + :users="approval.eligibleApprovers.nodes" + :approvals-required="approval.approvalsRequired" + /> + </gl-card> +</template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue new file mode 100644 index 00000000000..28a1c09fa82 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue @@ -0,0 +1,110 @@ +<script> +import { GlAvatarsInline, GlAvatar, GlAvatarLink, GlTooltipDirective } from '@gitlab/ui'; +import { n__ } from '~/locale'; + +const AVATAR_TOOLTIP_MAX_CHARS = 100; +export const MAX_VISIBLE_AVATARS = 4; +export const AVATAR_SIZE = 32; + +export default { + name: 'ProtectionRow', + AVATAR_TOOLTIP_MAX_CHARS, + MAX_VISIBLE_AVATARS, + AVATAR_SIZE, + components: { GlAvatarsInline, GlAvatar, GlAvatarLink }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + title: { + type: String, + required: false, + default: null, + }, + accessLevels: { + type: Array, + required: false, + default: () => [], + }, + showDivider: { + type: Boolean, + required: false, + default: false, + }, + users: { + type: Array, + required: false, + default: () => [], + }, + approvalsRequired: { + type: Number, + required: false, + default: 0, + }, + }, + computed: { + avatarBadgeSrOnlyText() { + return n__( + '%d additional user', + '%d additional users', + this.users.length - this.$options.MAX_VISIBLE_AVATARS, + ); + }, + commaSeparateList() { + return this.accessLevels.length > 1; + }, + approvalsRequiredTitle() { + return this.approvalsRequired + ? n__('%d approval required', '%d approvals required', this.approvalsRequired) + : null; + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-align-items-center gl-border-gray-100 gl-mb-4 gl-pt-4" + :class="{ 'gl-border-t-solid': showDivider }" + > + <div class="gl-display-flex gl-w-half gl-justify-content-space-between"> + <div class="gl-mr-7 gl-w-quarter">{{ title }}</div> + + <gl-avatars-inline + v-if="users.length" + class="gl-w-quarter!" + :avatars="users" + :collapsed="true" + :max-visible="$options.MAX_VISIBLE_AVATARS" + :avatar-size="$options.AVATAR_SIZE" + badge-tooltip-prop="name" + :badge-tooltip-max-chars="$options.AVATAR_TOOLTIP_MAX_CHARS" + :badge-sr-only-text="avatarBadgeSrOnlyText" + > + <template #avatar="{ avatar }"> + <gl-avatar-link + :key="avatar.username" + v-gl-tooltip + target="_blank" + :href="avatar.webUrl" + :title="avatar.name" + > + <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="$options.AVATAR_SIZE" /> + </gl-avatar-link> + </template> + </gl-avatars-inline> + + <div + v-for="(item, index) in accessLevels" + :key="index" + data-testid="access-level" + class="gl-w-quarter" + > + <span v-if="commaSeparateList && index > 0" data-testid="comma-separator">,</span> + {{ item.accessLevelDescription }} + </div> + + <div class="gl-ml-7 gl-w-quarter">{{ approvalsRequiredTitle }}</div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js index 8452542540e..07fd0a7080f 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js +++ b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import RuleEdit from './components/rule_edit.vue'; +import View from './components/view/index.vue'; export default function mountBranchRules(el) { if (!el) { @@ -14,13 +14,18 @@ export default function mountBranchRules(el) { defaultClient: createDefaultClient(), }); - const { projectPath } = el.dataset; + const { projectPath, protectedBranchesPath, approvalRulesPath } = el.dataset; return new Vue({ el, apolloProvider, + provide: { + projectPath, + protectedBranchesPath, + approvalRulesPath, + }, render(h) { - return h(RuleEdit, { props: { projectPath } }); + return h(View); }, }); } diff --git a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql new file mode 100644 index 00000000000..3ac165498a1 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql @@ -0,0 +1,50 @@ +query getBranchRulesDetails($projectPath: ID!) { + project(fullPath: $projectPath) { + id + branchRules { + nodes { + name + branchProtection { + allowForcePush + codeOwnerApprovalRequired + mergeAccessLevels { + edges { + node { + accessLevel + accessLevelDescription + group { + id + avatarUrl + } + user { + id + name + avatarUrl + webUrl + } + } + } + } + pushAccessLevels { + edges { + node { + accessLevel + accessLevelDescription + group { + id + avatarUrl + } + user { + id + name + avatarUrl + webUrl + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue index 2209172c06d..cc47496971d 100644 --- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -9,7 +9,7 @@ import { GlSprintf, } from '@gitlab/ui'; import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __, s__, n__ } from '~/locale'; import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api'; import { LEVEL_TYPES, ACCESS_LEVELS } from '../constants'; @@ -163,7 +163,7 @@ export default { this.setSelected({ initial }); }) .catch(() => - createFlash({ message: __('Failed to load groups, users and deploy keys.') }), + createAlert({ message: __('Failed to load groups, users and deploy keys.') }), ) .finally(() => { this.initialLoading = false; @@ -175,7 +175,7 @@ export default { this.consolidateData(deployKeysResponse.data); this.setSelected({ initial }); }) - .catch(() => createFlash({ message: __('Failed to load deploy keys.') })) + .catch(() => createAlert({ message: __('Failed to load deploy keys.') })) .finally(() => { this.initialLoading = false; this.loading = false; diff --git a/app/assets/javascripts/projects/settings/components/default_branch_selector.vue b/app/assets/javascripts/projects/settings/components/default_branch_selector.vue new file mode 100644 index 00000000000..fee2f591216 --- /dev/null +++ b/app/assets/javascripts/projects/settings/components/default_branch_selector.vue @@ -0,0 +1,38 @@ +<script> +import RefSelector from '~/ref/components/ref_selector.vue'; +import { REF_TYPE_BRANCHES } from '~/ref/constants'; +import { __ } from '~/locale'; + +export default { + components: { + RefSelector, + }, + props: { + persistedDefaultBranch: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + }, + refTypes: [REF_TYPE_BRANCHES], + i18n: { + dropdownHeader: __('Select default branch'), + searchPlaceholder: __('Search branch'), + }, +}; +</script> +<template> + <ref-selector + :value="persistedDefaultBranch" + class="gl-w-full" + :project-id="projectId" + :enabled-ref-types="$options.refTypes" + :translations="$options.i18n" + name="project[default_branch]" + data-testid="default-branch-dropdown" + data-qa-selector="default_branch_dropdown" + /> +</template> diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue index c13753da00b..55420c9c732 100644 --- a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue +++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue @@ -1,13 +1,14 @@ <script> -import { GlFormGroup } from '@gitlab/ui'; -import produce from 'immer'; +import { GlFormGroup, GlAlert } from '@gitlab/ui'; +import { debounce } from 'lodash'; import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; -import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getTransferLocations } from '~/api/projects_api'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; -import searchNamespacesWhereUserCanTransferProjects from '../graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql'; - -const GROUPS_PER_PAGE = 25; +import { s__, __ } from '~/locale'; +import currentUserNamespace from '../graphql/queries/current_user_namespace.query.graphql'; export default { name: 'TransferProjectForm', @@ -15,7 +16,15 @@ export default { GlFormGroup, NamespaceSelect, ConfirmDanger, + GlAlert, + }, + i18n: { + errorMessage: s__( + 'ProjectTransfer|An error occurred fetching the transfer locations, please refresh the page and try again.', + ), + alertDismissAlert: __('Dismiss'), }, + inject: ['projectId'], props: { confirmationPhrase: { type: String, @@ -26,93 +35,131 @@ export default { required: true, }, }, - apollo: { - currentUser: { - query: searchNamespacesWhereUserCanTransferProjects, - debounce: DEBOUNCE_DELAY, - variables() { - return { - search: this.searchTerm, - after: null, - first: GROUPS_PER_PAGE, - }; - }, - result() { - this.isLoadingMoreGroups = false; - this.isSearchLoading = false; - }, - }, - }, data() { return { - currentUser: {}, + userNamespaces: [], + groupNamespaces: [], + initialNamespacesLoaded: false, selectedNamespace: null, - isLoadingMoreGroups: false, + hasError: false, + isLoading: false, isSearchLoading: false, searchTerm: '', + page: 1, + totalPages: 1, }; }, computed: { hasSelectedNamespace() { return Boolean(this.selectedNamespace?.id); }, - groupNamespaces() { - return this.currentUser.groups?.nodes?.map(this.formatNamespace) || []; - }, - userNamespaces() { - const { namespace } = this.currentUser; - - return namespace ? [this.formatNamespace(namespace)] : []; - }, hasNextPageOfGroups() { - return this.currentUser.groups?.pageInfo?.hasNextPage || false; + return this.page < this.totalPages; }, }, methods: { + async handleShow() { + if (this.initialNamespacesLoaded) { + return; + } + + this.isLoading = true; + + [this.groupNamespaces, this.userNamespaces] = await Promise.all([ + this.getGroupNamespaces(), + this.getUserNamespaces(), + ]); + + this.isLoading = false; + this.initialNamespacesLoaded = true; + }, handleSelect(selectedNamespace) { this.selectedNamespace = selectedNamespace; this.$emit('selectNamespace', selectedNamespace.id); }, - handleLoadMoreGroups() { - this.isLoadingMoreGroups = true; + async getGroupNamespaces() { + try { + const { data: groupNamespaces, headers } = await getTransferLocations(this.projectId, { + page: this.page, + search: this.searchTerm, + }); + + const { totalPages } = parseIntPagination(normalizeHeaders(headers)); + this.totalPages = totalPages; - this.$apollo.queries.currentUser.fetchMore({ - variables: { - after: this.currentUser.groups.pageInfo.endCursor, - first: GROUPS_PER_PAGE, - }, - updateQuery( - previousResult, + return groupNamespaces.map(({ id, full_name: humanName }) => ({ + id, + humanName, + })); + } catch (error) { + this.hasError = true; + + return []; + } + }, + async getUserNamespaces() { + try { + const { + data: { + currentUser: { namespace }, + }, + } = await this.$apollo.query({ + query: currentUserNamespace, + }); + + if (!namespace) { + return []; + } + + return [ { - fetchMoreResult: { - currentUser: { groups: newGroups }, - }, + id: getIdFromGraphQLId(namespace.id), + humanName: namespace.fullName, }, - ) { - const previousGroups = previousResult.currentUser.groups; + ]; + } catch (error) { + this.hasError = true; - return produce(previousResult, (draftData) => { - draftData.currentUser.groups.nodes = [...previousGroups.nodes, ...newGroups.nodes]; - draftData.currentUser.groups.pageInfo = newGroups.pageInfo; - }); - }, - }); + return []; + } }, - handleSearch(searchTerm) { + async handleLoadMoreGroups() { + this.isLoading = true; + this.page += 1; + + const groupNamespaces = await this.getGroupNamespaces(); + this.groupNamespaces.push(...groupNamespaces); + + this.isLoading = false; + }, + debouncedSearch: debounce(async function debouncedSearch() { this.isSearchLoading = true; + + this.groupNamespaces = await this.getGroupNamespaces(); + + this.isSearchLoading = false; + }, DEBOUNCE_DELAY), + handleSearch(searchTerm) { this.searchTerm = searchTerm; + this.page = 1; + + this.debouncedSearch(); }, - formatNamespace({ id, fullName }) { - return { - id: getIdFromGraphQLId(id), - humanName: fullName, - }; + handleAlertDismiss() { + this.hasError = false; }, }, }; </script> <template> <div> + <gl-alert + v-if="hasError" + variant="danger" + :dismiss-label="$options.i18n.alertDismissLabel" + @dismiss="handleAlertDismiss" + >{{ $options.i18n.errorMessage }}</gl-alert + > <gl-form-group> <namespace-select data-testid="transfer-project-namespace" @@ -121,12 +168,13 @@ export default { :user-namespaces="userNamespaces" :selected-namespace="selectedNamespace" :has-next-page-of-groups="hasNextPageOfGroups" - :is-loading-more-groups="isLoadingMoreGroups" + :is-loading="isLoading" :is-search-loading="isSearchLoading" :should-filter-namespaces="false" @select="handleSelect" @load-more-groups="handleLoadMoreGroups" @search="handleSearch" + @show="handleShow" /> </gl-form-group> <confirm-danger diff --git a/app/assets/javascripts/projects/settings/graphql/queries/current_user_namespace.query.graphql b/app/assets/javascripts/projects/settings/graphql/queries/current_user_namespace.query.graphql new file mode 100644 index 00000000000..7ae6ee1428b --- /dev/null +++ b/app/assets/javascripts/projects/settings/graphql/queries/current_user_namespace.query.graphql @@ -0,0 +1,9 @@ +query currentUserNamespace { + currentUser { + id + namespace { + id + fullName + } + } +} diff --git a/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql b/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql deleted file mode 100644 index d4bcb8c869c..00000000000 --- a/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql +++ /dev/null @@ -1,24 +0,0 @@ -#import "~/graphql_shared/fragments/page_info.fragment.graphql" - -query searchNamespacesWhereUserCanTransferProjects( - $search: String = "" - $after: String = "" - $first: Int = null -) { - currentUser { - id - groups(permissionScope: TRANSFER_PROJECTS, search: $search, after: $after, first: $first) { - nodes { - id - fullName - } - pageInfo { - ...PageInfo - } - } - namespace { - id - fullName - } - } -} diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js index bc1aff640d2..89c158a9ba8 100644 --- a/app/assets/javascripts/projects/settings/init_transfer_project_form.js +++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js @@ -12,6 +12,7 @@ export default () => { Vue.use(VueApollo); const { + projectId, targetFormId = null, targetHiddenInputId = null, buttonText: confirmButtonText = '', @@ -26,6 +27,7 @@ export default () => { }), provide: { confirmDangerMessage, + projectId, }, render(createElement) { return createElement(TransferProjectForm, { diff --git a/app/assets/javascripts/projects/settings/mount_default_branch_selector.js b/app/assets/javascripts/projects/settings/mount_default_branch_selector.js new file mode 100644 index 00000000000..611561e38f2 --- /dev/null +++ b/app/assets/javascripts/projects/settings/mount_default_branch_selector.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import DefaultBranchSelector from './components/default_branch_selector.vue'; + +export default (el) => { + if (!el) { + return null; + } + + const { projectId, defaultBranch } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(DefaultBranchSelector, { + props: { + persistedDefaultBranch: defaultBranch, + projectId, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue index e8eaf0a70b2..94793a535cc 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -1,6 +1,6 @@ <script> import { s__ } from '~/locale'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import branchRulesQuery from './graphql/queries/branch_rules.query.graphql'; import BranchRule from './components/branch_rule.vue'; @@ -31,14 +31,13 @@ export default { return data.project?.branchRules?.nodes || []; }, error() { - createFlash({ message: this.$options.i18n.queryError }); + createAlert({ message: this.$options.i18n.queryError }); }, }, }, - props: { + inject: { projectPath: { - type: String, - required: true, + default: '', }, }, data() { diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue index 68750318029..2b88f8561d7 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue @@ -1,10 +1,11 @@ <script> -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; export const i18n = { defaultLabel: s__('BranchRules|default'), protectedLabel: s__('BranchRules|protected'), + detailsButtonLabel: s__('BranchRules|Details'), }; export default { @@ -12,6 +13,12 @@ export default { i18n, components: { GlBadge, + GlButton, + }, + inject: { + branchRulesPath: { + default: '', + }, }, props: { name: { @@ -38,24 +45,30 @@ export default { hasApprovalDetails() { return this.approvalDetails && this.approvalDetails.length; }, + detailsPath() { + return `${this.branchRulesPath}?branch=${this.name}`; + }, }, }; </script> <template> - <div class="gl-border-b gl-pt-5 gl-pb-5"> - <strong class="gl-font-monospace">{{ name }}</strong> + <div class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between"> + <div> + <strong class="gl-font-monospace">{{ name }}</strong> - <gl-badge v-if="isDefault" variant="info" size="sm" class="gl-ml-2">{{ - $options.i18n.defaultLabel - }}</gl-badge> + <gl-badge v-if="isDefault" variant="info" size="sm" class="gl-ml-2">{{ + $options.i18n.defaultLabel + }}</gl-badge> - <gl-badge v-if="isProtected" variant="success" size="sm" class="gl-ml-2">{{ - $options.i18n.protectedLabel - }}</gl-badge> + <gl-badge v-if="isProtected" variant="success" size="sm" class="gl-ml-2">{{ + $options.i18n.protectedLabel + }}</gl-badge> - <ul v-if="hasApprovalDetails" class="gl-pl-6 gl-mt-2 gl-mb-0 gl-text-gray-500"> - <li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li> - </ul> + <ul v-if="hasApprovalDetails" class="gl-pl-6 gl-mt-2 gl-mb-0 gl-text-gray-500"> + <li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li> + </ul> + </div> + <gl-button :href="detailsPath"> {{ $options.i18n.detailsButtonLabel }}</gl-button> </div> </template> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js index 35322e2e466..042be089e09 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js @@ -12,17 +12,17 @@ const apolloProvider = new VueApollo({ export default function mountBranchRules(el) { if (!el) return null; - const { projectPath } = el.dataset; + const { projectPath, branchRulesPath } = el.dataset; return new Vue({ el, apolloProvider, + provide: { + projectPath, + branchRulesPath, + }, render(createElement) { - return createElement(BranchRulesApp, { - props: { - projectPath, - }, - }); + return createElement(BranchRulesApp); }, }); } |