Commit 96b03792 authored by Rémy Coutable's avatar Rémy Coutable

Merge branch 'ce-22058-improve-ux-multi-assignees-in-mr' into 'master'

Improve UX multi assigness in MR

See merge request gitlab-org/gitlab-ce!31545
parents d9f9904c e4c44b08
<script>
import { __, sprintf } from '~/locale';
export default {
props: {
user: {
type: Object,
required: true,
},
imgSize: {
type: Number,
required: true,
},
issuableType: {
type: String,
required: false,
default: 'issue',
},
},
computed: {
assigneeAlt() {
return sprintf(__("%{userName}'s avatar"), { userName: this.user.name });
},
avatarUrl() {
return this.user.avatar || this.user.avatar_url || gon.default_avatar_url;
},
isMergeRequest() {
return this.issuableType === 'merge_request';
},
hasMergeIcon() {
return this.isMergeRequest && !this.user.can_merge;
},
},
};
</script>
<template>
<span class="position-relative">
<img
:alt="assigneeAlt"
:src="avatarUrl"
:width="imgSize"
:class="`s${imgSize}`"
class="avatar avatar-inline m-0"
/>
<i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i>
</span>
</template>
<script>
import { __, sprintf } from '~/locale';
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import AssigneeAvatar from './assignee_avatar.vue';
export default {
components: {
AssigneeAvatar,
GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
user: {
type: Object,
required: true,
},
rootPath: {
type: String,
required: true,
},
tooltipPlacement: {
type: String,
default: 'bottom',
required: false,
},
tooltipHasName: {
type: Boolean,
default: true,
required: false,
},
issuableType: {
type: String,
default: 'issue',
required: false,
},
},
computed: {
cannotMerge() {
return this.issuableType === 'merge_request' && !this.user.can_merge;
},
tooltipTitle() {
if (this.cannotMerge && this.tooltipHasName) {
return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name });
} else if (this.cannotMerge) {
return __('Cannot merge');
} else if (this.tooltipHasName) {
return this.user.name;
}
return '';
},
tooltipOption() {
return {
container: 'body',
placement: this.tooltipPlacement,
boundary: 'viewport',
};
},
assigneeUrl() {
return joinPaths(`${this.rootPath}`, `${this.user.username}`);
},
},
};
</script>
<template>
<!-- must be `d-inline-block` or parent flex-basis causes width issues -->
<gl-link
v-gl-tooltip="tooltipOption"
:href="assigneeUrl"
:title="tooltipTitle"
class="d-inline-block"
>
<!-- use d-flex so that slot can be appropriately styled -->
<span class="d-flex">
<assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
<slot :user="user"></slot>
</span>
</gl-link>
</template>
<script> <script>
import { __, sprintf } from '~/locale'; import CollapsedAssigneeList from '../assignees/collapsed_assignee_list.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import UncollapsedAssigneeList from '../assignees/uncollapsed_assignee_list.vue';
export default { export default {
// name: 'Assignees' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives // name: 'Assignees' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
name: 'Assignees', name: 'Assignees',
directives: { components: {
tooltip, CollapsedAssigneeList,
UncollapsedAssigneeList,
}, },
props: { props: {
rootPath: { rootPath: {
...@@ -24,171 +25,34 @@ export default { ...@@ -24,171 +25,34 @@ export default {
}, },
issuableType: { issuableType: {
type: String, type: String,
require: true, required: false,
default: 'issue', default: 'issue',
}, },
}, },
data() {
return {
defaultRenderCount: 5,
defaultMaxCounter: 99,
showLess: true,
};
},
computed: { computed: {
firstUser() {
return this.users[0];
},
hasMoreThanTwoAssignees() {
return this.users.length > 2;
},
hasMoreThanOneAssignee() {
return this.users.length > 1;
},
hasAssignees() {
return this.users.length > 0;
},
hasNoUsers() { hasNoUsers() {
return !this.users.length; return !this.users.length;
}, },
hasOneUser() { sortedAssigness() {
return this.users.length === 1; const canMergeUsers = this.users.filter(user => user.can_merge);
}, const canNotMergeUsers = this.users.filter(user => !user.can_merge);
renderShowMoreSection() {
return this.users.length > this.defaultRenderCount;
},
numberOfHiddenAssignees() {
return this.users.length - this.defaultRenderCount;
},
isHiddenAssignees() {
return this.numberOfHiddenAssignees > 0;
},
hiddenAssigneesLabel() {
const { numberOfHiddenAssignees } = this;
return sprintf(__('+ %{numberOfHiddenAssignees} more'), { numberOfHiddenAssignees });
},
collapsedTooltipTitle() {
const maxRender = Math.min(this.defaultRenderCount, this.users.length);
const renderUsers = this.users.slice(0, maxRender);
const names = renderUsers.map(u => u.name);
if (this.users.length > maxRender) {
names.push(`+ ${this.users.length - maxRender} more`);
}
if (!this.users.length) {
const emptyTooltipLabel = __('Assignee(s)');
names.push(emptyTooltipLabel);
}
return names.join(', ');
},
sidebarAvatarCounter() {
let counter = `+${this.users.length - 1}`;
if (this.users.length > this.defaultMaxCounter) { return [...canMergeUsers, ...canNotMergeUsers];
counter = `${this.defaultMaxCounter}+`;
}
return counter;
},
mergeNotAllowedTooltipMessage() {
const assigneesCount = this.users.length;
if (this.issuableType !== 'merge_request' || assigneesCount === 0) {
return null;
}
const cannotMergeCount = this.users.filter(u => u.can_merge === false).length;
const canMergeCount = assigneesCount - cannotMergeCount;
if (canMergeCount === assigneesCount) {
// Everyone can merge
return null;
} else if (cannotMergeCount === assigneesCount && assigneesCount > 1) {
return __('No one can merge');
} else if (assigneesCount === 1) {
return __('Cannot merge');
}
return sprintf(__('%{canMergeCount}/%{assigneesCount} can merge'), {
canMergeCount,
assigneesCount,
});
}, },
}, },
methods: { methods: {
assignSelf() { assignSelf() {
this.$emit('assign-self'); this.$emit('assign-self');
}, },
toggleShowLess() {
this.showLess = !this.showLess;
},
renderAssignee(index) {
return !this.showLess || (index < this.defaultRenderCount && this.showLess);
},
avatarUrl(user) {
return user.avatar || user.avatar_url || gon.default_avatar_url;
},
assigneeUrl(user) {
return `${this.rootPath}${user.username}`;
},
assigneeAlt(user) {
return sprintf(__("%{userName}'s avatar"), { userName: user.name });
},
assigneeUsername(user) {
return `@${user.username}`;
},
shouldRenderCollapsedAssignee(index) {
const firstTwo = this.users.length <= 2 && index <= 2;
return index === 0 || firstTwo;
},
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<div <collapsed-assignee-list :users="sortedAssigness" :issuable-type="issuableType" />
v-tooltip
:class="{ 'multiple-users': hasMoreThanOneAssignee }"
:title="collapsedTooltipTitle"
class="sidebar-collapsed-icon sidebar-collapsed-user"
data-container="body"
data-placement="left"
data-boundary="viewport"
>
<i v-if="hasNoUsers" :aria-label="__('None')" class="fa fa-user"> </i>
<button
v-for="(user, index) in users"
v-if="shouldRenderCollapsedAssignee(index)"
:key="user.id"
type="button"
class="btn-link"
>
<img
:alt="assigneeAlt(user)"
:src="avatarUrl(user)"
width="24"
class="avatar avatar-inline s24"
/>
<span class="author"> {{ user.name }} </span>
</button>
<button v-if="hasMoreThanTwoAssignees" class="btn-link" type="button">
<span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span>
</button>
</div>
<div class="value hide-collapsed"> <div class="value hide-collapsed">
<span
v-if="mergeNotAllowedTooltipMessage"
v-tooltip
:title="mergeNotAllowedTooltipMessage"
data-placement="left"
class="float-right cannot-be-merged"
>
<i aria-hidden="true" data-hidden="true" class="fa fa-exclamation-triangle"></i>
</span>
<template v-if="hasNoUsers"> <template v-if="hasNoUsers">
<span class="assign-yourself no-value qa-assign-yourself"> <span class="assign-yourself no-value qa-assign-yourself">
{{ __('None') }} {{ __('None') }}
...@@ -200,51 +64,13 @@ export default { ...@@ -200,51 +64,13 @@ export default {
</template> </template>
</span> </span>
</template> </template>
<template v-else-if="hasOneUser">
<a :href="assigneeUrl(firstUser)" class="author-link bold"> <uncollapsed-assignee-list
<img v-else
:alt="assigneeAlt(firstUser)" :users="sortedAssigness"
:src="avatarUrl(firstUser)" :root-path="rootPath"
width="32" :issuable-type="issuableType"
class="avatar avatar-inline s32"
/>
<span class="author"> {{ firstUser.name }} </span>
<span class="username"> {{ assigneeUsername(firstUser) }} </span>
</a>
</template>
<template v-else>
<div class="user-list">
<div
v-for="(user, index) in users"
v-if="renderAssignee(index)"
:key="user.id"
class="user-item"
>
<a
:href="assigneeUrl(user)"
:data-title="user.name"
class="user-link has-tooltip"
data-container="body"
data-placement="bottom"
>
<img
:alt="assigneeAlt(user)"
:src="avatarUrl(user)"
width="32"
class="avatar avatar-inline s32"
/> />
</a>
</div>
</div>
<div v-if="renderShowMoreSection" class="user-list-more">
<button type="button" class="btn-link" @click="toggleShowLess">
<template v-if="showLess">
{{ hiddenAssigneesLabel }}
</template>
<template v-else>{{ __('- show less') }}</template>
</button>
</div>
</template>
</div> </div>
</div> </div>
</template> </template>
<script>
import AssigneeAvatar from './assignee_avatar.vue';
export default {
components: {
AssigneeAvatar,
},
props: {
user: {
type: Object,
required: true,
},
issuableType: {
type: String,
required: false,
default: 'issue',
},
},
};
</script>
<template>
<button type="button" class="btn-link">
<assignee-avatar :user="user" :img-size="24" :issuable-type="issuableType" />
<span class="author"> {{ user.name }} </span>
</button>
</template>
<script>
import { __, sprintf } from '~/locale';
import { GlTooltipDirective } from '@gitlab/ui';
import CollapsedAssignee from './collapsed_assignee.vue';
const DEFAULT_MAX_COUNTER = 99;
const DEFAULT_RENDER_COUNT = 5;
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
CollapsedAssignee,
},
props: {
users: {
type: Array,
required: true,
},
issuableType: {
type: String,
required: false,
default: 'issue',
},
},
computed: {
isMergeRequest() {
return this.issuableType === 'merge_request';
},
hasNoUsers() {
return !this.users.length;
},
hasMoreThanOneAssignee() {
return this.users.length > 1;
},
hasMoreThanTwoAssignees() {
return this.users.length > 2;
},
allAssigneesCanMerge() {
return this.users.every(user => user.can_merge);
},
sidebarAvatarCounter() {
if (this.users.length > DEFAULT_MAX_COUNTER) {
return `${DEFAULT_MAX_COUNTER}+`;
}
return `+${this.users.length - 1}`;
},
collapsedUsers() {
const collapsedLength = this.hasMoreThanTwoAssignees ? 1 : this.users.length;
return this.users.slice(0, collapsedLength);
},
tooltipTitleMergeStatus() {
if (!this.isMergeRequest) {
return '';
}
const mergeLength = this.users.filter(u => u.can_merge).length;
if (mergeLength === this.users.length) {
return '';
} else if (mergeLength > 0) {
return sprintf(__('%{mergeLength}/%{usersLength} can merge'), {
mergeLength,
usersLength: this.users.length,
});
}
return this.users.length === 1 ? __('cannot merge') : __('no one can merge');
},
tooltipTitle() {
const maxRender = Math.min(DEFAULT_RENDER_COUNT, this.users.length);
const renderUsers = this.users.slice(0, maxRender);
const names = renderUsers.map(u => u.name);
if (!this.users.length) {
return __('Assignee(s)');
}
if (this.users.length > names.length) {
names.push(sprintf(__('+ %{amount} more'), { amount: this.users.length - names.length }));
}
const text = names.join(', ');
return this.tooltipTitleMergeStatus ? `${text} (${this.tooltipTitleMergeStatus})` : text;
},
tooltipOptions() {
return { container: 'body', placement: 'left', boundary: 'viewport' };
},
},
};
</script>
<template>
<div
v-gl-tooltip="tooltipOptions"
:class="{ 'multiple-users': hasMoreThanOneAssignee }"
:title="tooltipTitle"
class="sidebar-collapsed-icon sidebar-collapsed-user"
>
<i v-if="hasNoUsers" :aria-label="__('None')" class="fa fa-user"> </i>
<collapsed-assignee
v-for="user in collapsedUsers"
:key="user.id"
:user="user"
:issuable-type="issuableType"
/>
<button v-if="hasMoreThanTwoAssignees" class="btn-link" type="button">
<span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span>
<i
v-if="isMergeRequest && !allAssigneesCanMerge"
aria-hidden="true"
class="fa fa-exclamation-triangle merge-icon"
></i>
</button>
</div>
</template>
...@@ -29,7 +29,7 @@ export default { ...@@ -29,7 +29,7 @@ export default {
}, },
issuableType: { issuableType: {
type: String, type: String,
require: true, required: false,
default: 'issue', default: 'issue',
}, },
}, },
......
<script>
import { __, sprintf } from '~/locale';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
const DEFAULT_RENDER_COUNT = 5;
export default {
components: {
AssigneeAvatarLink,
},
props: {
users: {
type: Array,
required: true,
},
rootPath: {
type: String,
required: true,
},
issuableType: {
type: String,
required: false,
default: 'issue',
},
},
data() {
return {
showLess: true,
};
},
computed: {
firstUser() {
return this.users[0];
},
hasOneUser() {
return this.users.length === 1;
},
hiddenAssigneesLabel() {
const { numberOfHiddenAssignees } = this;
return sprintf(__('+ %{numberOfHiddenAssignees} more'), { numberOfHiddenAssignees });
},
renderShowMoreSection() {
return this.users.length > DEFAULT_RENDER_COUNT;
},
numberOfHiddenAssignees() {
return this.users.length - DEFAULT_RENDER_COUNT;
},
uncollapsedUsers() {
const uncollapsedLength = this.showLess
? Math.min(this.users.length, DEFAULT_RENDER_COUNT)
: this.users.length;
return this.showLess ? this.users.slice(0, uncollapsedLength) : this.users;
},
username() {
return `@${this.firstUser.username}`;
},
},
methods: {
toggleShowLess() {
this.showLess = !this.showLess;
},
},
};
</script>
<template>
<assignee-avatar-link
v-if="hasOneUser"
v-slot="{ user }"
tooltip-placement="left"
:tooltip-has-name="false"
:user="firstUser"
:root-path="rootPath"
:issuable-type="issuableType"
>
<div class="ml-2">
<span class="author"> {{ user.name }} </span>
<span class="username"> {{ username }} </span>
</div>
</assignee-avatar-link>
<div v-else>
<div class="user-list">
<div v-for="user in uncollapsedUsers" :key="user.id" class="user-item">
<assignee-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" />
</div>
</div>
<div v-if="renderShowMoreSection" class="user-list-more">
<button type="button" class="btn-link" @click="toggleShowLess">
<template v-if="showLess">
{{ hiddenAssigneesLabel }}
</template>
<template v-else>{{ __('- show less') }}</template>
</button>
</div>
</div>
</template>
...@@ -62,6 +62,8 @@ function UsersSelect(currentUser, els, options = {}) { ...@@ -62,6 +62,8 @@ function UsersSelect(currentUser, els, options = {}) {
options.showCurrentUser = $dropdown.data('currentUser'); options.showCurrentUser = $dropdown.data('currentUser');
options.todoFilter = $dropdown.data('todoFilter'); options.todoFilter = $dropdown.data('todoFilter');
options.todoStateFilter = $dropdown.data('todoStateFilter'); options.todoStateFilter = $dropdown.data('todoStateFilter');
options.iid = $dropdown.data('iid');
options.issuableType = $dropdown.data('issuableType');
showNullUser = $dropdown.data('nullUser'); showNullUser = $dropdown.data('nullUser');
defaultNullUser = $dropdown.data('nullUserDefault'); defaultNullUser = $dropdown.data('nullUserDefault');
showMenuAbove = $dropdown.data('showMenuAbove'); showMenuAbove = $dropdown.data('showMenuAbove');
...@@ -423,6 +425,8 @@ function UsersSelect(currentUser, els, options = {}) { ...@@ -423,6 +425,8 @@ function UsersSelect(currentUser, els, options = {}) {
const { $el, e, isMarking } = options; const { $el, e, isMarking } = options;
const user = options.selectedObj; const user = options.selectedObj;
$el.tooltip('dispose');
if ($dropdown.hasClass('js-multiselect')) { if ($dropdown.hasClass('js-multiselect')) {
const isActive = $el.hasClass('is-active'); const isActive = $el.hasClass('is-active');
const previouslySelected = $dropdown const previouslySelected = $dropdown
...@@ -570,20 +574,11 @@ function UsersSelect(currentUser, els, options = {}) { ...@@ -570,20 +574,11 @@ function UsersSelect(currentUser, els, options = {}) {
user.name, user.name,
)}</a></li>`; )}</a></li>`;
} else { } else {
img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; // 0 margin, because it's now handled by a wrapper
img = "<img src='" + avatar + "' class='avatar avatar-inline m-0' width='32' />";
} }
return ` return _this.renderRow(options.issuableType, user, selected, username, img);
<li data-user-id=${user.id}>
<a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'>
${img}
<strong class='dropdown-menu-user-full-name'>
${_.escape(user.name)}
</strong>
${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''}
</a>
</li>
`;
}, },
}); });
}; };
...@@ -764,6 +759,11 @@ UsersSelect.prototype.users = function(query, options, callback) { ...@@ -764,6 +759,11 @@ UsersSelect.prototype.users = function(query, options, callback) {
author_id: options.authorId || null, author_id: options.authorId || null,
skip_users: options.skipUsers || null, skip_users: options.skipUsers || null,
}; };
if (options.issuableType === 'merge_request') {
params.merge_request_iid = options.iid || null;
}
return axios.get(url, { params }).then(({ data }) => { return axios.get(url, { params }).then(({ data }) => {
callback(data); callback(data);
}); });
...@@ -776,4 +776,44 @@ UsersSelect.prototype.buildUrl = function(url) { ...@@ -776,4 +776,44 @@ UsersSelect.prototype.buildUrl = function(url) {
return url; return url;
}; };
UsersSelect.prototype.renderRow = function(issuableType, user, selected, username, img) {
const tooltip = issuableType === 'merge_request' && !user.can_merge ? __('Cannot merge') : '';
const tooltipClass = tooltip ? `has-tooltip` : '';
const selectedClass = selected === true ? 'is-active' : '';
const linkClasses = `${selectedClass} ${tooltipClass}`;
const tooltipAttributes = tooltip
? `data-container="body" data-placement="left" data-title="${tooltip}"`
: '';
return `
<li data-user-id=${user.id}>
<a href="#" class="dropdown-menu-user-link d-flex align-items-center ${linkClasses}" ${tooltipAttributes}>
${this.renderRowAvatar(issuableType, user, img)}
<span class="d-flex flex-column overflow-hidden">
<strong class="dropdown-menu-user-full-name">
${_.escape(user.name)}
</strong>
${username ? `<span class="dropdown-menu-user-username">${username}</span>` : ''}
</span>
</a>
</li>
`;
};
UsersSelect.prototype.renderRowAvatar = function(issuableType, user, img) {
if (user.beforeDivider) {
return img;
}
const mergeIcon =
issuableType === 'merge_request' && !user.can_merge
? '<i class="fa fa-exclamation-triangle merge-icon"></i>'
: '';
return `<span class="position-relative mr-2">
${img}
${mergeIcon}
</span>`;
};
export default UsersSelect; export default UsersSelect;
...@@ -126,6 +126,16 @@ ...@@ -126,6 +126,16 @@
} }
} }
.assignee {
.merge-icon {
color: $orange-500;
position: absolute;
bottom: 0;
right: 0;
text-shadow: -1px -1px 0 $white-light, 1px -1px 0 $white-light, -1px 1px 0 $white-light, 1px 1px 0 $white-light;
}
}
.right-sidebar { .right-sidebar {
position: fixed; position: fixed;
top: $header-height; top: $header-height;
...@@ -202,7 +212,6 @@ ...@@ -202,7 +212,6 @@
&.assignee { &.assignee {
.author-link { .author-link {
display: block; display: block;
padding-left: 42px;
position: relative; position: relative;
&:hover { &:hover {
...@@ -210,12 +219,6 @@ ...@@ -210,12 +219,6 @@
text-decoration: underline; text-decoration: underline;
} }
} }
.avatar {
left: 0;
position: absolute;
top: 0;
}
} }
} }
} }
...@@ -354,13 +357,6 @@ ...@@ -354,13 +357,6 @@
margin-top: 0; margin-top: 0;
} }
.assignee .avatar {
float: left;
margin-right: 10px;
margin-bottom: 0;
margin-left: 0;
}
.assignee .user-list .avatar { .assignee .user-list .avatar {
margin: 0; margin: 0;
} }
...@@ -521,6 +517,10 @@ ...@@ -521,6 +517,10 @@
display: none; display: none;
} }
.merge-icon {
font-size: 10px;
}
.multiple-users { .multiple-users {
position: relative; position: relative;
height: 24px; height: 24px;
......
...@@ -4,6 +4,7 @@ class IssuableSidebarBasicEntity < Grape::Entity ...@@ -4,6 +4,7 @@ class IssuableSidebarBasicEntity < Grape::Entity
include RequestAwareEntity include RequestAwareEntity
expose :id expose :id
expose :iid
expose :type do |issuable| expose :type do |issuable|
issuable.to_ability_name issuable.to_ability_name
end end
......
...@@ -8,7 +8,7 @@ class MergeRequestSerializer < BaseSerializer ...@@ -8,7 +8,7 @@ class MergeRequestSerializer < BaseSerializer
entity ||= entity ||=
case opts[:serializer] case opts[:serializer]
when 'sidebar' when 'sidebar'
IssuableSidebarBasicEntity MergeRequestSidebarBasicEntity
when 'sidebar_extras' when 'sidebar_extras'
MergeRequestSidebarExtrasEntity MergeRequestSidebarExtrasEntity
when 'basic' when 'basic'
......
# frozen_string_literal: true
class MergeRequestSidebarBasicEntity < IssuableSidebarBasicEntity
expose :current_user, if: lambda { |_issuable| current_user } do
expose :can_merge do |merge_request|
merge_request.can_be_merged_by?(current_user)
end
end
end
...@@ -20,6 +20,8 @@ ...@@ -20,6 +20,8 @@
placeholder: _('Search users'), placeholder: _('Search users'),
data: { first_user: issuable_sidebar.dig(:current_user, :username), data: { first_user: issuable_sidebar.dig(:current_user, :username),
current_user: true, current_user: true,
iid: issuable_sidebar[:iid],
issuable_type: issuable_type,
project_id: issuable_sidebar[:project_id], project_id: issuable_sidebar[:project_id],
author_id: issuable_sidebar[:author_id], author_id: issuable_sidebar[:author_id],
field_name: "#{issuable_type}[assignee_ids][]", field_name: "#{issuable_type}[assignee_ids][]",
......
---
title: Update assignee (cannot merge) style
merge_request: 31545
author:
type: changed
...@@ -128,9 +128,6 @@ msgstr[1] "" ...@@ -128,9 +128,6 @@ msgstr[1] ""
msgid "%{actionText} & %{openOrClose} %{noteable}" msgid "%{actionText} & %{openOrClose} %{noteable}"
msgstr "" msgstr ""
msgid "%{canMergeCount}/%{assigneesCount} can merge"
msgstr ""
msgid "%{commit_author_link} authored %{commit_timeago}" msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr "" msgstr ""
...@@ -202,6 +199,9 @@ msgstr "" ...@@ -202,6 +199,9 @@ msgstr ""
msgid "%{lock_path} is locked by GitLab User %{lock_user_id}" msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
msgstr "" msgstr ""
msgid "%{mergeLength}/%{usersLength} can merge"
msgstr ""
msgid "%{mrText}, this issue will be closed automatically." msgid "%{mrText}, this issue will be closed automatically."
msgstr "" msgstr ""
...@@ -279,6 +279,9 @@ msgstr "" ...@@ -279,6 +279,9 @@ msgstr ""
msgid "%{usage_ping_link_start}Learn more%{usage_ping_link_end} about what information is shared with GitLab Inc." msgid "%{usage_ping_link_start}Learn more%{usage_ping_link_end} about what information is shared with GitLab Inc."
msgstr "" msgstr ""
msgid "%{userName} (cannot merge)"
msgstr ""
msgid "%{userName}'s avatar" msgid "%{userName}'s avatar"
msgstr "" msgstr ""
...@@ -306,6 +309,9 @@ msgstr "" ...@@ -306,6 +309,9 @@ msgstr ""
msgid "(external source)" msgid "(external source)"
msgstr "" msgstr ""
msgid "+ %{amount} more"
msgstr ""
msgid "+ %{count} more" msgid "+ %{count} more"
msgstr "" msgstr ""
...@@ -7439,9 +7445,6 @@ msgstr "" ...@@ -7439,9 +7445,6 @@ msgstr ""
msgid "No milestones to show" msgid "No milestones to show"
msgstr "" msgstr ""
msgid "No one can merge"
msgstr ""
msgid "No other labels with such name or description" msgid "No other labels with such name or description"
msgstr "" msgstr ""
...@@ -13443,6 +13446,9 @@ msgstr "" ...@@ -13443,6 +13446,9 @@ msgstr ""
msgid "cannot include leading slash or directory traversal." msgid "cannot include leading slash or directory traversal."
msgstr "" msgstr ""
msgid "cannot merge"
msgstr ""
msgid "comment" msgid "comment"
msgstr "" msgstr ""
...@@ -13870,6 +13876,9 @@ msgstr "" ...@@ -13870,6 +13876,9 @@ msgstr ""
msgid "no contributions" msgid "no contributions"
msgstr "" msgstr ""
msgid "no one can merge"
msgstr ""
msgid "none" msgid "none"
msgstr "" msgstr ""
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
"type": "object", "type": "object",
"properties" : { "properties" : {
"id": { "type": "integer" }, "id": { "type": "integer" },
"iid": { "type": "integer" },
"type": { "type": "string" }, "type": { "type": "string" },
"author_id": { "type": "integer" }, "author_id": { "type": "integer" },
"project_id": { "type": "integer" }, "project_id": { "type": "integer" },
......
import { shallowMount } from '@vue/test-utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { TEST_HOST } from 'helpers/test_constants';
import AssigneeAvatarLink from '~/sidebar/components/assignees/assignee_avatar_link.vue';
import AssigneeAvatar from '~/sidebar/components/assignees/assignee_avatar.vue';
import userDataMock from '../../user_data_mock';
const TOOLTIP_PLACEMENT = 'bottom';
const { name: USER_NAME, username: USER_USERNAME } = userDataMock();
const TEST_ISSUABLE_TYPE = 'merge_request';
describe('AssigneeAvatarLink component', () => {
let wrapper;
function createComponent(props = {}) {
const propsData = {
user: userDataMock(),
showLess: true,
rootPath: TEST_HOST,
tooltipPlacement: TOOLTIP_PLACEMENT,
singleUser: false,
issuableType: TEST_ISSUABLE_TYPE,
...props,
};
wrapper = shallowMount(AssigneeAvatarLink, {
propsData,
sync: false,
});
}
afterEach(() => {
wrapper.destroy();
});
const findTooltipText = () => wrapper.attributes('data-original-title');
it('has the root url present in the assigneeUrl method', () => {
createComponent();
const assigneeUrl = joinPaths(TEST_HOST, USER_USERNAME);
expect(wrapper.attributes().href).toEqual(assigneeUrl);
});
it('renders assignee avatar', () => {
createComponent();
expect(wrapper.find(AssigneeAvatar).props()).toEqual(
expect.objectContaining({
issuableType: TEST_ISSUABLE_TYPE,
user: userDataMock(),
}),
);
});
describe.each`
issuableType | tooltipHasName | canMerge | expected
${'merge_request'} | ${true} | ${true} | ${USER_NAME}
${'merge_request'} | ${true} | ${false} | ${`${USER_NAME} (cannot merge)`}
${'merge_request'} | ${false} | ${true} | ${''}
${'merge_request'} | ${false} | ${false} | ${'Cannot merge'}
${'issue'} | ${true} | ${true} | ${USER_NAME}
${'issue'} | ${true} | ${false} | ${USER_NAME}
${'issue'} | ${false} | ${true} | ${''}
${'issue'} | ${false} | ${false} | ${''}
`(
'with $issuableType and tooltipHasName=$tooltipHasName and canMerge=$canMerge',
({ issuableType, tooltipHasName, canMerge, expected }) => {
beforeEach(() => {
createComponent({
issuableType,
tooltipHasName,
user: {
...userDataMock(),
can_merge: canMerge,
},
});
});
it('sets tooltip', () => {
expect(findTooltipText()).toBe(expected);
});
},
);
});
import { shallowMount } from '@vue/test-utils';
import AssigneeAvatar from '~/sidebar/components/assignees/assignee_avatar.vue';
import { TEST_HOST } from 'helpers/test_constants';
import userDataMock from '../../user_data_mock';
const TEST_AVATAR = `${TEST_HOST}/avatar.png`;
const TEST_DEFAULT_AVATAR_URL = `${TEST_HOST}/default/avatar/url.png`;
describe('AssigneeAvatar', () => {
let origGon;
let wrapper;
function createComponent(props = {}) {
const propsData = {
user: userDataMock(),
imgSize: 24,
issuableType: 'merge_request',
...props,
};
wrapper = shallowMount(AssigneeAvatar, {
propsData,
sync: false,
});
}
beforeEach(() => {
origGon = window.gon;
window.gon = { default_avatar_url: TEST_DEFAULT_AVATAR_URL };
});
afterEach(() => {
window.gon = origGon;
wrapper.destroy();
});
const findImg = () => wrapper.find('img');
it('does not show warning icon if assignee can merge', () => {
createComponent();
expect(wrapper.find('.merge-icon').exists()).toBe(false);
});
it('shows warning icon if assignee cannot merge', () => {
createComponent({
user: {
can_merge: false,
},
});
expect(wrapper.find('.merge-icon').exists()).toBe(true);
});
it('does not show warning icon for issuableType = "issue"', () => {
createComponent({
issuableType: 'issue',
});
expect(wrapper.find('.merge-icon').exists()).toBe(false);
});
it.each`
avatar | avatar_url | expected | desc
${TEST_AVATAR} | ${null} | ${TEST_AVATAR} | ${'with avatar'}
${null} | ${TEST_AVATAR} | ${TEST_AVATAR} | ${'with avatar_url'}
${null} | ${null} | ${TEST_DEFAULT_AVATAR_URL} | ${'with no avatar'}
`('$desc', ({ avatar, avatar_url, expected }) => {
createComponent({
user: {
avatar,
avatar_url,
},
});
expect(findImg().attributes('src')).toEqual(expected);
});
});
import { shallowMount } from '@vue/test-utils';
import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue';
import CollapsedAssignee from '~/sidebar/components/assignees/collapsed_assignee.vue';
import UsersMockHelper from 'helpers/user_mock_data_helper';
const DEFAULT_MAX_COUNTER = 99;
describe('CollapsedAssigneeList component', () => {
let wrapper;
function createComponent(props = {}) {
const propsData = {
users: [],
issuableType: 'merge_request',
...props,
};
wrapper = shallowMount(CollapsedAssigneeList, {
propsData,
sync: false,
});
}
const findNoUsersIcon = () => wrapper.find('i[aria-label=None]');
const findAvatarCounter = () => wrapper.find('.avatar-counter');
const findAssignees = () => wrapper.findAll(CollapsedAssignee);
const getTooltipTitle = () => wrapper.attributes('data-original-title');
afterEach(() => {
wrapper.destroy();
});
describe('No assignees/users', () => {
beforeEach(() => {
createComponent({
users: [],
});
});
it('has no users', () => {
expect(findNoUsersIcon().exists()).toBe(true);
});
});
describe('One assignee/user', () => {
let users;
beforeEach(() => {
users = UsersMockHelper.createNumberRandomUsers(1);
});
it('should not show no users icon', () => {
createComponent({ users });
expect(findNoUsersIcon().exists()).toBe(false);
});
it('has correct "cannot merge" tooltip when user cannot merge', () => {
users[0].can_merge = false;
createComponent({ users });
expect(getTooltipTitle()).toContain('cannot merge');
});
it('does not have "merge" word in tooltip if user can merge', () => {
users[0].can_merge = true;
createComponent({ users });
expect(getTooltipTitle()).not.toContain('merge');
});
});
describe('More than one assignees/users', () => {
let users;
beforeEach(() => {
users = UsersMockHelper.createNumberRandomUsers(2);
createComponent({ users });
});
it('has multiple-users class', () => {
expect(wrapper.classes('multiple-users')).toBe(true);
});
it('does not display an avatar count', () => {
expect(findAvatarCounter().exists()).toBe(false);
});
it('returns just two collapsed users', () => {
expect(findAssignees().length).toBe(2);
});
});
describe('More than two assignees/users', () => {
let users;
let userNames;
beforeEach(() => {
users = UsersMockHelper.createNumberRandomUsers(3);
userNames = users.map(x => x.name).join(', ');
});
describe('default', () => {
beforeEach(() => {
createComponent({ users });
});
it('does display an avatar count', () => {
expect(findAvatarCounter().exists()).toBe(true);
expect(findAvatarCounter().text()).toEqual('+2');
});
it('returns one collapsed users', () => {
expect(findAssignees().length).toBe(1);
});
});
it('has corrent "no one can merge" tooltip when no one can merge', () => {
users[0].can_merge = false;
users[1].can_merge = false;
users[2].can_merge = false;
createComponent({
users,
});
expect(getTooltipTitle()).toEqual(`${userNames} (no one can merge)`);
});
it('has correct "cannot merge" tooltip when one user can merge', () => {
users[0].can_merge = true;
users[1].can_merge = false;
users[2].can_merge = false;
createComponent({
users,
});
expect(getTooltipTitle()).toEqual(`${userNames} (1/3 can merge)`);
});
it('has correct "cannot merge" tooltip when more than one user can merge', () => {
users[0].can_merge = false;
users[1].can_merge = true;
users[2].can_merge = true;
createComponent({
users,
});
expect(getTooltipTitle()).toEqual(`${userNames} (2/3 can merge)`);
});
it('does not have "merge" in tooltip if everyone can merge', () => {
users[0].can_merge = true;
users[1].can_merge = true;
users[2].can_merge = true;
createComponent({
users,
});
expect(getTooltipTitle()).toEqual(userNames);
});
it('displays the correct avatar count', () => {
users = UsersMockHelper.createNumberRandomUsers(5);
createComponent({
users,
});
expect(findAvatarCounter().text()).toEqual(`+${users.length - 1}`);
});
it('displays the correct avatar count via a computed property if more than default max counter', () => {
users = UsersMockHelper.createNumberRandomUsers(100);
createComponent({
users,
});
expect(findAvatarCounter().text()).toEqual(`${DEFAULT_MAX_COUNTER}+`);
});
});
});
import { shallowMount } from '@vue/test-utils';
import CollapsedAssignee from '~/sidebar/components/assignees/collapsed_assignee.vue';
import AssigneeAvatar from '~/sidebar/components/assignees/assignee_avatar.vue';
import userDataMock from '../../user_data_mock';
const TEST_USER = userDataMock();
const TEST_ISSUABLE_TYPE = 'merge_request';
describe('CollapsedAssignee assignee component', () => {
let wrapper;
function createComponent(props = {}) {
const propsData = {
user: userDataMock(),
issuableType: TEST_ISSUABLE_TYPE,
...props,
};
wrapper = shallowMount(CollapsedAssignee, {
propsData,
sync: false,
});
}
afterEach(() => {
wrapper.destroy();
});
it('has author name', () => {
createComponent();
expect(
wrapper
.find('.author')
.text()
.trim(),
).toEqual(TEST_USER.name);
});
it('has assignee avatar', () => {
createComponent();
expect(wrapper.find(AssigneeAvatar).props()).toEqual({
imgSize: 24,
user: TEST_USER,
issuableType: TEST_ISSUABLE_TYPE,
});
});
});
import { mount } from '@vue/test-utils';
import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_assignee_list.vue';
import AssigneeAvatarLink from '~/sidebar/components/assignees/assignee_avatar_link.vue';
import { TEST_HOST } from 'helpers/test_constants';
import userDataMock from '../../user_data_mock';
import UsersMockHelper from '../../../helpers/user_mock_data_helper';
const DEFAULT_RENDER_COUNT = 5;
describe('UncollapsedAssigneeList component', () => {
let wrapper;
function createComponent(props = {}) {
const propsData = {
users: [],
rootPath: TEST_HOST,
...props,
};
wrapper = mount(UncollapsedAssigneeList, {
sync: false,
propsData,
});
}
afterEach(() => {
wrapper.destroy();
});
const findMoreButton = () => wrapper.find('.user-list-more button');
describe('One assignee/user', () => {
let user;
beforeEach(() => {
user = userDataMock();
createComponent({
users: [user],
});
});
it('only has one user', () => {
expect(wrapper.findAll(AssigneeAvatarLink).length).toBe(1);
});
it('calls the AssigneeAvatarLink with the proper props', () => {
expect(wrapper.find(AssigneeAvatarLink).exists()).toBe(true);
expect(wrapper.find(AssigneeAvatarLink).props().tooltipPlacement).toEqual('left');
});
it('Shows one user with avatar, username and author name', () => {
expect(wrapper.text()).toContain(user.name);
expect(wrapper.text()).toContain(`@${user.username}`);
});
});
describe('n+ more label', () => {
describe('when users count is rendered users', () => {
beforeEach(() => {
createComponent({
users: UsersMockHelper.createNumberRandomUsers(DEFAULT_RENDER_COUNT),
});
});
it('does not show more label', () => {
expect(findMoreButton().exists()).toBe(false);
});
});
describe('when more than rendered users', () => {
beforeEach(() => {
createComponent({
users: UsersMockHelper.createNumberRandomUsers(DEFAULT_RENDER_COUNT + 1),
});
});
it('shows "+1 more" label', () => {
expect(findMoreButton().text()).toBe('+ 1 more');
});
it('shows truncated users', () => {
expect(wrapper.findAll(AssigneeAvatarLink).length).toBe(DEFAULT_RENDER_COUNT);
});
describe('when more button is clicked', () => {
beforeEach(() => {
findMoreButton().trigger('click');
return wrapper.vm.$nextTick();
});
it('shows "show less" label', () => {
expect(findMoreButton().text()).toBe('- show less');
});
it('shows all users', () => {
expect(wrapper.findAll(AssigneeAvatarLink).length).toBe(DEFAULT_RENDER_COUNT + 1);
});
});
});
});
});
export default () => ({
avatar_url: 'mock_path',
id: 1,
name: 'Root',
state: 'active',
username: 'root',
web_url: '',
can_merge: true,
});
...@@ -94,115 +94,9 @@ describe('Assignee component', () => { ...@@ -94,115 +94,9 @@ describe('Assignee component', () => {
expect(assignee.querySelector('.author').innerText.trim()).toEqual(UsersMock.user.name); expect(assignee.querySelector('.author').innerText.trim()).toEqual(UsersMock.user.name);
}); });
it('Shows one user with avatar, username and author name', () => {
component = new AssigneeComponent({
propsData: {
rootPath: 'http://localhost:3000/',
users: [UsersMock.user],
editable: true,
},
}).$mount();
expect(component.$el.querySelector('.author-link')).not.toBeNull();
// The image
expect(component.$el.querySelector('.author-link img').getAttribute('src')).toEqual(
UsersMock.user.avatar,
);
// Author name
expect(component.$el.querySelector('.author-link .author').innerText.trim()).toEqual(
UsersMock.user.name,
);
// Username
expect(component.$el.querySelector('.author-link .username').innerText.trim()).toEqual(
`@${UsersMock.user.username}`,
);
});
it('has the root url present in the assigneeUrl method', () => {
component = new AssigneeComponent({
propsData: {
rootPath: 'http://localhost:3000/',
users: [UsersMock.user],
editable: true,
},
}).$mount();
expect(component.assigneeUrl(UsersMock.user).indexOf('http://localhost:3000/')).not.toEqual(
-1,
);
});
it('has correct "cannot merge" tooltip when user cannot merge', () => {
const user = Object.assign({}, UsersMock.user, { can_merge: false });
component = new AssigneeComponent({
propsData: {
rootPath: 'http://localhost:3000/',
users: [user],
editable: true,
issuableType: 'merge_request',
},
}).$mount();
expect(component.mergeNotAllowedTooltipMessage).toEqual('Cannot merge');
});
}); });
describe('Two or more assignees/users', () => { describe('Two or more assignees/users', () => {
it('has correct "cannot merge" tooltip when one user can merge', () => {
const users = UsersMockHelper.createNumberRandomUsers(3);
users[0].can_merge = true;
users[1].can_merge = false;
users[2].can_merge = false;
component = new AssigneeComponent({
propsData: {
rootPath: 'http://localhost:3000/',
users,
editable: true,
issuableType: 'merge_request',
},
}).$mount();
expect(component.mergeNotAllowedTooltipMessage).toEqual('1/3 can merge');
});
it('has correct "cannot merge" tooltip when no user can merge', () => {
const users = UsersMockHelper.createNumberRandomUsers(2);
users[0].can_merge = false;
users[1].can_merge = false;
component = new AssigneeComponent({
propsData: {
rootPath: 'http://localhost:3000/',
users,
editable: true,
issuableType: 'merge_request',
},
}).$mount();
expect(component.mergeNotAllowedTooltipMessage).toEqual('No one can merge');
});
it('has correct "cannot merge" tooltip when more than one user can merge', () => {
const users = UsersMockHelper.createNumberRandomUsers(3);
users[0].can_merge = false;
users[1].can_merge = true;
users[2].can_merge = true;
component = new AssigneeComponent({
propsData: {
rootPath: 'http://localhost:3000/',
users,
editable: true,
issuableType: 'merge_request',
},
}).$mount();
expect(component.mergeNotAllowedTooltipMessage).toEqual('2/3 can merge');
});
it('has no "cannot merge" tooltip when every user can merge', () => { it('has no "cannot merge" tooltip when every user can merge', () => {
const users = UsersMockHelper.createNumberRandomUsers(2); const users = UsersMockHelper.createNumberRandomUsers(2);
users[0].can_merge = true; users[0].can_merge = true;
...@@ -217,7 +111,7 @@ describe('Assignee component', () => { ...@@ -217,7 +111,7 @@ describe('Assignee component', () => {
}, },
}).$mount(); }).$mount();
expect(component.mergeNotAllowedTooltipMessage).toEqual(null); expect(component.collapsedTooltipTitle).not.toContain('cannot merge');
}); });
it('displays two assignee icons when collapsed', () => { it('displays two assignee icons when collapsed', () => {
...@@ -295,21 +189,12 @@ describe('Assignee component', () => { ...@@ -295,21 +189,12 @@ describe('Assignee component', () => {
expect(component.$el.querySelector('.user-list-more')).toBe(null); expect(component.$el.querySelector('.user-list-more')).toBe(null);
}); });
it('sets tooltip container to body', () => { it('shows sorted assignee where "can merge" users are sorted first', () => {
const users = UsersMockHelper.createNumberRandomUsers(2); const users = UsersMockHelper.createNumberRandomUsers(3);
component = new AssigneeComponent({ users[0].can_merge = false;
propsData: { users[1].can_merge = false;
rootPath: 'http://localhost:3000', users[2].can_merge = true;
users,
editable: true,
},
}).$mount();
expect(component.$el.querySelector('.user-link').getAttribute('data-container')).toBe('body');
});
it('Shows the "show-less" assignees label', done => {
const users = UsersMockHelper.createNumberRandomUsers(6);
component = new AssigneeComponent({ component = new AssigneeComponent({
propsData: { propsData: {
rootPath: 'http://localhost:3000', rootPath: 'http://localhost:3000',
...@@ -318,85 +203,46 @@ describe('Assignee component', () => { ...@@ -318,85 +203,46 @@ describe('Assignee component', () => {
}, },
}).$mount(); }).$mount();
expect(component.$el.querySelectorAll('.user-item').length).toEqual( expect(component.sortedAssigness[0].can_merge).toBe(true);
component.defaultRenderCount,
);
expect(component.$el.querySelector('.user-list-more')).not.toBe(null);
const usersLabelExpectation = users.length - component.defaultRenderCount;
expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()).not.toBe(
`+${usersLabelExpectation} more`,
);
component.toggleShowLess();
Vue.nextTick(() => {
expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()).toBe(
'- show less',
);
done();
});
}); });
it('Shows the "show-less" when "n+ more " label is clicked', done => { it('passes the sorted assignees to the uncollapsed-assignee-list', () => {
const users = UsersMockHelper.createNumberRandomUsers(6); const users = UsersMockHelper.createNumberRandomUsers(3);
component = new AssigneeComponent({ users[0].can_merge = false;
propsData: { users[1].can_merge = false;
rootPath: 'http://localhost:3000', users[2].can_merge = true;
users,
editable: true,
},
}).$mount();
component.$el.querySelector('.user-list-more .btn-link').click();
Vue.nextTick(() => {
expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()).toBe(
'- show less',
);
done();
});
});
it('gets the count of avatar via a computed property ', () => {
const users = UsersMockHelper.createNumberRandomUsers(6);
component = new AssigneeComponent({ component = new AssigneeComponent({
propsData: { propsData: {
rootPath: 'http://localhost:3000', rootPath: 'http://localhost:3000',
users, users,
editable: true, editable: false,
}, },
}).$mount(); }).$mount();
expect(component.sidebarAvatarCounter).toEqual(`+${users.length - 1}`); const userItems = component.$el.querySelectorAll('.user-list .user-item a');
expect(userItems.length).toBe(3);
expect(userItems[0].dataset.originalTitle).toBe(users[2].name);
}); });
describe('n+ more label', () => { it('passes the sorted assignees to the collapsed-assignee-list', () => {
beforeEach(() => { const users = UsersMockHelper.createNumberRandomUsers(3);
const users = UsersMockHelper.createNumberRandomUsers(6); users[0].can_merge = false;
users[1].can_merge = false;
users[2].can_merge = true;
component = new AssigneeComponent({ component = new AssigneeComponent({
propsData: { propsData: {
rootPath: 'http://localhost:3000', rootPath: 'http://localhost:3000',
users, users,
editable: true, editable: false,
}, },
}).$mount(); }).$mount();
});
it('shows "+1 more" label', () => {
expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()).toBe(
'+ 1 more',
);
});
it('shows "show less" label', done => { const collapsedButton = component.$el.querySelector('.sidebar-collapsed-user button');
component.toggleShowLess();
Vue.nextTick(() => { expect(collapsedButton.innerText.trim()).toBe(users[2].name);
expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()).toBe(
'- show less',
);
done();
});
});
}); });
}); });
}); });
# frozen_string_literal: true
require 'spec_helper'
describe MergeRequestSidebarBasicEntity do
let(:project) { create :project, :repository }
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:user) { create(:user) }
let(:request) { double('request', current_user: user, project: project) }
let(:entity) { described_class.new(merge_request, request: request).as_json }
describe '#current_user' do
it 'contains attributes related to the current user' do
expect(entity[:current_user].keys).to contain_exactly(
:id, :name, :username, :state, :avatar_url, :web_url, :todo,
:can_edit, :can_move, :can_admin_label, :can_merge
)
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