Commit 699c8057 authored by Natalia Tepluhina's avatar Natalia Tepluhina Committed by Phil Hughes

Scaffolded MR assignees

Moved changes from https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60611
Added fetching merge request permisstion

Separated user search queries

Fixed canMerge calculation

Removed default debounce

Added correct canMerge calculation

Fixed assignees canMerge

Fixed "cannot merge" icon positioning

Frxed missing import error

Fixed MR assignees spec for disabled FF

Added tests for FF enabled

Fixed user select spec

Added merge permissions to active user

Added tooltips for cannot merge

Fixed invite user test case

Fixed MR assignees spec

Fixed sidebar assignees spec

Removed non-required changes

Revert "Removed non-required changes"

This reverts commit b4cb5b8a38e922f3dec95e55e0f656408b94c87a.
Fixed rubocop warning

Fixed multiple assignees specs for FF disabled

Fixed multiple assignees for FF enabled

Added unit test for sidebar participant

Added tests for user select
Added @ to usernames on sidebar

Added focusing search input

Fixed dropdown navigation

Fixed selecting assignees

Fixed tooltip for collapsed assignees

Removed opening dropdown on collapsed click

Fixed assignees widget scoped slot

Removed unnecessary paddings

Fixed sidebar assignees widget test

Fixed user select spec
parent aeb60464
#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';
......@@ -53,8 +54,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+)$/;
......@@ -91,6 +90,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';
......@@ -135,6 +136,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,
......@@ -152,21 +155,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,
},
}),
},
......@@ -585,7 +583,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,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
}
}
}
}
......
<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') }}
......
......@@ -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
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
......@@ -9,5 +9,15 @@ RSpec.describe 'Merge request > User edits MR with multiple assignees' do
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
......@@ -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,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_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
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