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>
import { __, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import CollapsedAssigneeList from '../assignees/collapsed_assignee_list.vue';
import UncollapsedAssigneeList from '../assignees/uncollapsed_assignee_list.vue';
export default {
// 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
name: 'Assignees',
directives: {
tooltip,
components: {
CollapsedAssigneeList,
UncollapsedAssigneeList,
},
props: {
rootPath: {
......@@ -24,171 +25,34 @@ export default {
},
issuableType: {
type: String,
require: true,
required: false,
default: 'issue',
},
},
data() {
return {
defaultRenderCount: 5,
defaultMaxCounter: 99,
showLess: true,
};
},
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() {
return !this.users.length;
},
hasOneUser() {
return this.users.length === 1;
},
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) {
counter = `${this.defaultMaxCounter}+`;
}
sortedAssigness() {
const canMergeUsers = this.users.filter(user => user.can_merge);
const canNotMergeUsers = this.users.filter(user => !user.can_merge);
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,
});
return [...canMergeUsers, ...canNotMergeUsers];
},
},
methods: {
assignSelf() {
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>
<template>
<div>
<div
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>
<collapsed-assignee-list :users="sortedAssigness" :issuable-type="issuableType" />
<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">
<span class="assign-yourself no-value qa-assign-yourself">
{{ __('None') }}
......@@ -200,51 +64,13 @@ export default {
</template>
</span>
</template>
<template v-else-if="hasOneUser">
<a :href="assigneeUrl(firstUser)" class="author-link bold">
<img
:alt="assigneeAlt(firstUser)"
:src="avatarUrl(firstUser)"
width="32"
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>
<uncollapsed-assignee-list
v-else
:users="sortedAssigness"
:root-path="rootPath"
:issuable-type="issuableType"
/>
</div>
</div>
</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 {
},
issuableType: {
type: String,
require: true,
required: false,
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 = {}) {
options.showCurrentUser = $dropdown.data('currentUser');
options.todoFilter = $dropdown.data('todoFilter');
options.todoStateFilter = $dropdown.data('todoStateFilter');
options.iid = $dropdown.data('iid');
options.issuableType = $dropdown.data('issuableType');
showNullUser = $dropdown.data('nullUser');
defaultNullUser = $dropdown.data('nullUserDefault');
showMenuAbove = $dropdown.data('showMenuAbove');
......@@ -239,7 +241,7 @@ function UsersSelect(currentUser, els, options = {}) {
'<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>',
);
assigneeTemplate = _.template(
`<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself">
`<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself">
${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), {
openingTag: '<a href="#" class="js-assign-yourself">',
closingTag: '</a>',
......@@ -423,6 +425,8 @@ function UsersSelect(currentUser, els, options = {}) {
const { $el, e, isMarking } = options;
const user = options.selectedObj;
$el.tooltip('dispose');
if ($dropdown.hasClass('js-multiselect')) {
const isActive = $el.hasClass('is-active');
const previouslySelected = $dropdown
......@@ -570,20 +574,11 @@ function UsersSelect(currentUser, els, options = {}) {
user.name,
)}</a></li>`;
} 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 `
<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>
`;
return _this.renderRow(options.issuableType, user, selected, username, img);
},
});
};
......@@ -764,6 +759,11 @@ UsersSelect.prototype.users = function(query, options, callback) {
author_id: options.authorId || 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 }) => {
callback(data);
});
......@@ -776,4 +776,44 @@ UsersSelect.prototype.buildUrl = function(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;
......@@ -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 {
position: fixed;
top: $header-height;
......@@ -202,7 +212,6 @@
&.assignee {
.author-link {
display: block;
padding-left: 42px;
position: relative;
&:hover {
......@@ -210,12 +219,6 @@
text-decoration: underline;
}
}
.avatar {
left: 0;
position: absolute;
top: 0;
}
}
}
}
......@@ -354,13 +357,6 @@
margin-top: 0;
}
.assignee .avatar {
float: left;
margin-right: 10px;
margin-bottom: 0;
margin-left: 0;
}
.assignee .user-list .avatar {
margin: 0;
}
......@@ -521,6 +517,10 @@
display: none;
}
.merge-icon {
font-size: 10px;
}
.multiple-users {
position: relative;
height: 24px;
......
......@@ -4,6 +4,7 @@ class IssuableSidebarBasicEntity < Grape::Entity
include RequestAwareEntity
expose :id
expose :iid
expose :type do |issuable|
issuable.to_ability_name
end
......
......@@ -8,7 +8,7 @@ class MergeRequestSerializer < BaseSerializer
entity ||=
case opts[:serializer]
when 'sidebar'
IssuableSidebarBasicEntity
MergeRequestSidebarBasicEntity
when 'sidebar_extras'
MergeRequestSidebarExtrasEntity
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 @@
placeholder: _('Search users'),
data: { first_user: issuable_sidebar.dig(:current_user, :username),
current_user: true,
iid: issuable_sidebar[:iid],
issuable_type: issuable_type,
project_id: issuable_sidebar[:project_id],
author_id: issuable_sidebar[:author_id],
field_name: "#{issuable_type}[assignee_ids][]",
......
---
title: Update assignee (cannot merge) style
merge_request: 31545
author:
type: changed
......@@ -128,9 +128,6 @@ msgstr[1] ""
msgid "%{actionText} & %{openOrClose} %{noteable}"
msgstr ""
msgid "%{canMergeCount}/%{assigneesCount} can merge"
msgstr ""
msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
......@@ -202,6 +199,9 @@ msgstr ""
msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
msgstr ""
msgid "%{mergeLength}/%{usersLength} can merge"
msgstr ""
msgid "%{mrText}, this issue will be closed automatically."
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."
msgstr ""
msgid "%{userName} (cannot merge)"
msgstr ""
msgid "%{userName}'s avatar"
msgstr ""
......@@ -306,6 +309,9 @@ msgstr ""
msgid "(external source)"
msgstr ""
msgid "+ %{amount} more"
msgstr ""
msgid "+ %{count} more"
msgstr ""
......@@ -7439,9 +7445,6 @@ msgstr ""
msgid "No milestones to show"
msgstr ""
msgid "No one can merge"
msgstr ""
msgid "No other labels with such name or description"
msgstr ""
......@@ -13443,6 +13446,9 @@ msgstr ""
msgid "cannot include leading slash or directory traversal."
msgstr ""
msgid "cannot merge"
msgstr ""
msgid "comment"
msgstr ""
......@@ -13870,6 +13876,9 @@ msgstr ""
msgid "no contributions"
msgstr ""
msgid "no one can merge"
msgstr ""
msgid "none"
msgstr ""
......
......@@ -2,6 +2,7 @@
"type": "object",
"properties" : {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"type": { "type": "string" },
"author_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', () => {
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', () => {
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', () => {
const users = UsersMockHelper.createNumberRandomUsers(2);
users[0].can_merge = true;
......@@ -217,7 +111,7 @@ describe('Assignee component', () => {
},
}).$mount();
expect(component.mergeNotAllowedTooltipMessage).toEqual(null);
expect(component.collapsedTooltipTitle).not.toContain('cannot merge');
});
it('displays two assignee icons when collapsed', () => {
......@@ -295,8 +189,12 @@ describe('Assignee component', () => {
expect(component.$el.querySelector('.user-list-more')).toBe(null);
});
it('sets tooltip container to body', () => {
const users = UsersMockHelper.createNumberRandomUsers(2);
it('shows sorted assignee where "can merge" users are sorted first', () => {
const users = UsersMockHelper.createNumberRandomUsers(3);
users[0].can_merge = false;
users[1].can_merge = false;
users[2].can_merge = true;
component = new AssigneeComponent({
propsData: {
rootPath: 'http://localhost:3000',
......@@ -305,98 +203,46 @@ describe('Assignee component', () => {
},
}).$mount();
expect(component.$el.querySelector('.user-link').getAttribute('data-container')).toBe('body');
expect(component.sortedAssigness[0].can_merge).toBe(true);
});
it('Shows the "show-less" assignees label', done => {
const users = UsersMockHelper.createNumberRandomUsers(6);
it('passes the sorted assignees to the uncollapsed-assignee-list', () => {
const users = UsersMockHelper.createNumberRandomUsers(3);
users[0].can_merge = false;
users[1].can_merge = false;
users[2].can_merge = true;
component = new AssigneeComponent({
propsData: {
rootPath: 'http://localhost:3000',
users,
editable: true,
editable: false,
},
}).$mount();
expect(component.$el.querySelectorAll('.user-item').length).toEqual(
component.defaultRenderCount,
);
expect(component.$el.querySelector('.user-list-more')).not.toBe(null);
const usersLabelExpectation = users.length - component.defaultRenderCount;
const userItems = component.$el.querySelectorAll('.user-list .user-item a');
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();
});
expect(userItems.length).toBe(3);
expect(userItems[0].dataset.originalTitle).toBe(users[2].name);
});
it('Shows the "show-less" when "n+ more " label is clicked', done => {
const users = UsersMockHelper.createNumberRandomUsers(6);
component = new AssigneeComponent({
propsData: {
rootPath: 'http://localhost:3000',
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('passes the sorted assignees to the collapsed-assignee-list', () => {
const users = UsersMockHelper.createNumberRandomUsers(3);
users[0].can_merge = false;
users[1].can_merge = false;
users[2].can_merge = true;
it('gets the count of avatar via a computed property ', () => {
const users = UsersMockHelper.createNumberRandomUsers(6);
component = new AssigneeComponent({
propsData: {
rootPath: 'http://localhost:3000',
users,
editable: true,
editable: false,
},
}).$mount();
expect(component.sidebarAvatarCounter).toEqual(`+${users.length - 1}`);
});
const collapsedButton = component.$el.querySelector('.sidebar-collapsed-user button');
describe('n+ more label', () => {
beforeEach(() => {
const users = UsersMockHelper.createNumberRandomUsers(6);
component = new AssigneeComponent({
propsData: {
rootPath: 'http://localhost:3000',
users,
editable: true,
},
}).$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 => {
component.toggleShowLess();
Vue.nextTick(() => {
expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()).toBe(
'- show less',
);
done();
});
});
expect(collapsedButton.innerText.trim()).toBe(users[2].name);
});
});
});
# 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