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>
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;
},
},
};
......
<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;
......
......@@ -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 '';
......
......@@ -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>
......
<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"
......
......@@ -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>
<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>
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,
......
......@@ -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();
......
......@@ -10,8 +10,14 @@ query getMrAssignees($fullPath: ID!, $iid: String!) {
nodes {
...User
...UserAvailability
mergeRequestInteraction {
canMerge
}
}
}
userPermissions {
canMerge
}
}
}
}
......@@ -2,22 +2,19 @@
#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
mergeRequestInteraction {
canMerge
}
}
participants {
nodes {
...User
...UserAvailability
}
}
}
}
......
<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);
}
},
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') }}
......
......@@ -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;
......
......@@ -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)
......
......@@ -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 });
},
......
......@@ -9,5 +9,15 @@ RSpec.describe 'Merge request > User creates MR with multiple assignees' do
stub_licensed_features(multiple_merge_request_assignees: true)
end
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
......@@ -9,5 +9,15 @@ RSpec.describe 'Merge request > User edits MR with multiple assignees' do
stub_licensed_features(multiple_merge_request_assignees: true)
end
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
......@@ -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();
......
......@@ -106,6 +106,7 @@ RSpec.describe 'Issue Sidebar' do
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)
......
......@@ -17,9 +17,15 @@ RSpec.describe 'Merge request > User edits assignees sidebar', :js do
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'] || '' }
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_feature_flags(issue_assignees_widget: false)
end
context 'when user is an owner' do
before do
stub_const('Autocomplete::UsersFinder::LIMIT', users_find_limit)
......@@ -79,4 +85,104 @@ RSpec.describe 'Merge request > User edits assignees sidebar', :js 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
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
open_assignees_dropdown
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
context 'with invite members considerations' do
let_it_be(:user) { create(: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
def open_assignees_dropdown
page.within('.assignee') do
click_button('Edit')
wait_for_requests
end
end
end
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(),
},
},
},
});
};
......
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);
});
});
});
......@@ -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: {
......
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');
});
});
});
# 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