diff options
Diffstat (limited to 'app/assets/javascripts/sidebar/components/reviewers')
8 files changed, 604 insertions, 0 deletions
diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue new file mode 100644 index 00000000000..6de926e0ff9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue @@ -0,0 +1,24 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import ReviewerAvatar from './reviewer_avatar.vue'; + +export default { + components: { + ReviewerAvatar, + }, + props: { + user: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <button type="button" class="btn-link"> + <reviewer-avatar :user="user" :img-size="24" /> + <span class="author"> {{ user.name }} </span> + </button> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue new file mode 100644 index 00000000000..45707c18f7b --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue @@ -0,0 +1,107 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import CollapsedReviewer from './collapsed_reviewer.vue'; + +const DEFAULT_MAX_COUNTER = 99; +const DEFAULT_RENDER_COUNT = 5; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + CollapsedReviewer, + GlIcon, + }, + props: { + users: { + type: Array, + required: true, + }, + }, + computed: { + hasNoUsers() { + return !this.users.length; + }, + hasMoreThanOneReviewer() { + return this.users.length > 1; + }, + hasMoreThanTwoReviewers() { + return this.users.length > 2; + }, + allReviewersCanMerge() { + return this.users.every(user => user.can_merge); + }, + sidebarAvatarCounter() { + if (this.users.length > DEFAULT_MAX_COUNTER) { + return `${DEFAULT_MAX_COUNTER}+`; + } + + return `+${this.users.length - 1}`; + }, + collapsedUsers() { + const collapsedLength = this.hasMoreThanTwoReviewers ? 1 : this.users.length; + + return this.users.slice(0, collapsedLength); + }, + tooltipTitleMergeStatus() { + const mergeLength = this.users.filter(u => u.can_merge).length; + + if (mergeLength === this.users.length) { + return ''; + } else if (mergeLength > 0) { + return sprintf(__('%{mergeLength}/%{usersLength} can merge'), { + mergeLength, + usersLength: this.users.length, + }); + } + + return this.users.length === 1 ? __('cannot merge') : __('no one can merge'); + }, + tooltipTitle() { + const maxRender = Math.min(DEFAULT_RENDER_COUNT, this.users.length); + const renderUsers = this.users.slice(0, maxRender); + const names = renderUsers.map(u => u.name); + + if (!this.users.length) { + return __('Reviewer(s)'); + } + + if (this.users.length > names.length) { + names.push(sprintf(__('+ %{amount} more'), { amount: this.users.length - names.length })); + } + + const text = names.join(', '); + + return this.tooltipTitleMergeStatus ? `${text} (${this.tooltipTitleMergeStatus})` : text; + }, + + tooltipOptions() { + return { container: 'body', placement: 'left', boundary: 'viewport' }; + }, + }, +}; +</script> + +<template> + <div + v-gl-tooltip="tooltipOptions" + :class="{ 'multiple-users': hasMoreThanOneReviewer }" + :title="tooltipTitle" + class="sidebar-collapsed-icon sidebar-collapsed-user" + > + <gl-icon v-if="hasNoUsers" name="user" :aria-label="__('None')" /> + <collapsed-reviewer v-for="user in collapsedUsers" :key="user.id" :user="user" /> + <button v-if="hasMoreThanTwoReviewers" class="btn-link" type="button"> + <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span> + <i + v-if="!allReviewersCanMerge" + aria-hidden="true" + class="fa fa-exclamation-triangle merge-icon" + ></i> + </button> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue new file mode 100644 index 00000000000..9fa3fa38eac --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue @@ -0,0 +1,43 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { __, sprintf } from '~/locale'; + +export default { + props: { + user: { + type: Object, + required: true, + }, + imgSize: { + type: Number, + required: true, + }, + }, + computed: { + reviewerAlt() { + return sprintf(__("%{userName}'s avatar"), { userName: this.user.name }); + }, + avatarUrl() { + return this.user.avatar || this.user.avatar_url || gon.default_avatar_url; + }, + hasMergeIcon() { + return !this.user.can_merge; + }, + }, +}; +</script> + +<template> + <span class="position-relative"> + <img + :alt="reviewerAlt" + :src="avatarUrl" + :width="imgSize" + :class="`s${imgSize}`" + class="avatar avatar-inline m-0" + data-qa-selector="avatar_image" + /> + <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i> + </span> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue new file mode 100644 index 00000000000..b1b04564a62 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -0,0 +1,84 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import ReviewerAvatar from './reviewer_avatar.vue'; + +export default { + components: { + ReviewerAvatar, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + user: { + type: Object, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + tooltipPlacement: { + type: String, + default: 'bottom', + required: false, + }, + tooltipHasName: { + type: Boolean, + default: true, + required: false, + }, + issuableType: { + type: String, + default: 'issue', + required: false, + }, + }, + computed: { + cannotMerge() { + return this.issuableType === 'merge_request' && !this.user.can_merge; + }, + tooltipTitle() { + if (this.cannotMerge && this.tooltipHasName) { + return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name }); + } else if (this.cannotMerge) { + return __('Cannot merge'); + } else if (this.tooltipHasName) { + return this.user.name; + } + + return ''; + }, + tooltipOption() { + return { + container: 'body', + placement: this.tooltipPlacement, + boundary: 'viewport', + }; + }, + reviewerUrl() { + return this.user.web_url; + }, + }, +}; +</script> + +<template> + <!-- must be `d-inline-block` or parent flex-basis causes width issues --> + <gl-link + v-gl-tooltip="tooltipOption" + :href="reviewerUrl" + :title="tooltipTitle" + class="d-inline-block" + > + <!-- use d-flex so that slot can be appropriately styled --> + <span class="d-flex"> + <reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> + <slot :user="user"></slot> + </span> + </gl-link> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue new file mode 100644 index 00000000000..437f28907fd --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue @@ -0,0 +1,64 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { GlLoadingIcon } from '@gitlab/ui'; +import { n__ } from '~/locale'; + +export default { + name: 'ReviewerTitle', + components: { + GlLoadingIcon, + }, + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + numberOfReviewers: { + type: Number, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + showToggle: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + reviewerTitle() { + const reviewers = this.numberOfReviewers; + return n__('Reviewer', `%d Reviewers`, reviewers); + }, + }, +}; +</script> +<template> + <div class="title hide-collapsed"> + {{ reviewerTitle }} + <gl-loading-icon v-if="loading" inline class="align-bottom" /> + <a + v-if="editable" + class="js-sidebar-dropdown-toggle edit-link float-right" + href="#" + data-track-event="click_edit_button" + data-track-label="right_sidebar" + data-track-property="reviewer" + > + {{ __('Edit') }} + </a> + <a + v-if="showToggle" + :aria-label="__('Toggle sidebar')" + class="gutter-toggle float-right js-sidebar-toggle" + href="#" + role="button" + > + <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i> + </a> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue new file mode 100644 index 00000000000..6a3d88f6385 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -0,0 +1,72 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import CollapsedReviewerList from './collapsed_reviewer_list.vue'; +import UncollapsedReviewerList from './uncollapsed_reviewer_list.vue'; + +export default { + // name: 'Reviewers' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Reviewers', + components: { + CollapsedReviewerList, + UncollapsedReviewerList, + }, + props: { + rootPath: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + computed: { + hasNoUsers() { + return !this.users.length; + }, + sortedReviewers() { + const canMergeUsers = this.users.filter(user => user.can_merge); + const canNotMergeUsers = this.users.filter(user => !user.can_merge); + + return [...canMergeUsers, ...canNotMergeUsers]; + }, + }, + methods: { + assignSelf() { + this.$emit('assign-self'); + }, + }, +}; +</script> + +<template> + <div> + <collapsed-reviewer-list :users="sortedReviewers" :issuable-type="issuableType" /> + + <div class="value hide-collapsed"> + <template v-if="hasNoUsers"> + <span class="assign-yourself no-value qa-assign-yourself"> + {{ __('None') }} + </span> + </template> + + <uncollapsed-reviewer-list + v-else + :users="sortedReviewers" + :root-path="rootPath" + :issuable-type="issuableType" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue new file mode 100644 index 00000000000..5d8a2e6fa65 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -0,0 +1,107 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { deprecatedCreateFlash as Flash } from '~/flash'; +import eventHub from '~/sidebar/event_hub'; +import Store from '~/sidebar/stores/sidebar_store'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ReviewerTitle from './reviewer_title.vue'; +import Reviewers from './reviewers.vue'; +import { __ } from '~/locale'; + +export default { + name: 'SidebarReviewers', + components: { + ReviewerTitle, + Reviewers, + }, + mixins: [glFeatureFlagsMixin()], + props: { + mediator: { + type: Object, + required: true, + }, + field: { + type: String, + required: true, + }, + signedIn: { + type: Boolean, + required: false, + default: false, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + issuableIid: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + store: new Store(), + loading: false, + }; + }, + created() { + this.removeReviewer = this.store.removeReviewer.bind(this.store); + this.addReviewer = this.store.addReviewer.bind(this.store); + this.removeAllReviewers = this.store.removeAllReviewers.bind(this.store); + + // Get events from deprecatedJQueryDropdown + eventHub.$on('sidebar.removeReviewer', this.removeReviewer); + eventHub.$on('sidebar.addReviewer', this.addReviewer); + eventHub.$on('sidebar.removeAllReviewers', this.removeAllReviewers); + eventHub.$on('sidebar.saveReviewers', this.saveReviewers); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeReviewer', this.removeReviewer); + eventHub.$off('sidebar.addReviewer', this.addReviewer); + eventHub.$off('sidebar.removeAllReviewers', this.removeAllReviewers); + eventHub.$off('sidebar.saveReviewers', this.saveReviewers); + }, + methods: { + saveReviewers() { + this.loading = true; + + this.mediator + .saveReviewers(this.field) + .then(() => { + this.loading = false; + // Uncomment once this issue has been addressed > https://gitlab.com/gitlab-org/gitlab/-/issues/237922 + // refreshUserMergeRequestCounts(); + }) + .catch(() => { + this.loading = false; + return new Flash(__('Error occurred when saving reviewers')); + }); + }, + }, +}; +</script> + +<template> + <div> + <reviewer-title + :number-of-reviewers="store.reviewers.length" + :loading="loading || store.isFetching.reviewers" + :editable="store.editable" + :show-toggle="!signedIn" + /> + <reviewers + v-if="!store.isFetching.reviewers" + :root-path="store.rootPath" + :users="store.reviewers" + :editable="store.editable" + :issuable-type="issuableType" + class="value" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue new file mode 100644 index 00000000000..2ae4a114b36 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -0,0 +1,103 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { __, sprintf } from '~/locale'; +import ReviewerAvatarLink from './reviewer_avatar_link.vue'; + +const DEFAULT_RENDER_COUNT = 5; + +export default { + components: { + ReviewerAvatarLink, + }, + props: { + users: { + type: Array, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + data() { + return { + showLess: true, + }; + }, + computed: { + firstUser() { + return this.users[0]; + }, + hasOneUser() { + return this.users.length === 1; + }, + hiddenReviewersLabel() { + const { numberOfHiddenReviewers } = this; + return sprintf(__('+ %{numberOfHiddenReviewers} more'), { numberOfHiddenReviewers }); + }, + renderShowMoreSection() { + return this.users.length > DEFAULT_RENDER_COUNT; + }, + numberOfHiddenReviewers() { + return this.users.length - DEFAULT_RENDER_COUNT; + }, + uncollapsedUsers() { + const uncollapsedLength = this.showLess + ? Math.min(this.users.length, DEFAULT_RENDER_COUNT) + : this.users.length; + return this.showLess ? this.users.slice(0, uncollapsedLength) : this.users; + }, + username() { + return `@${this.firstUser.username}`; + }, + }, + methods: { + toggleShowLess() { + this.showLess = !this.showLess; + }, + }, +}; +</script> + +<template> + <reviewer-avatar-link + v-if="hasOneUser" + #default="{ user }" + tooltip-placement="left" + :tooltip-has-name="false" + :user="firstUser" + :root-path="rootPath" + :issuable-type="issuableType" + > + <div class="ml-2"> + <span class="author"> {{ user.name }} </span> + <span class="username"> {{ username }} </span> + </div> + </reviewer-avatar-link> + <div v-else> + <div class="user-list"> + <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item"> + <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" /> + </div> + </div> + <div v-if="renderShowMoreSection" class="user-list-more"> + <button + type="button" + class="btn-link" + data-qa-selector="more_reviewers_link" + @click="toggleShowLess" + > + <template v-if="showLess"> + {{ hiddenReviewersLabel }} + </template> + <template v-else>{{ __('- show less') }}</template> + </button> + </div> + </div> +</template> |