diff --git a/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql new file mode 100644 index 0000000000000000000000000000000000000000..2bd016feb190aa85ba66d50a2d11978556dd79a4 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql @@ -0,0 +1,24 @@ +#import "../fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +query projectUsersSearchWithMRPermissions( + $search: String! + $fullPath: ID! + $mergeRequestId: MergeRequestID! +) { + workspace: project(fullPath: $fullPath) { + id + users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) { + nodes { + id + mergeRequestInteraction(id: $mergeRequestId) { + canMerge + } + user { + ...User + ...UserAvailability + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue index da9ff407faf978e192ab270cb10a1a24353b9099..240e12ee5979d0ba674da27d179431e581b339b3 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -1,5 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; +import { IssuableType } from '~/issues/constants'; import { __, sprintf } from '~/locale'; export default { @@ -31,10 +32,11 @@ export default { ); }, isMergeRequest() { - return this.issuableType === 'merge_request'; + return this.issuableType === IssuableType.MergeRequest; }, hasMergeIcon() { - return this.isMergeRequest && !this.user.can_merge; + const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge; + return this.isMergeRequest && !canMerge; }, }, }; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue index 2a237e7ace03d9adff1a238d891845a5fa980599..578c344da02d736e66ef4e6460590e0fca2efe69 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { IssuableType } from '~/issues/constants'; import { __ } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; import AssigneeAvatar from './assignee_avatar.vue'; @@ -71,7 +72,8 @@ export default { }, computed: { cannotMerge() { - return this.issuableType === 'merge_request' && !this.user.can_merge; + const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge; + return this.issuableType === IssuableType.MergeRequest && !canMerge; }, tooltipTitle() { const { name = '', availability = '' } = this.user; diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue index 6a74ab83c220cd7af09f325e1c9b9ca214be8066..856687c00ae1df996ac6fba4e3a37561be6a6dc3 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -58,7 +58,7 @@ export default { return this.users.length > 2; }, allAssigneesCanMerge() { - return this.users.every((user) => user.can_merge); + return this.users.every((user) => user.can_merge || user.mergeRequestInteraction?.canMerge); }, sidebarAvatarCounter() { if (this.users.length > DEFAULT_MAX_COUNTER) { @@ -77,7 +77,9 @@ export default { return ''; } - const mergeLength = this.users.filter((u) => u.can_merge).length; + const mergeLength = this.users.filter( + (u) => u.can_merge || u.mergeRequestInteraction?.canMerge, + ).length; if (mergeLength === this.users.length) { return ''; diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index a3379784bc11cdb6a75370e9ce4bd5a79078aa4b..59a4eb54bbea819712b9dd98ef7b30d62226b910 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -44,7 +44,7 @@ export default { <div class="gl-display-flex gl-flex-direction-column issuable-assignees"> <div v-if="emptyUsers" - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-2 hide-collapsed" + class="gl-display-flex gl-align-items-center gl-text-gray-500 hide-collapsed" data-testid="none" > <span> {{ __('None') }}</span> @@ -65,7 +65,7 @@ export default { v-else :users="users" :issuable-type="issuableType" - class="gl-text-gray-800 gl-mt-2 hide-collapsed" + class="gl-text-gray-800 hide-collapsed" @toggle-attention-requested="toggleAttentionRequested" /> </div> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 9c031ae64f8fdd3323500cfd74125d5ca63e1bc2..7743004a2933932e7797ef83d2f165aa88417721 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -1,6 +1,5 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; -import { cloneDeep } from 'lodash'; import Vue from 'vue'; import createFlash from '~/flash'; import { IssuableType } from '~/issues/constants'; @@ -101,7 +100,10 @@ export default { } const issuable = data.workspace?.issuable; if (issuable) { - this.selected = cloneDeep(issuable.assignees.nodes); + this.selected = issuable.assignees.nodes.map((node) => ({ + ...node, + canMerge: node.mergeRequestInteraction?.canMerge || false, + })); } }, error() { @@ -141,6 +143,7 @@ export default { username: gon?.current_username, name: gon?.current_user_fullname, avatarUrl: gon?.current_user_avatar_url, + canMerge: this.issuable?.userPermissions?.canMerge || false, }; }, signedIn() { @@ -206,8 +209,8 @@ export default { expandWidget() { this.$refs.toggle.expand(); }, - focusSearch() { - this.$refs.userSelect.focusSearch(); + showDropdown() { + this.$refs.userSelect.showDropdown(); }, showError() { createFlash({ message: __('An error occurred while fetching participants.') }); @@ -236,11 +239,11 @@ export default { :initial-loading="isAssigneesLoading" :title="assigneeText" :is-dirty="isDirty" - @open="focusSearch" + @open="showDropdown" @close="saveAssignees" > <template #collapsed> - <slot name="collapsed" :users="assignees" :on-click="expandWidget"></slot> + <slot name="collapsed" :users="assignees"></slot> <issuable-assignees :users="assignees" :issuable-type="issuableType" @@ -256,12 +259,13 @@ export default { :text="$options.i18n.assignees" :header-text="$options.i18n.assignTo" :iid="iid" + :issuable-id="issuableId" :full-path="fullPath" :allow-multiple-assignees="allowMultipleAssignees" :current-user="currentUser" :issuable-type="issuableType" :is-editing="edit" - class="gl-w-full dropdown-menu-user" + class="gl-w-full dropdown-menu-user gl-mt-n3" @toggle="collapseWidget" @error="showError" @input="setDirtyState" diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue index 8ef65ef7308b1cdbc66f44bca3b77a6ec3c53d33..28bc5afc1a403dd514e4eb6c527a117bdf5c0d09 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue @@ -30,6 +30,6 @@ export default { :event="$options.dataTrackEvent" :label="$options.dataTrackLabel" :trigger-source="triggerSource" - classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" + classes="gl-display-block gl-pl-0 gl-hover-text-decoration-none gl-hover-text-blue-800!" /> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue index e2a38a100b968fe9992189598552c85a38889e77..19f588b28be3d8fa7d256e171fb6a173905a319a 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -1,17 +1,24 @@ <script> -import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui'; +import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui'; +import { IssuableType } from '~/issues/constants'; import { s__, sprintf } from '~/locale'; export default { components: { GlAvatarLabeled, GlAvatarLink, + GlIcon, }, props: { user: { type: Object, required: true, }, + issuableType: { + type: String, + required: false, + default: IssuableType.Issue, + }, }, computed: { userLabel() { @@ -22,6 +29,9 @@ export default { author: this.user.name, }); }, + hasCannotMergeIcon() { + return this.issuableType === IssuableType.MergeRequest && !this.user.canMerge; + }, }, }; </script> @@ -31,9 +41,19 @@ export default { <gl-avatar-labeled :size="32" :label="userLabel" - :sub-label="user.username" + :sub-label="`@${user.username}`" :src="user.avatarUrl || user.avatar || user.avatar_url" - class="gl-align-items-center" - /> + class="gl-align-items-center gl-relative" + > + <template #meta> + <gl-icon + v-if="hasCannotMergeIcon" + name="warning-solid" + aria-hidden="true" + class="merge-icon" + :size="12" + /> + </template> + </gl-avatar-labeled> </gl-avatar-link> </template> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index f53579eb75ab93e6dbe7b750842cae7ccebc9d16..989dc574bc3f58bfc788611ef04e6d22f21aeafa 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,7 +1,8 @@ import { s__, sprintf } from '~/locale'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; +import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql'; import { IssuableType, WorkspaceType } from '~/issues/constants'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql'; import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql'; @@ -55,8 +56,6 @@ import projectIssueMilestoneMutation from './queries/project_issue_milestone.mut import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql'; import projectMilestonesQuery from './queries/project_milestones.query.graphql'; -export const ASSIGNEES_DEBOUNCE_DELAY = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; - export const defaultEpicSort = 'TITLE_ASC'; export const epicIidPattern = /^&(?<iid>\d+)$/; @@ -93,6 +92,15 @@ export const participantsQueries = { }, }; +export const userSearchQueries = { + [IssuableType.Issue]: { + query: userSearchQuery, + }, + [IssuableType.MergeRequest]: { + query: userSearchWithMRPermissionsQuery, + }, +}; + export const confidentialityQueries = { [IssuableType.Issue]: { query: issueConfidentialQuery, diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 32e27ca713751d3ec13de5119319073db0c0b01c..2a7d967cb612ab963abff41937bc557b6b0ae0cb 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -10,6 +10,7 @@ import { isInIssuePage, isInDesignPage, isInIncidentPage, + isInMRPage, parseBoolean, } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -136,6 +137,8 @@ function mountAssigneesComponent() { if (!el) return; const { id, iid, fullPath, editable } = getSidebarOptions(); + const isIssuablePage = isInIssuePage() || isInIncidentPage() || isInDesignPage(); + const issuableType = isIssuablePage ? IssuableType.Issue : IssuableType.MergeRequest; // eslint-disable-next-line no-new new Vue({ el, @@ -153,21 +156,16 @@ function mountAssigneesComponent() { props: { iid: String(iid), fullPath, - issuableType: - isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue - : IssuableType.MergeRequest, + issuableType, issuableId: id, allowMultipleAssignees: !el.dataset.maxAssignees, }, scopedSlots: { - collapsed: ({ users, onClick }) => + collapsed: ({ users }) => createElement(CollapsedAssigneeList, { props: { users, - }, - nativeOn: { - click: onClick, + issuableType, }, }), }, @@ -616,7 +614,7 @@ function mountCopyEmailComponent() { } const isAssigneesWidgetShown = - (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget; + (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget; export function mountSidebar(mediator, store) { initInviteMembersModal(); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql index 81e19e48d753a833d01c4bf793560daf63550875..7127940bb05df29acaf1e7a9980ccd0e5b32c7c6 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql @@ -10,8 +10,14 @@ query getMrAssignees($fullPath: ID!, $iid: String!) { nodes { ...User ...UserAvailability + mergeRequestInteraction { + canMerge + } } } + userPermissions { + canMerge + } } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql index 77140ea36d89f68c6190ac09411b24d8556f21be..5fec2ccbdfb3a3f13068bc40c74073dbb12042df 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql @@ -2,21 +2,18 @@ #import "~/graphql_shared/fragments/user_availability.fragment.graphql" mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) { - mergeRequestSetAssignees( + issuableSetAssignees: mergeRequestSetAssignees( input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath } ) { - mergeRequest { + issuable: mergeRequest { id assignees { nodes { ...User ...UserAvailability - } - } - participants { - nodes { - ...User - ...UserAvailability + mergeRequestInteraction { + canMerge + } } } } diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue index b85cae0c64f1076a01db67757985a3c059402a29..9df5254155ede91377a4bbae9b06a578f7b79418 100644 --- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue +++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue @@ -1,4 +1,5 @@ <script> +import { debounce } from 'lodash'; import { GlDropdown, GlDropdownForm, @@ -6,11 +7,14 @@ import { GlDropdownItem, GlSearchBoxByType, GlLoadingIcon, + GlTooltipDirective, } from '@gitlab/ui'; -import searchUsers from '~/graphql_shared/queries/users_search.query.graphql'; import { __ } from '~/locale'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; -import { ASSIGNEES_DEBOUNCE_DELAY, participantsQueries } from '~/sidebar/constants'; +import { IssuableType } from '~/issues/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { participantsQueries, userSearchQueries } from '~/sidebar/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; export default { i18n: { @@ -25,6 +29,9 @@ export default { SidebarParticipant, GlLoadingIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { headerText: { type: String, @@ -58,13 +65,18 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: IssuableType.Issue, }, isEditing: { type: Boolean, required: false, default: true, }, + issuableId: { + type: Number, + required: false, + default: null, + }, }, data() { return { @@ -89,28 +101,35 @@ export default { }; }, update(data) { - return data.workspace?.issuable?.participants.nodes; + return data.workspace?.issuable?.participants.nodes.map((node) => ({ + ...node, + canMerge: false, + })); }, error() { this.$emit('error'); }, }, searchUsers: { - query: searchUsers, + query() { + return userSearchQueries[this.issuableType].query; + }, variables() { - return { - fullPath: this.fullPath, - search: this.search, - first: 20, - }; + return this.searchUsersVariables; }, skip() { return !this.isEditing; }, update(data) { - return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || []; + return ( + data.workspace?.users?.nodes + .filter((x) => x?.user) + .map((node) => ({ + ...node.user, + canMerge: node.mergeRequestInteraction?.canMerge || false, + })) || [] + ); }, - debounce: ASSIGNEES_DEBOUNCE_DELAY, error() { this.$emit('error'); this.isSearching = false; @@ -121,6 +140,23 @@ export default { }, }, computed: { + isMergeRequest() { + return this.issuableType === IssuableType.MergeRequest; + }, + searchUsersVariables() { + const variables = { + fullPath: this.fullPath, + search: this.search, + first: 20, + }; + if (!this.isMergeRequest) { + return variables; + } + return { + ...variables, + mergeRequestId: convertToGraphQLId('MergeRequest', this.issuableId), + }; + }, isLoading() { return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading; }, @@ -135,8 +171,8 @@ export default { // TODO this de-duplication is temporary (BE fix required) // https://gitlab.com/gitlab-org/gitlab/-/issues/327822 - const mergedSearchResults = filteredParticipants - .concat(this.searchUsers) + const mergedSearchResults = this.searchUsers + .concat(filteredParticipants) .reduce( (acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]), [], @@ -179,6 +215,7 @@ export default { return this.selectedFiltered.length === 0; }, }, + watch: { // We need to add this watcher to track the moment when user is alredy typing // but query is still not started due to debounce @@ -188,15 +225,21 @@ export default { } }, }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, methods: { selectAssignee(user) { let selected = [...this.value]; if (!this.allowMultipleAssignees) { selected = [user]; + this.$emit('input', selected); + this.$refs.dropdown.hide(); + this.$emit('toggle'); } else { selected.push(user); + this.$emit('input', selected); } - this.$emit('input', selected); }, unselect(name) { const selected = this.value.filter((user) => user.username !== name); @@ -205,6 +248,9 @@ export default { focusSearch() { this.$refs.search.focusInput(); }, + showDropdown() { + this.$refs.dropdown.show(); + }, showDivider(list) { return list.length > 0 && this.isSearchEmpty; }, @@ -216,22 +262,37 @@ export default { const currentUser = usersCopy.find((user) => user.username === this.currentUser.username); if (currentUser) { + currentUser.canMerge = this.currentUser.canMerge; const index = usersCopy.indexOf(currentUser); usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]); } return usersCopy; }, + setSearchKey(value) { + this.search = value.trim(); + }, + tooltipText(user) { + if (!this.isMergeRequest) { + return ''; + } + return user.canMerge ? '' : __('Cannot merge'); + }, }, }; </script> <template> - <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')"> + <gl-dropdown ref="dropdown" :text="text" @toggle="$emit('toggle')" @shown="focusSearch"> <template #header> <p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p> <gl-dropdown-divider /> - <gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" /> + <gl-search-box-by-type + ref="search" + :value="search" + class="js-dropdown-input-field" + @input="debouncedSearchKeyUpdate" + /> </template> <gl-dropdown-form class="gl-relative gl-min-h-7"> <gl-loading-icon @@ -247,7 +308,7 @@ export default { :is-checked="selectedIsEmpty" :is-check-centered="true" data-testid="unassign" - @click="$emit('input', [])" + @click.native.capture.stop="$emit('input', [])" > <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{ $options.i18n.unassigned @@ -258,27 +319,44 @@ export default { <gl-dropdown-item v-for="item in selectedFiltered" :key="item.id" + v-gl-tooltip.left.viewport + :title="tooltipText(item)" + boundary="viewport" is-checked is-check-centered data-testid="selected-participant" - @click.stop="unselect(item.username)" + @click.native.capture.stop="unselect(item.username)" > - <sidebar-participant :user="item" /> + <sidebar-participant :user="item" :issuable-type="issuableType" /> </gl-dropdown-item> <template v-if="showCurrentUser"> <gl-dropdown-divider /> - <gl-dropdown-item data-testid="current-user" @click.stop="selectAssignee(currentUser)"> - <sidebar-participant :user="currentUser" class="gl-pl-6!" /> + <gl-dropdown-item + data-testid="current-user" + @click.native.capture.stop="selectAssignee(currentUser)" + > + <sidebar-participant + :user="currentUser" + :issuable-type="issuableType" + class="gl-pl-6!" + /> </gl-dropdown-item> </template> <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> <gl-dropdown-item v-for="unselectedUser in unselectedFiltered" :key="unselectedUser.id" + v-gl-tooltip.left.viewport + :title="tooltipText(unselectedUser)" + boundary="viewport" data-testid="unselected-participant" - @click="selectAssignee(unselectedUser)" + @click.native.capture.stop="selectAssignee(unselectedUser)" > - <sidebar-participant :user="unselectedUser" class="gl-pl-6!" /> + <sidebar-participant + :user="unselectedUser" + :issuable-type="issuableType" + class="gl-pl-6!" + /> </gl-dropdown-item> <gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!"> {{ __('No matching results') }} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 6a27a0770c07c1c7a934d957971a68c698c4b097..c00af802c0645d8d70a72d17238e817757fbc574 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -108,12 +108,15 @@ .merge-icon { color: $orange-400; position: absolute; - bottom: 0; - right: 0; filter: drop-shadow(0 0 0.5px $white) drop-shadow(0 0 1px $white) drop-shadow(0 0 2px $white); } } +.assignee .merge-icon { + top: calc(50% + 0.25rem); + left: 1.275rem; +} + .reviewer .merge-icon { bottom: -3px; right: -3px; diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 04f311f58e9ccfd674ae869ae591c39f8639d8c8..5259bf90dd03a8d78cc697efddfbdde32925a6c0 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -43,6 +43,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:rebase_without_ci_ui, project, default_enabled: :yaml) push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml) push_frontend_feature_flag(:secure_vulnerability_training, project, default_enabled: :yaml) + push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml) # Usage data feature flags push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml) push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml) diff --git a/ee/app/assets/javascripts/boards/components/assignee_select.vue b/ee/app/assets/javascripts/boards/components/assignee_select.vue index a40528cdf558bd71259bab8c5ca7c8da5e03d443..de518c61a0e56775cf3d0e3ff1f61eefdc4151fd 100644 --- a/ee/app/assets/javascripts/boards/components/assignee_select.vue +++ b/ee/app/assets/javascripts/boards/components/assignee_select.vue @@ -5,7 +5,7 @@ import { mapActions, mapGetters } from 'vuex'; import searchGroupUsers from '~/graphql_shared/queries/group_users_search.query.graphql'; import searchProjectUsers from '~/graphql_shared/queries/users_search.query.graphql'; import { s__ } from '~/locale'; -import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; @@ -59,7 +59,7 @@ export default { // https://gitlab.com/gitlab-org/gitlab/-/issues/329750 return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || []; }, - debounce: ASSIGNEES_DEBOUNCE_DELAY, + debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS, error() { this.setError({ message: this.$options.i18n.errorSearchingUsers }); }, diff --git a/ee/spec/features/merge_request/user_creates_multiple_assignees_mr_spec.rb b/ee/spec/features/merge_request/user_creates_multiple_assignees_mr_spec.rb index 0279f10141169d2ee04e8553a439f4a24d78b269..af3802036619f80d2aeec89eacc600a4369c1f05 100644 --- a/ee/spec/features/merge_request/user_creates_multiple_assignees_mr_spec.rb +++ b/ee/spec/features/merge_request/user_creates_multiple_assignees_mr_spec.rb @@ -9,5 +9,15 @@ stub_licensed_features(multiple_merge_request_assignees: true) end - it_behaves_like 'multiple assignees merge request', 'creates', 'Create merge request' + context 'when GraphQL assignees widget feature flag is disabled' do + before do + stub_feature_flags(issue_assignees_widget: false) + end + + it_behaves_like 'multiple assignees merge request', 'creates', 'Create merge request' + end + + context 'when GraphQL assignees widget feature flag is enabled' do + it_behaves_like 'multiple assignees widget merge request', 'creates', 'Create merge request' + end end diff --git a/ee/spec/features/merge_request/user_edits_multiple_assignees_mr_spec.rb b/ee/spec/features/merge_request/user_edits_multiple_assignees_mr_spec.rb index d1e03bf0e353a9e0abb3545e3af3c110882364d4..a355756410a6d76a1fc9e6c2d4ae0ee013437a5a 100644 --- a/ee/spec/features/merge_request/user_edits_multiple_assignees_mr_spec.rb +++ b/ee/spec/features/merge_request/user_edits_multiple_assignees_mr_spec.rb @@ -9,5 +9,15 @@ stub_licensed_features(multiple_merge_request_assignees: true) end - it_behaves_like 'multiple assignees merge request', 'updates', 'Save changes' + context 'when GraphQL assignees widget feature flag is disabled' do + before do + stub_feature_flags(issue_assignees_widget: false) + end + + it_behaves_like 'multiple assignees merge request', 'updates', 'Save changes' + end + + context 'when GraphQL assignees widget feature flag is enabled' do + it_behaves_like 'multiple assignees widget merge request', 'updates', 'Save changes' + end end diff --git a/ee/spec/frontend/boards/components/assignee_select_spec.js b/ee/spec/frontend/boards/components/assignee_select_spec.js index d4a85f2b0ff95cd0c68ed043a9bb0ab0a20bdaec..facdf96eb1e7e7458a659f50d2fd303185077408 100644 --- a/ee/spec/frontend/boards/components/assignee_select_spec.js +++ b/ee/spec/frontend/boards/components/assignee_select_spec.js @@ -15,7 +15,7 @@ import { projectMembersResponse, groupMembersResponse, mockUser2 } from 'jest/si import defaultStore from '~/boards/stores'; import searchGroupUsersQuery from '~/graphql_shared/queries/group_users_search.query.graphql'; import searchProjectUsersQuery from '~/graphql_shared/queries/users_search.query.graphql'; -import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue'; Vue.use(VueApollo); @@ -103,7 +103,7 @@ describe('Assignee select component', () => { it('trigger query and renders dropdown with returned users', async () => { findEditButton().vm.$emit('click'); await waitForPromises(); - jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); await waitForPromises(); expect(usersQueryHandlerSuccess).toHaveBeenCalled(); @@ -140,7 +140,7 @@ describe('Assignee select component', () => { findEditButton().vm.$emit('click'); await waitForPromises(); - jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); await nextTick(); expect(queryHandler).toHaveBeenCalled(); diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 261e11148003397b062fcf46751720c815a06fc8..aaa478378a90ec575a63f9cd5a2d9fe7cd2296cb 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -106,6 +106,7 @@ end context 'when GraphQL assignees widget feature flag is enabled' do + # TODO: Move to shared examples when feature flag is removed: https://gitlab.com/gitlab-org/gitlab/-/issues/328185 context 'when a privileged user can invite' do it 'shows a link for inviting members and launches invite modal' do project.add_maintainer(user) diff --git a/spec/features/merge_request/user_edits_assignees_sidebar_spec.rb b/spec/features/merge_request/user_edits_assignees_sidebar_spec.rb index 5894ec923c20ce35eb392aa164cc5fe66a4fe705..92b9b7851480622a9fecbc74923a8c1bd1249929 100644 --- a/spec/features/merge_request/user_edits_assignees_sidebar_spec.rb +++ b/spec/features/merge_request/user_edits_assignees_sidebar_spec.rb @@ -17,66 +17,172 @@ let(:sidebar_assignee_block) { page.find('.js-issuable-sidebar .assignee') } let(:sidebar_assignee_avatar_link) { sidebar_assignee_block.find_all('a').find { |a| a['href'].include? assignee.username } } let(:sidebar_assignee_tooltip) { sidebar_assignee_avatar_link['title'] || '' } - let(:sidebar_assignee_dropdown_item) { sidebar_assignee_block.find(".dropdown-menu li[data-user-id=\"#{assignee.id}\"]") } - let(:sidebar_assignee_dropdown_tooltip) { sidebar_assignee_dropdown_item.find('a')['data-title'] || '' } - context 'when user is an owner' do + context 'when GraphQL assignees widget feature flag is disabled' do + let(:sidebar_assignee_dropdown_item) { sidebar_assignee_block.find(".dropdown-menu li[data-user-id=\"#{assignee.id}\"]") } + let(:sidebar_assignee_dropdown_tooltip) { sidebar_assignee_dropdown_item.find('a')['data-title'] || '' } + before do - stub_const('Autocomplete::UsersFinder::LIMIT', users_find_limit) + stub_feature_flags(issue_assignees_widget: false) + end - sign_in(project.first_owner) + context 'when user is an owner' do + before do + stub_const('Autocomplete::UsersFinder::LIMIT', users_find_limit) - merge_request.assignees << assignee + sign_in(project.first_owner) - visit project_merge_request_path(project, merge_request) + merge_request.assignees << assignee - wait_for_requests + visit project_merge_request_path(project, merge_request) + + wait_for_requests + end + + shared_examples 'when assigned' do |expected_tooltip: ''| + it 'shows assignee name' do + expect(sidebar_assignee_block).to have_text(assignee.name) + end + + it "shows assignee tooltip '#{expected_tooltip}'" do + expect(sidebar_assignee_tooltip).to eql(expected_tooltip) + end + + context 'when edit is clicked' do + before do + sidebar_assignee_block.click_link('Edit') + + wait_for_requests + end + + it "shows assignee tooltip '#{expected_tooltip}" do + expect(sidebar_assignee_dropdown_tooltip).to eql(expected_tooltip) + end + end + end + + context 'when assigned to maintainer' do + let(:assignee) { project_maintainers.last } + + it_behaves_like 'when assigned', expected_tooltip: '' + end + + context 'when assigned to developer' do + let(:assignee) { project_developers.last } + + it_behaves_like 'when assigned', expected_tooltip: 'Cannot merge' + end end - shared_examples 'when assigned' do |expected_tooltip: ''| - it 'shows assignee name' do - expect(sidebar_assignee_block).to have_text(assignee.name) + context 'with invite members considerations' do + let_it_be(:user) { create(:user) } + + before do + sign_in(user) end - it "shows assignee tooltip '#{expected_tooltip}'" do - expect(sidebar_assignee_tooltip).to eql(expected_tooltip) + include_examples 'issuable invite members' do + let(:issuable_path) { project_merge_request_path(project, merge_request) } end + end + end + + context 'when GraphQL assignees widget feature flag is enabled' do + let(:sidebar_assignee_dropdown_item) { sidebar_assignee_block.find(".dropdown-item", text: assignee.username ) } + let(:sidebar_assignee_dropdown_tooltip) { sidebar_assignee_dropdown_item['title']} + + context 'when user is an owner' do + before do + stub_const('Autocomplete::UsersFinder::LIMIT', users_find_limit) + + sign_in(project.first_owner) + + merge_request.assignees << assignee - context 'when edit is clicked' do - before do - sidebar_assignee_block.click_link('Edit') + visit project_merge_request_path(project, merge_request) - wait_for_requests + wait_for_requests + end + + shared_examples 'when assigned' do |expected_tooltip: ''| + it 'shows assignee name' do + expect(sidebar_assignee_block).to have_text(assignee.name) end - it "shows assignee tooltip '#{expected_tooltip}" do - expect(sidebar_assignee_dropdown_tooltip).to eql(expected_tooltip) + it "shows assignee tooltip '#{expected_tooltip}'" do + expect(sidebar_assignee_tooltip).to eql(expected_tooltip) + end + + context 'when edit is clicked' do + before do + open_assignees_dropdown + end + + it "shows assignee tooltip '#{expected_tooltip}" do + expect(sidebar_assignee_dropdown_tooltip).to eql(expected_tooltip) + end end end - end - context 'when assigned to maintainer' do - let(:assignee) { project_maintainers.last } + context 'when assigned to maintainer' do + let(:assignee) { project_maintainers.last } - it_behaves_like 'when assigned', expected_tooltip: '' - end + it_behaves_like 'when assigned', expected_tooltip: '' + end - context 'when assigned to developer' do - let(:assignee) { project_developers.last } + context 'when assigned to developer' do + let(:assignee) { project_developers.last } - it_behaves_like 'when assigned', expected_tooltip: 'Cannot merge' + it_behaves_like 'when assigned', expected_tooltip: 'Cannot merge' + end end - end - context 'with invite members considerations' do - let_it_be(:user) { create(:user) } + context 'with invite members considerations' do + let_it_be(:user) { create(:user) } - before do - sign_in(user) + before do + sign_in(user) + end + + # TODO: Move to shared examples when feature flag is removed: https://gitlab.com/gitlab-org/gitlab/-/issues/328185 + context 'when a privileged user can invite' do + it 'shows a link for inviting members and launches invite modal' do + project.add_maintainer(user) + visit project_merge_request_path(project, merge_request) + + open_assignees_dropdown + + page.within '.dropdown-menu-user' do + expect(page).to have_link('Invite members') + expect(page).to have_selector('[data-track-action="click_invite_members"]') + expect(page).to have_selector('[data-track-label="edit_assignee"]') + end + + click_link 'Invite members' + + expect(page).to have_content("You're inviting members to the") + end + end + + context 'when user cannot invite members in assignee dropdown' do + it 'shows author in assignee dropdown and no invite link' do + project.add_developer(user) + visit project_merge_request_path(project, merge_request) + + open_assignees_dropdown + + page.within '.dropdown-menu-user' do + expect(page).not_to have_link('Invite members') + end + end + end end + end - include_examples 'issuable invite members' do - let(:issuable_path) { project_merge_request_path(project, merge_request) } + def open_assignees_dropdown + page.within('.assignee') do + click_button('Edit') + wait_for_requests end end end diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js index db1cffbd2cb14820938db85991ffd627f2a91c79..5fd364afbe46377c8b07841238f6861dc69b1224 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js @@ -1,4 +1,4 @@ -import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; +import { GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; @@ -76,7 +76,16 @@ describe('Sidebar assignees widget', () => { SidebarEditableItem, UserSelect, GlSearchBoxByType, - GlDropdown, + GlDropdown: { + template: ` + <div> + <slot name="footer"></slot> + </div> + `, + methods: { + show: jest.fn(), + }, + }, }, }); }; diff --git a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js index 88a5f4ea8b7f4bf2e273043c2c298821e804fedf..71424aaead38e4ee6522cedbf16c6df24bb59f14 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js @@ -1,5 +1,6 @@ -import { GlAvatarLabeled } from '@gitlab/ui'; +import { GlAvatarLabeled, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { IssuableType } from '~/issues/constants'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; const user = { @@ -13,14 +14,24 @@ describe('Sidebar participant component', () => { let wrapper; const findAvatar = () => wrapper.findComponent(GlAvatarLabeled); + const findIcon = () => wrapper.findComponent(GlIcon); - const createComponent = (status = null) => { + const createComponent = ({ + status = null, + issuableType = IssuableType.Issue, + canMerge = false, + } = {}) => { wrapper = shallowMount(SidebarParticipant, { propsData: { user: { ...user, + canMerge, status, }, + issuableType, + }, + stubs: { + GlAvatarLabeled, }, }); }; @@ -29,15 +40,35 @@ describe('Sidebar participant component', () => { wrapper.destroy(); }); - it('when user is not busy', () => { + it('does not show `Busy` status when user is not busy', () => { createComponent(); expect(findAvatar().props('label')).toBe(user.name); }); - it('when user is busy', () => { - createComponent({ availability: 'BUSY' }); + it('shows `Busy` status when user is busy', () => { + createComponent({ status: { availability: 'BUSY' } }); expect(findAvatar().props('label')).toBe(`${user.name} (Busy)`); }); + + it('does not render a warning icon', () => { + createComponent(); + + expect(findIcon().exists()).toBe(false); + }); + + describe('when on merge request sidebar', () => { + it('when project member cannot merge', () => { + createComponent({ issuableType: IssuableType.MergeRequest }); + + expect(findIcon().exists()).toBe(true); + }); + + it('when project member can merge', () => { + createComponent({ issuableType: IssuableType.MergeRequest, canMerge: true }); + + expect(findIcon().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index 30972484a08fac96031e93aa0de644622490523a..fbca00636b6f99e3f96dba75b615d51f81c02761 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -428,7 +428,7 @@ const mockUser1 = { export const mockUser2 = { __typename: 'UserCore', - id: 'gid://gitlab/User/4', + id: 'gid://gitlab/User/5', avatarUrl: '/avatar2', name: 'rookie', username: 'rookie', @@ -457,6 +457,33 @@ export const searchResponse = { }, }; +export const searchResponseOnMR = { + data: { + workspace: { + __typename: 'Project', + id: '1', + users: { + nodes: [ + { + id: 'gid://gitlab/User/1', + user: mockUser1, + mergeRequestInteraction: { + canMerge: true, + }, + }, + { + id: 'gid://gitlab/User/4', + user: mockUser2, + mergeRequestInteraction: { + canMerge: false, + }, + }, + ], + }, + }, + }, +}; + export const projectMembersResponse = { data: { workspace: { diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js index 411a15e1c74d291fbc2a680d7377f692332fbbfb..cb4769109446391fa626a527c6ddc8a3cf2a64b8 100644 --- a/spec/frontend/vue_shared/components/user_select_spec.js +++ b/spec/frontend/vue_shared/components/user_select_spec.js @@ -1,4 +1,4 @@ -import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; +import { GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; import Vue, { nextTick } from 'vue'; @@ -6,11 +6,14 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql'; -import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; +import searchUsersQueryOnMR from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql'; +import { IssuableType } from '~/issues/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import { searchResponse, + searchResponseOnMR, projectMembersResponse, participantsQueryResponse, } from '../../sidebar/mock_data'; @@ -28,7 +31,7 @@ const assignee = { const mockError = jest.fn().mockRejectedValue('Error!'); const waitForSearch = async () => { - jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); await nextTick(); await waitForPromises(); }; @@ -58,6 +61,7 @@ describe('User select dropdown', () => { } = {}) => { fakeApollo = createMockApollo([ [searchUsersQuery, searchQueryHandler], + [searchUsersQueryOnMR, jest.fn().mockResolvedValue(searchResponseOnMR)], [getIssueParticipantsQuery, participantsQueryHandler], ]); wrapper = shallowMount(UserSelect, { @@ -76,7 +80,18 @@ describe('User select dropdown', () => { ...props, }, stubs: { - GlDropdown, + GlDropdown: { + template: ` + <div> + <slot name="header"></slot> + <slot></slot> + <slot name="footer"></slot> + </div> + `, + methods: { + hide: jest.fn(), + }, + }, }, }); }; @@ -132,11 +147,19 @@ describe('User select dropdown', () => { expect(findSelectedParticipants()).toHaveLength(1); }); + it('does not render a `Cannot merge` tooltip', async () => { + createComponent(); + await waitForPromises(); + + expect(findUnselectedParticipants().at(0).attributes('title')).toBe(''); + }); + describe('when search is empty', () => { it('renders a merged list of participants and project members', async () => { createComponent(); await waitForPromises(); - expect(findUnselectedParticipants()).toHaveLength(3); + + expect(findUnselectedParticipants()).toHaveLength(4); }); it('renders `Unassigned` link with the checkmark when there are no selected users', async () => { @@ -162,7 +185,7 @@ describe('User select dropdown', () => { }, }); await waitForPromises(); - findUnassignLink().vm.$emit('click'); + findUnassignLink().trigger('click'); expect(wrapper.emitted('input')).toEqual([[[]]]); }); @@ -175,7 +198,7 @@ describe('User select dropdown', () => { }); await waitForPromises(); - findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); + findSelectedParticipants().at(0).trigger('click'); expect(wrapper.emitted('input')).toEqual([[[]]]); }); @@ -187,8 +210,9 @@ describe('User select dropdown', () => { }); await waitForPromises(); - findUnselectedParticipants().at(0).vm.$emit('click'); - expect(wrapper.emitted('input')).toEqual([ + findUnselectedParticipants().at(0).trigger('click'); + + expect(wrapper.emitted('input')).toMatchObject([ [ [ { @@ -214,7 +238,7 @@ describe('User select dropdown', () => { }); await waitForPromises(); - findUnselectedParticipants().at(0).vm.$emit('click'); + findUnselectedParticipants().at(0).trigger('click'); expect(wrapper.emitted('input')[0][0]).toHaveLength(2); }); }); @@ -232,7 +256,7 @@ describe('User select dropdown', () => { createComponent(); await waitForPromises(); findSearchField().vm.$emit('input', 'roo'); - jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); await nextTick(); expect(findParticipantsLoading().exists()).toBe(true); @@ -273,4 +297,19 @@ describe('User select dropdown', () => { expect(findEmptySearchResults().exists()).toBe(true); }); }); + + describe('when on merge request sidebar', () => { + beforeEach(() => { + createComponent({ props: { issuableType: IssuableType.MergeRequest, issuableId: 1 } }); + return waitForPromises(); + }); + + it('does not render a `Cannot merge` tooltip for a user that has merge permission', () => { + expect(findUnselectedParticipants().at(0).attributes('title')).toBe(''); + }); + + it('renders a `Cannot merge` tooltip for a user that does not have merge permission', () => { + expect(findUnselectedParticipants().at(1).attributes('title')).toBe('Cannot merge'); + }); + }); }); diff --git a/spec/support/shared_examples/features/multiple_assignees_widget_mr_shared_examples.rb b/spec/support/shared_examples/features/multiple_assignees_widget_mr_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..bbde448a1a13e6121fe65a98e0245e7e92402c48 --- /dev/null +++ b/spec/support/shared_examples/features/multiple_assignees_widget_mr_shared_examples.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'multiple assignees widget merge request' do |action, save_button_title| + it "#{action} a MR with multiple assignees", :js do + find('.js-assignee-search').click + page.within '.dropdown-menu-user' do + click_link user.name + click_link user2.name + end + + # Extra click needed in order to toggle the dropdown + find('.js-assignee-search').click + + expect(all('input[name="merge_request[assignee_ids][]"]', visible: false).map(&:value)) + .to match_array([user.id.to_s, user2.id.to_s]) + + page.within '.js-assignee-search' do + expect(page).to have_content "#{user2.name} + 1 more" + end + + click_button save_button_title + + page.within '.issuable-sidebar' do + page.within '.assignee' do + expect(page).to have_content '2 Assignees' + + click_button('Edit') + + expect(page).to have_content user.name + expect(page).to have_content user2.name + end + end + + page.within '.dropdown-menu-user' do + click_link user.name + end + + page.within '.issuable-sidebar' do + page.within '.assignee' do + # Closing dropdown to persist + click_button('Apply') + + expect(page).to have_content user2.name + end + end + end +end