Commit 5a0ca7e8 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'ntepluhina-mr-assignees-widget' into 'master'

Add assignees widget to MR sidebar

See merge request gitlab-org/gitlab!79950
parents 30e944a4 699c8057
#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
}
}
}
}
}
<script> <script>
import { GlIcon } from '@gitlab/ui'; import { GlIcon } from '@gitlab/ui';
import { IssuableType } from '~/issues/constants';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
export default { export default {
...@@ -31,10 +32,11 @@ export default { ...@@ -31,10 +32,11 @@ export default {
); );
}, },
isMergeRequest() { isMergeRequest() {
return this.issuableType === 'merge_request'; return this.issuableType === IssuableType.MergeRequest;
}, },
hasMergeIcon() { hasMergeIcon() {
return this.isMergeRequest && !this.user.can_merge; const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge;
return this.isMergeRequest && !canMerge;
}, },
}, },
}; };
......
<script> <script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { IssuableType } from '~/issues/constants';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils'; import { isUserBusy } from '~/set_status_modal/utils';
import AssigneeAvatar from './assignee_avatar.vue'; import AssigneeAvatar from './assignee_avatar.vue';
...@@ -71,7 +72,8 @@ export default { ...@@ -71,7 +72,8 @@ export default {
}, },
computed: { computed: {
cannotMerge() { 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() { tooltipTitle() {
const { name = '', availability = '' } = this.user; const { name = '', availability = '' } = this.user;
......
...@@ -58,7 +58,7 @@ export default { ...@@ -58,7 +58,7 @@ export default {
return this.users.length > 2; return this.users.length > 2;
}, },
allAssigneesCanMerge() { allAssigneesCanMerge() {
return this.users.every((user) => user.can_merge); return this.users.every((user) => user.can_merge || user.mergeRequestInteraction?.canMerge);
}, },
sidebarAvatarCounter() { sidebarAvatarCounter() {
if (this.users.length > DEFAULT_MAX_COUNTER) { if (this.users.length > DEFAULT_MAX_COUNTER) {
...@@ -77,7 +77,9 @@ export default { ...@@ -77,7 +77,9 @@ export default {
return ''; 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) { if (mergeLength === this.users.length) {
return ''; return '';
......
...@@ -44,7 +44,7 @@ export default { ...@@ -44,7 +44,7 @@ export default {
<div class="gl-display-flex gl-flex-direction-column issuable-assignees"> <div class="gl-display-flex gl-flex-direction-column issuable-assignees">
<div <div
v-if="emptyUsers" 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" data-testid="none"
> >
<span> {{ __('None') }}</span> <span> {{ __('None') }}</span>
...@@ -65,7 +65,7 @@ export default { ...@@ -65,7 +65,7 @@ export default {
v-else v-else
:users="users" :users="users"
:issuable-type="issuableType" :issuable-type="issuableType"
class="gl-text-gray-800 gl-mt-2 hide-collapsed" class="gl-text-gray-800 hide-collapsed"
@toggle-attention-requested="toggleAttentionRequested" @toggle-attention-requested="toggleAttentionRequested"
/> />
</div> </div>
......
<script> <script>
import { GlDropdownItem } from '@gitlab/ui'; import { GlDropdownItem } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import Vue from 'vue'; import Vue from 'vue';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { IssuableType } from '~/issues/constants'; import { IssuableType } from '~/issues/constants';
...@@ -101,7 +100,10 @@ export default { ...@@ -101,7 +100,10 @@ export default {
} }
const issuable = data.workspace?.issuable; const issuable = data.workspace?.issuable;
if (issuable) { if (issuable) {
this.selected = cloneDeep(issuable.assignees.nodes); this.selected = issuable.assignees.nodes.map((node) => ({
...node,
canMerge: node.mergeRequestInteraction?.canMerge || false,
}));
} }
}, },
error() { error() {
...@@ -141,6 +143,7 @@ export default { ...@@ -141,6 +143,7 @@ export default {
username: gon?.current_username, username: gon?.current_username,
name: gon?.current_user_fullname, name: gon?.current_user_fullname,
avatarUrl: gon?.current_user_avatar_url, avatarUrl: gon?.current_user_avatar_url,
canMerge: this.issuable?.userPermissions?.canMerge || false,
}; };
}, },
signedIn() { signedIn() {
...@@ -206,8 +209,8 @@ export default { ...@@ -206,8 +209,8 @@ export default {
expandWidget() { expandWidget() {
this.$refs.toggle.expand(); this.$refs.toggle.expand();
}, },
focusSearch() { showDropdown() {
this.$refs.userSelect.focusSearch(); this.$refs.userSelect.showDropdown();
}, },
showError() { showError() {
createFlash({ message: __('An error occurred while fetching participants.') }); createFlash({ message: __('An error occurred while fetching participants.') });
...@@ -236,11 +239,11 @@ export default { ...@@ -236,11 +239,11 @@ export default {
:initial-loading="isAssigneesLoading" :initial-loading="isAssigneesLoading"
:title="assigneeText" :title="assigneeText"
:is-dirty="isDirty" :is-dirty="isDirty"
@open="focusSearch" @open="showDropdown"
@close="saveAssignees" @close="saveAssignees"
> >
<template #collapsed> <template #collapsed>
<slot name="collapsed" :users="assignees" :on-click="expandWidget"></slot> <slot name="collapsed" :users="assignees"></slot>
<issuable-assignees <issuable-assignees
:users="assignees" :users="assignees"
:issuable-type="issuableType" :issuable-type="issuableType"
...@@ -256,12 +259,13 @@ export default { ...@@ -256,12 +259,13 @@ export default {
:text="$options.i18n.assignees" :text="$options.i18n.assignees"
:header-text="$options.i18n.assignTo" :header-text="$options.i18n.assignTo"
:iid="iid" :iid="iid"
:issuable-id="issuableId"
:full-path="fullPath" :full-path="fullPath"
:allow-multiple-assignees="allowMultipleAssignees" :allow-multiple-assignees="allowMultipleAssignees"
:current-user="currentUser" :current-user="currentUser"
:issuable-type="issuableType" :issuable-type="issuableType"
:is-editing="edit" :is-editing="edit"
class="gl-w-full dropdown-menu-user" class="gl-w-full dropdown-menu-user gl-mt-n3"
@toggle="collapseWidget" @toggle="collapseWidget"
@error="showError" @error="showError"
@input="setDirtyState" @input="setDirtyState"
......
...@@ -30,6 +30,6 @@ export default { ...@@ -30,6 +30,6 @@ export default {
:event="$options.dataTrackEvent" :event="$options.dataTrackEvent"
:label="$options.dataTrackLabel" :label="$options.dataTrackLabel"
:trigger-source="triggerSource" :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> </template>
<script> <script>
import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui'; import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui';
import { IssuableType } from '~/issues/constants';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
export default { export default {
components: { components: {
GlAvatarLabeled, GlAvatarLabeled,
GlAvatarLink, GlAvatarLink,
GlIcon,
}, },
props: { props: {
user: { user: {
type: Object, type: Object,
required: true, required: true,
}, },
issuableType: {
type: String,
required: false,
default: IssuableType.Issue,
},
}, },
computed: { computed: {
userLabel() { userLabel() {
...@@ -22,6 +29,9 @@ export default { ...@@ -22,6 +29,9 @@ export default {
author: this.user.name, author: this.user.name,
}); });
}, },
hasCannotMergeIcon() {
return this.issuableType === IssuableType.MergeRequest && !this.user.canMerge;
},
}, },
}; };
</script> </script>
...@@ -31,9 +41,19 @@ export default { ...@@ -31,9 +41,19 @@ export default {
<gl-avatar-labeled <gl-avatar-labeled
:size="32" :size="32"
:label="userLabel" :label="userLabel"
:sub-label="user.username" :sub-label="`@${user.username}`"
:src="user.avatarUrl || user.avatar || user.avatar_url" :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> </gl-avatar-link>
</template> </template>
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; 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 { 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 epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql'; import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql';
import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql'; import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql';
...@@ -55,8 +56,6 @@ import projectIssueMilestoneMutation from './queries/project_issue_milestone.mut ...@@ -55,8 +56,6 @@ import projectIssueMilestoneMutation from './queries/project_issue_milestone.mut
import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql'; import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql';
import projectMilestonesQuery from './queries/project_milestones.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 defaultEpicSort = 'TITLE_ASC';
export const epicIidPattern = /^&(?<iid>\d+)$/; export const epicIidPattern = /^&(?<iid>\d+)$/;
...@@ -93,6 +92,15 @@ export const participantsQueries = { ...@@ -93,6 +92,15 @@ export const participantsQueries = {
}, },
}; };
export const userSearchQueries = {
[IssuableType.Issue]: {
query: userSearchQuery,
},
[IssuableType.MergeRequest]: {
query: userSearchWithMRPermissionsQuery,
},
};
export const confidentialityQueries = { export const confidentialityQueries = {
[IssuableType.Issue]: { [IssuableType.Issue]: {
query: issueConfidentialQuery, query: issueConfidentialQuery,
......
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
isInIssuePage, isInIssuePage,
isInDesignPage, isInDesignPage,
isInIncidentPage, isInIncidentPage,
isInMRPage,
parseBoolean, parseBoolean,
} from '~/lib/utils/common_utils'; } from '~/lib/utils/common_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
...@@ -136,6 +137,8 @@ function mountAssigneesComponent() { ...@@ -136,6 +137,8 @@ function mountAssigneesComponent() {
if (!el) return; if (!el) return;
const { id, iid, fullPath, editable } = getSidebarOptions(); const { id, iid, fullPath, editable } = getSidebarOptions();
const isIssuablePage = isInIssuePage() || isInIncidentPage() || isInDesignPage();
const issuableType = isIssuablePage ? IssuableType.Issue : IssuableType.MergeRequest;
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
...@@ -153,21 +156,16 @@ function mountAssigneesComponent() { ...@@ -153,21 +156,16 @@ function mountAssigneesComponent() {
props: { props: {
iid: String(iid), iid: String(iid),
fullPath, fullPath,
issuableType: issuableType,
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
: IssuableType.MergeRequest,
issuableId: id, issuableId: id,
allowMultipleAssignees: !el.dataset.maxAssignees, allowMultipleAssignees: !el.dataset.maxAssignees,
}, },
scopedSlots: { scopedSlots: {
collapsed: ({ users, onClick }) => collapsed: ({ users }) =>
createElement(CollapsedAssigneeList, { createElement(CollapsedAssigneeList, {
props: { props: {
users, users,
}, issuableType,
nativeOn: {
click: onClick,
}, },
}), }),
}, },
...@@ -616,7 +614,7 @@ function mountCopyEmailComponent() { ...@@ -616,7 +614,7 @@ function mountCopyEmailComponent() {
} }
const isAssigneesWidgetShown = const isAssigneesWidgetShown =
(isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget; (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget;
export function mountSidebar(mediator, store) { export function mountSidebar(mediator, store) {
initInviteMembersModal(); initInviteMembersModal();
......
...@@ -10,8 +10,14 @@ query getMrAssignees($fullPath: ID!, $iid: String!) { ...@@ -10,8 +10,14 @@ query getMrAssignees($fullPath: ID!, $iid: String!) {
nodes { nodes {
...User ...User
...UserAvailability ...UserAvailability
mergeRequestInteraction {
canMerge
}
} }
} }
userPermissions {
canMerge
}
} }
} }
} }
...@@ -2,21 +2,18 @@ ...@@ -2,21 +2,18 @@
#import "~/graphql_shared/fragments/user_availability.fragment.graphql" #import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) { mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
mergeRequestSetAssignees( issuableSetAssignees: mergeRequestSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath } input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) { ) {
mergeRequest { issuable: mergeRequest {
id id
assignees { assignees {
nodes { nodes {
...User ...User
...UserAvailability ...UserAvailability
} mergeRequestInteraction {
} canMerge
participants { }
nodes {
...User
...UserAvailability
} }
} }
} }
......
<script> <script>
import { debounce } from 'lodash';
import { import {
GlDropdown, GlDropdown,
GlDropdownForm, GlDropdownForm,
...@@ -6,11 +7,14 @@ import { ...@@ -6,11 +7,14 @@ import {
GlDropdownItem, GlDropdownItem,
GlSearchBoxByType, GlSearchBoxByType,
GlLoadingIcon, GlLoadingIcon,
GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { __ } from '~/locale'; import { __ } from '~/locale';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; 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 { export default {
i18n: { i18n: {
...@@ -25,6 +29,9 @@ export default { ...@@ -25,6 +29,9 @@ export default {
SidebarParticipant, SidebarParticipant,
GlLoadingIcon, GlLoadingIcon,
}, },
directives: {
GlTooltip: GlTooltipDirective,
},
props: { props: {
headerText: { headerText: {
type: String, type: String,
...@@ -58,13 +65,18 @@ export default { ...@@ -58,13 +65,18 @@ export default {
issuableType: { issuableType: {
type: String, type: String,
required: false, required: false,
default: 'issue', default: IssuableType.Issue,
}, },
isEditing: { isEditing: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true, default: true,
}, },
issuableId: {
type: Number,
required: false,
default: null,
},
}, },
data() { data() {
return { return {
...@@ -89,28 +101,35 @@ export default { ...@@ -89,28 +101,35 @@ export default {
}; };
}, },
update(data) { update(data) {
return data.workspace?.issuable?.participants.nodes; return data.workspace?.issuable?.participants.nodes.map((node) => ({
...node,
canMerge: false,
}));
}, },
error() { error() {
this.$emit('error'); this.$emit('error');
}, },
}, },
searchUsers: { searchUsers: {
query: searchUsers, query() {
return userSearchQueries[this.issuableType].query;
},
variables() { variables() {
return { return this.searchUsersVariables;
fullPath: this.fullPath,
search: this.search,
first: 20,
};
}, },
skip() { skip() {
return !this.isEditing; return !this.isEditing;
}, },
update(data) { 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() { error() {
this.$emit('error'); this.$emit('error');
this.isSearching = false; this.isSearching = false;
...@@ -121,6 +140,23 @@ export default { ...@@ -121,6 +140,23 @@ export default {
}, },
}, },
computed: { 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() { isLoading() {
return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading; return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading;
}, },
...@@ -135,8 +171,8 @@ export default { ...@@ -135,8 +171,8 @@ export default {
// TODO this de-duplication is temporary (BE fix required) // TODO this de-duplication is temporary (BE fix required)
// https://gitlab.com/gitlab-org/gitlab/-/issues/327822 // https://gitlab.com/gitlab-org/gitlab/-/issues/327822
const mergedSearchResults = filteredParticipants const mergedSearchResults = this.searchUsers
.concat(this.searchUsers) .concat(filteredParticipants)
.reduce( .reduce(
(acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]), (acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]),
[], [],
...@@ -179,6 +215,7 @@ export default { ...@@ -179,6 +215,7 @@ export default {
return this.selectedFiltered.length === 0; return this.selectedFiltered.length === 0;
}, },
}, },
watch: { watch: {
// We need to add this watcher to track the moment when user is alredy typing // We need to add this watcher to track the moment when user is alredy typing
// but query is still not started due to debounce // but query is still not started due to debounce
...@@ -188,15 +225,21 @@ export default { ...@@ -188,15 +225,21 @@ export default {
} }
}, },
}, },
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
methods: { methods: {
selectAssignee(user) { selectAssignee(user) {
let selected = [...this.value]; let selected = [...this.value];
if (!this.allowMultipleAssignees) { if (!this.allowMultipleAssignees) {
selected = [user]; selected = [user];
this.$emit('input', selected);
this.$refs.dropdown.hide();
this.$emit('toggle');
} else { } else {
selected.push(user); selected.push(user);
this.$emit('input', selected);
} }
this.$emit('input', selected);
}, },
unselect(name) { unselect(name) {
const selected = this.value.filter((user) => user.username !== name); const selected = this.value.filter((user) => user.username !== name);
...@@ -205,6 +248,9 @@ export default { ...@@ -205,6 +248,9 @@ export default {
focusSearch() { focusSearch() {
this.$refs.search.focusInput(); this.$refs.search.focusInput();
}, },
showDropdown() {
this.$refs.dropdown.show();
},
showDivider(list) { showDivider(list) {
return list.length > 0 && this.isSearchEmpty; return list.length > 0 && this.isSearchEmpty;
}, },
...@@ -216,22 +262,37 @@ export default { ...@@ -216,22 +262,37 @@ export default {
const currentUser = usersCopy.find((user) => user.username === this.currentUser.username); const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
if (currentUser) { if (currentUser) {
currentUser.canMerge = this.currentUser.canMerge;
const index = usersCopy.indexOf(currentUser); const index = usersCopy.indexOf(currentUser);
usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]); usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
} }
return usersCopy; return usersCopy;
}, },
setSearchKey(value) {
this.search = value.trim();
},
tooltipText(user) {
if (!this.isMergeRequest) {
return '';
}
return user.canMerge ? '' : __('Cannot merge');
},
}, },
}; };
</script> </script>
<template> <template>
<gl-dropdown class="show" :text="text" @toggle="$emit('toggle')"> <gl-dropdown ref="dropdown" :text="text" @toggle="$emit('toggle')" @shown="focusSearch">
<template #header> <template #header>
<p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p> <p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
<gl-dropdown-divider /> <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> </template>
<gl-dropdown-form class="gl-relative gl-min-h-7"> <gl-dropdown-form class="gl-relative gl-min-h-7">
<gl-loading-icon <gl-loading-icon
...@@ -247,7 +308,7 @@ export default { ...@@ -247,7 +308,7 @@ export default {
:is-checked="selectedIsEmpty" :is-checked="selectedIsEmpty"
:is-check-centered="true" :is-check-centered="true"
data-testid="unassign" 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">{{ <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{
$options.i18n.unassigned $options.i18n.unassigned
...@@ -258,27 +319,44 @@ export default { ...@@ -258,27 +319,44 @@ export default {
<gl-dropdown-item <gl-dropdown-item
v-for="item in selectedFiltered" v-for="item in selectedFiltered"
:key="item.id" :key="item.id"
v-gl-tooltip.left.viewport
:title="tooltipText(item)"
boundary="viewport"
is-checked is-checked
is-check-centered is-check-centered
data-testid="selected-participant" 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> </gl-dropdown-item>
<template v-if="showCurrentUser"> <template v-if="showCurrentUser">
<gl-dropdown-divider /> <gl-dropdown-divider />
<gl-dropdown-item data-testid="current-user" @click.stop="selectAssignee(currentUser)"> <gl-dropdown-item
<sidebar-participant :user="currentUser" class="gl-pl-6!" /> data-testid="current-user"
@click.native.capture.stop="selectAssignee(currentUser)"
>
<sidebar-participant
:user="currentUser"
:issuable-type="issuableType"
class="gl-pl-6!"
/>
</gl-dropdown-item> </gl-dropdown-item>
</template> </template>
<gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
<gl-dropdown-item <gl-dropdown-item
v-for="unselectedUser in unselectedFiltered" v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id" :key="unselectedUser.id"
v-gl-tooltip.left.viewport
:title="tooltipText(unselectedUser)"
boundary="viewport"
data-testid="unselected-participant" 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>
<gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!"> <gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!">
{{ __('No matching results') }} {{ __('No matching results') }}
......
...@@ -108,12 +108,15 @@ ...@@ -108,12 +108,15 @@
.merge-icon { .merge-icon {
color: $orange-400; color: $orange-400;
position: absolute; 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); 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 { .reviewer .merge-icon {
bottom: -3px; bottom: -3px;
right: -3px; right: -3px;
......
...@@ -43,6 +43,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -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(:rebase_without_ci_ui, project, default_enabled: :yaml)
push_frontend_feature_flag(:markdown_continue_lists, 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(:secure_vulnerability_training, project, default_enabled: :yaml)
push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml)
# Usage data feature flags # Usage data feature flags
push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml) push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml)
push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml) push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml)
......
...@@ -5,7 +5,7 @@ import { mapActions, mapGetters } from 'vuex'; ...@@ -5,7 +5,7 @@ import { mapActions, mapGetters } from 'vuex';
import searchGroupUsers from '~/graphql_shared/queries/group_users_search.query.graphql'; import searchGroupUsers from '~/graphql_shared/queries/group_users_search.query.graphql';
import searchProjectUsers from '~/graphql_shared/queries/users_search.query.graphql'; import searchProjectUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { s__ } from '~/locale'; 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 DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
...@@ -59,7 +59,7 @@ export default { ...@@ -59,7 +59,7 @@ export default {
// https://gitlab.com/gitlab-org/gitlab/-/issues/329750 // https://gitlab.com/gitlab-org/gitlab/-/issues/329750
return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || []; return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || [];
}, },
debounce: ASSIGNEES_DEBOUNCE_DELAY, debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
error() { error() {
this.setError({ message: this.$options.i18n.errorSearchingUsers }); this.setError({ message: this.$options.i18n.errorSearchingUsers });
}, },
......
...@@ -9,5 +9,15 @@ RSpec.describe 'Merge request > User creates MR with multiple assignees' do ...@@ -9,5 +9,15 @@ RSpec.describe 'Merge request > User creates MR with multiple assignees' do
stub_licensed_features(multiple_merge_request_assignees: true) stub_licensed_features(multiple_merge_request_assignees: true)
end 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 end
...@@ -9,5 +9,15 @@ RSpec.describe 'Merge request > User edits MR with multiple assignees' do ...@@ -9,5 +9,15 @@ RSpec.describe 'Merge request > User edits MR with multiple assignees' do
stub_licensed_features(multiple_merge_request_assignees: true) stub_licensed_features(multiple_merge_request_assignees: true)
end 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 end
...@@ -15,7 +15,7 @@ import { projectMembersResponse, groupMembersResponse, mockUser2 } from 'jest/si ...@@ -15,7 +15,7 @@ import { projectMembersResponse, groupMembersResponse, mockUser2 } from 'jest/si
import defaultStore from '~/boards/stores'; import defaultStore from '~/boards/stores';
import searchGroupUsersQuery from '~/graphql_shared/queries/group_users_search.query.graphql'; import searchGroupUsersQuery from '~/graphql_shared/queries/group_users_search.query.graphql';
import searchProjectUsersQuery from '~/graphql_shared/queries/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'; import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -103,7 +103,7 @@ describe('Assignee select component', () => { ...@@ -103,7 +103,7 @@ describe('Assignee select component', () => {
it('trigger query and renders dropdown with returned users', async () => { it('trigger query and renders dropdown with returned users', async () => {
findEditButton().vm.$emit('click'); findEditButton().vm.$emit('click');
await waitForPromises(); await waitForPromises();
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await waitForPromises(); await waitForPromises();
expect(usersQueryHandlerSuccess).toHaveBeenCalled(); expect(usersQueryHandlerSuccess).toHaveBeenCalled();
...@@ -140,7 +140,7 @@ describe('Assignee select component', () => { ...@@ -140,7 +140,7 @@ describe('Assignee select component', () => {
findEditButton().vm.$emit('click'); findEditButton().vm.$emit('click');
await waitForPromises(); await waitForPromises();
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await nextTick(); await nextTick();
expect(queryHandler).toHaveBeenCalled(); expect(queryHandler).toHaveBeenCalled();
......
...@@ -106,6 +106,7 @@ RSpec.describe 'Issue Sidebar' do ...@@ -106,6 +106,7 @@ RSpec.describe 'Issue Sidebar' do
end end
context 'when GraphQL assignees widget feature flag is enabled' do 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 context 'when a privileged user can invite' do
it 'shows a link for inviting members and launches invite modal' do it 'shows a link for inviting members and launches invite modal' do
project.add_maintainer(user) project.add_maintainer(user)
......
...@@ -17,66 +17,172 @@ RSpec.describe 'Merge request > User edits assignees sidebar', :js do ...@@ -17,66 +17,172 @@ RSpec.describe 'Merge request > User edits assignees sidebar', :js do
let(:sidebar_assignee_block) { page.find('.js-issuable-sidebar .assignee') } 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_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_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 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 end
shared_examples 'when assigned' do |expected_tooltip: ''| context 'with invite members considerations' do
it 'shows assignee name' do let_it_be(:user) { create(:user) }
expect(sidebar_assignee_block).to have_text(assignee.name)
before do
sign_in(user)
end end
it "shows assignee tooltip '#{expected_tooltip}'" do include_examples 'issuable invite members' do
expect(sidebar_assignee_tooltip).to eql(expected_tooltip) let(:issuable_path) { project_merge_request_path(project, merge_request) }
end 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 visit project_merge_request_path(project, merge_request)
before do
sidebar_assignee_block.click_link('Edit')
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 end
it "shows assignee tooltip '#{expected_tooltip}" do it "shows assignee tooltip '#{expected_tooltip}'" do
expect(sidebar_assignee_dropdown_tooltip).to eql(expected_tooltip) 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 end
end
context 'when assigned to maintainer' do context 'when assigned to maintainer' do
let(:assignee) { project_maintainers.last } let(:assignee) { project_maintainers.last }
it_behaves_like 'when assigned', expected_tooltip: '' it_behaves_like 'when assigned', expected_tooltip: ''
end end
context 'when assigned to developer' do context 'when assigned to developer' do
let(:assignee) { project_developers.last } 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
end
context 'with invite members considerations' do context 'with invite members considerations' do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
before do before do
sign_in(user) 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
end
include_examples 'issuable invite members' do def open_assignees_dropdown
let(:issuable_path) { project_merge_request_path(project, merge_request) } page.within('.assignee') do
click_button('Edit')
wait_for_requests
end end
end end
end end
import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
...@@ -76,7 +76,16 @@ describe('Sidebar assignees widget', () => { ...@@ -76,7 +76,16 @@ describe('Sidebar assignees widget', () => {
SidebarEditableItem, SidebarEditableItem,
UserSelect, UserSelect,
GlSearchBoxByType, GlSearchBoxByType,
GlDropdown, GlDropdown: {
template: `
<div>
<slot name="footer"></slot>
</div>
`,
methods: {
show: jest.fn(),
},
},
}, },
}); });
}; };
......
import { GlAvatarLabeled } from '@gitlab/ui'; import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { IssuableType } from '~/issues/constants';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
const user = { const user = {
...@@ -13,14 +14,24 @@ describe('Sidebar participant component', () => { ...@@ -13,14 +14,24 @@ describe('Sidebar participant component', () => {
let wrapper; let wrapper;
const findAvatar = () => wrapper.findComponent(GlAvatarLabeled); 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, { wrapper = shallowMount(SidebarParticipant, {
propsData: { propsData: {
user: { user: {
...user, ...user,
canMerge,
status, status,
}, },
issuableType,
},
stubs: {
GlAvatarLabeled,
}, },
}); });
}; };
...@@ -29,15 +40,35 @@ describe('Sidebar participant component', () => { ...@@ -29,15 +40,35 @@ describe('Sidebar participant component', () => {
wrapper.destroy(); wrapper.destroy();
}); });
it('when user is not busy', () => { it('does not show `Busy` status when user is not busy', () => {
createComponent(); createComponent();
expect(findAvatar().props('label')).toBe(user.name); expect(findAvatar().props('label')).toBe(user.name);
}); });
it('when user is busy', () => { it('shows `Busy` status when user is busy', () => {
createComponent({ availability: 'BUSY' }); createComponent({ status: { availability: 'BUSY' } });
expect(findAvatar().props('label')).toBe(`${user.name} (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);
});
});
}); });
...@@ -428,7 +428,7 @@ const mockUser1 = { ...@@ -428,7 +428,7 @@ const mockUser1 = {
export const mockUser2 = { export const mockUser2 = {
__typename: 'UserCore', __typename: 'UserCore',
id: 'gid://gitlab/User/4', id: 'gid://gitlab/User/5',
avatarUrl: '/avatar2', avatarUrl: '/avatar2',
name: 'rookie', name: 'rookie',
username: 'rookie', username: 'rookie',
...@@ -457,6 +457,33 @@ export const searchResponse = { ...@@ -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 = { export const projectMembersResponse = {
data: { data: {
workspace: { workspace: {
......
import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
...@@ -6,11 +6,14 @@ import VueApollo from 'vue-apollo'; ...@@ -6,11 +6,14 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql'; 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 getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import { import {
searchResponse, searchResponse,
searchResponseOnMR,
projectMembersResponse, projectMembersResponse,
participantsQueryResponse, participantsQueryResponse,
} from '../../sidebar/mock_data'; } from '../../sidebar/mock_data';
...@@ -28,7 +31,7 @@ const assignee = { ...@@ -28,7 +31,7 @@ const assignee = {
const mockError = jest.fn().mockRejectedValue('Error!'); const mockError = jest.fn().mockRejectedValue('Error!');
const waitForSearch = async () => { const waitForSearch = async () => {
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await nextTick(); await nextTick();
await waitForPromises(); await waitForPromises();
}; };
...@@ -58,6 +61,7 @@ describe('User select dropdown', () => { ...@@ -58,6 +61,7 @@ describe('User select dropdown', () => {
} = {}) => { } = {}) => {
fakeApollo = createMockApollo([ fakeApollo = createMockApollo([
[searchUsersQuery, searchQueryHandler], [searchUsersQuery, searchQueryHandler],
[searchUsersQueryOnMR, jest.fn().mockResolvedValue(searchResponseOnMR)],
[getIssueParticipantsQuery, participantsQueryHandler], [getIssueParticipantsQuery, participantsQueryHandler],
]); ]);
wrapper = shallowMount(UserSelect, { wrapper = shallowMount(UserSelect, {
...@@ -76,7 +80,18 @@ describe('User select dropdown', () => { ...@@ -76,7 +80,18 @@ describe('User select dropdown', () => {
...props, ...props,
}, },
stubs: { 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', () => { ...@@ -132,11 +147,19 @@ describe('User select dropdown', () => {
expect(findSelectedParticipants()).toHaveLength(1); 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', () => { describe('when search is empty', () => {
it('renders a merged list of participants and project members', async () => { it('renders a merged list of participants and project members', async () => {
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
expect(findUnselectedParticipants()).toHaveLength(3);
expect(findUnselectedParticipants()).toHaveLength(4);
}); });
it('renders `Unassigned` link with the checkmark when there are no selected users', async () => { it('renders `Unassigned` link with the checkmark when there are no selected users', async () => {
...@@ -162,7 +185,7 @@ describe('User select dropdown', () => { ...@@ -162,7 +185,7 @@ describe('User select dropdown', () => {
}, },
}); });
await waitForPromises(); await waitForPromises();
findUnassignLink().vm.$emit('click'); findUnassignLink().trigger('click');
expect(wrapper.emitted('input')).toEqual([[[]]]); expect(wrapper.emitted('input')).toEqual([[[]]]);
}); });
...@@ -175,7 +198,7 @@ describe('User select dropdown', () => { ...@@ -175,7 +198,7 @@ describe('User select dropdown', () => {
}); });
await waitForPromises(); await waitForPromises();
findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); findSelectedParticipants().at(0).trigger('click');
expect(wrapper.emitted('input')).toEqual([[[]]]); expect(wrapper.emitted('input')).toEqual([[[]]]);
}); });
...@@ -187,8 +210,9 @@ describe('User select dropdown', () => { ...@@ -187,8 +210,9 @@ describe('User select dropdown', () => {
}); });
await waitForPromises(); await waitForPromises();
findUnselectedParticipants().at(0).vm.$emit('click'); findUnselectedParticipants().at(0).trigger('click');
expect(wrapper.emitted('input')).toEqual([
expect(wrapper.emitted('input')).toMatchObject([
[ [
[ [
{ {
...@@ -214,7 +238,7 @@ describe('User select dropdown', () => { ...@@ -214,7 +238,7 @@ describe('User select dropdown', () => {
}); });
await waitForPromises(); await waitForPromises();
findUnselectedParticipants().at(0).vm.$emit('click'); findUnselectedParticipants().at(0).trigger('click');
expect(wrapper.emitted('input')[0][0]).toHaveLength(2); expect(wrapper.emitted('input')[0][0]).toHaveLength(2);
}); });
}); });
...@@ -232,7 +256,7 @@ describe('User select dropdown', () => { ...@@ -232,7 +256,7 @@ describe('User select dropdown', () => {
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
findSearchField().vm.$emit('input', 'roo'); findSearchField().vm.$emit('input', 'roo');
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await nextTick(); await nextTick();
expect(findParticipantsLoading().exists()).toBe(true); expect(findParticipantsLoading().exists()).toBe(true);
...@@ -273,4 +297,19 @@ describe('User select dropdown', () => { ...@@ -273,4 +297,19 @@ describe('User select dropdown', () => {
expect(findEmptySearchResults().exists()).toBe(true); 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');
});
});
}); });
# 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment