diff options
author | Samantha Ming <sming@gitlab.com> | 2019-07-25 19:30:50 -0700 |
---|---|---|
committer | Paul Slaughter <pslaughter@gitlab.com> | 2019-08-20 15:15:18 -0500 |
commit | f1f34baf6f4e0866feb3f4c7523e3dccf5784b0b (patch) | |
tree | 0922f96df924d9e9bf99ccc3a047683b7586b9c7 /app/assets/javascripts/sidebar | |
parent | fd589be32f02c160812bbce0f5fb2178fb7142d3 (diff) | |
download | gitlab-ce-f1f34baf6f4e0866feb3f4c7523e3dccf5784b0b.tar.gz |
Improve UX multi assigness in MR
Add merge warning on avatar in:
- open view assigness
- collapsed view assigness
- dropdown (search) view assigness
Add can_merge option to MR sidebar entity
Diffstat (limited to 'app/assets/javascripts/sidebar')
6 files changed, 384 insertions, 192 deletions
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue new file mode 100644 index 00000000000..0bb4f757de8 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -0,0 +1,48 @@ +<script> +import { __, sprintf } from '~/locale'; + +export default { + props: { + user: { + type: Object, + required: true, + }, + imgSize: { + type: Number, + required: true, + }, + issuableType: { + type: String, + require: true, + 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> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue new file mode 100644 index 00000000000..6563d1d3410 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -0,0 +1,74 @@ +<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> + <gl-link v-gl-tooltip="tooltipOption" :href="assigneeUrl" :title="tooltipTitle" class="d-flex"> + <assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> + <slot :user="user"></slot> + </gl-link> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 631e2e28d4d..ca4090eee96 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -1,13 +1,14 @@ <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: { @@ -28,167 +29,30 @@ export default { 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> diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue new file mode 100644 index 00000000000..3f3626092a8 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue @@ -0,0 +1,27 @@ +<script> +import AssigneeAvatar from './assignee_avatar.vue'; + +export default { + components: { + AssigneeAvatar, + }, + props: { + user: { + type: Object, + required: true, + }, + issuableType: { + type: String, + require: true, + 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> diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue new file mode 100644 index 00000000000..6db198f1c1e --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -0,0 +1,121 @@ +<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, + require: true, + 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> diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue new file mode 100644 index 00000000000..e84b323c7bd --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -0,0 +1,96 @@ +<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, + require: true, + 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> |