diff options
author | Dennis Tang <dennis@dennistang.net> | 2018-04-19 14:43:20 +0000 |
---|---|---|
committer | Rémy Coutable <remy@rymai.me> | 2018-04-19 14:43:20 +0000 |
commit | 49b85262c5a1944d7fdb50e43900a4adb13aba6c (patch) | |
tree | 9a24b282724d1fcfd9eec599db891a0cb2c6e517 /app | |
parent | 79905b5a0507bc75d6a960d9d85cce281e9041b3 (diff) | |
download | gitlab-ce-49b85262c5a1944d7fdb50e43900a4adb13aba6c.tar.gz |
Resolve "Improve tooltips of collapsed sidebars"
Diffstat (limited to 'app')
27 files changed, 374 insertions, 100 deletions
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index 842a4255f08..4164149dd06 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -2,7 +2,9 @@ import $ from 'jquery'; import Pikaday from 'pikaday'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; +import { timeFor } from './lib/utils/datetime_utility'; import { parsePikadayDate, pikadayToString } from './lib/utils/datefix'; class DueDateSelect { @@ -14,6 +16,7 @@ class DueDateSelect { this.$dropdownParent = $dropdownParent; this.$datePicker = $dropdownParent.find('.js-due-date-calendar'); this.$block = $block; + this.$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon'); this.$selectbox = $dropdown.closest('.selectbox'); this.$value = $block.find('.value'); this.$valueContent = $block.find('.value-content'); @@ -128,7 +131,8 @@ class DueDateSelect { submitSelectedDate(isDropdown) { const selectedDateValue = this.datePayload[this.abilityName].due_date; - const displayedDateStyle = this.displayedDate !== 'No due date' ? 'bold' : 'no-value'; + const hasDueDate = this.displayedDate !== 'No due date'; + const displayedDateStyle = hasDueDate ? 'bold' : 'no-value'; this.$loading.removeClass('hidden').fadeIn(); @@ -145,10 +149,13 @@ class DueDateSelect { return axios.put(this.issueUpdateURL, this.datePayload) .then(() => { + const tooltipText = hasDueDate ? `${__('Due date')}<br />${selectedDateValue} (${timeFor(selectedDateValue)})` : __('Due date'); if (isDropdown) { this.$dropdown.trigger('loaded.gl.dropdown'); this.$dropdown.dropdown('toggle'); } + this.$sidebarCollapsedValue.attr('data-original-title', tooltipText); + return this.$loading.fadeOut(); }); } diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index d0050abb8e9..9b62cfb8206 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -83,7 +83,7 @@ export default class LabelsSelect { $dropdown.trigger('loading.gl.dropdown'); axios.put(issueUpdateURL, data) .then(({ data }) => { - var labelCount, template, labelTooltipTitle, labelTitles; + var labelCount, template, labelTooltipTitle, labelTitles, formattedLabels; $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); @@ -115,8 +115,7 @@ export default class LabelsSelect { labelTooltipTitle = labelTitles.join(', '); } else { - labelTooltipTitle = ''; - $sidebarLabelTooltip.tooltip('destroy'); + labelTooltipTitle = __('Labels'); } $sidebarLabelTooltip diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index d0a2b27b0e6..7e9a50a885d 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import _ from 'underscore'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import { timeFor } from './lib/utils/datetime_utility'; import ModalStore from './boards/stores/modal_store'; @@ -25,7 +26,7 @@ export default class MilestoneSelect { } $els.each((i, dropdown) => { - let collapsedSidebarLabelTemplate, milestoneLinkNoneTemplate, milestoneLinkTemplate, selectedMilestone, selectedMilestoneDefault; + let milestoneLinkNoneTemplate, milestoneLinkTemplate, selectedMilestone, selectedMilestoneDefault; const $dropdown = $(dropdown); const projectId = $dropdown.data('projectId'); const milestonesUrl = $dropdown.data('milestones'); @@ -52,7 +53,6 @@ export default class MilestoneSelect { if (issueUpdateURL) { milestoneLinkTemplate = _.template('<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>'); milestoneLinkNoneTemplate = '<span class="no-value">None</span>'; - collapsedSidebarLabelTemplate = _.template('<span class="has-tooltip" data-container="body" title="<%- name %><br /><%- remaining %>" data-placement="left" data-html="true"> <%- title %> </span>'); } return $dropdown.glDropdown({ showMenuAbove: showMenuAbove, @@ -214,10 +214,16 @@ export default class MilestoneSelect { data.milestone.remaining = timeFor(data.milestone.due_date); data.milestone.name = data.milestone.title; $value.html(milestoneLinkTemplate(data.milestone)); - return $sidebarCollapsedValue.find('span').html(collapsedSidebarLabelTemplate(data.milestone)); + return $sidebarCollapsedValue + .attr('data-original-title', `${data.milestone.name}<br />${data.milestone.remaining}`) + .find('span') + .text(data.milestone.title); } else { $value.html(milestoneLinkNoneTemplate); - return $sidebarCollapsedValue.find('span').text('No'); + return $sidebarCollapsedValue + .attr('data-original-title', __('Milestone')) + .find('span') + .text(__('None')); } }) .catch(() => { diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 2088a49590a..6eb0b62fa1c 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -5,6 +5,7 @@ import _ from 'underscore'; import Cookies from 'js-cookie'; import flash from './flash'; import axios from './lib/utils/axios_utils'; +import { __ } from './locale'; function Sidebar(currentUser) { this.toggleTodo = this.toggleTodo.bind(this); @@ -41,12 +42,14 @@ Sidebar.prototype.addEventListeners = function() { }; Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { - var $allGutterToggleIcons, $this, $thisIcon; + var $allGutterToggleIcons, $this, isExpanded, tooltipLabel; e.preventDefault(); $this = $(this); - $thisIcon = $this.find('i'); + isExpanded = $this.find('i').hasClass('fa-angle-double-right'); + tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar'); $allGutterToggleIcons = $('.js-sidebar-toggle i'); - if ($thisIcon.hasClass('fa-angle-double-right')) { + + if (isExpanded) { $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left'); $('aside.right-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); $('.layout-page').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); @@ -57,6 +60,9 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { if (gl.lazyLoader) gl.lazyLoader.loadCheck(); } + + $this.attr('data-original-title', tooltipLabel); + if (!triggered) { Cookies.set("collapsed_gutter", $('.right-sidebar').hasClass('right-sidebar-collapsed')); } diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 1e7f46454bf..2d00e8ac7e0 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -1,6 +1,12 @@ <script> +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; + export default { name: 'Assignees', + directives: { + tooltip, + }, props: { rootPath: { type: String, @@ -14,6 +20,11 @@ export default { type: Boolean, required: true, }, + issuableType: { + type: String, + require: true, + default: 'issue', + }, }, data() { return { @@ -62,6 +73,12 @@ export default { names.push(`+ ${this.users.length - maxRender} more`); } + if (!this.users.length) { + const emptyTooltipLabel = this.issuableType === 'issue' ? + __('Assignee(s)') : __('Assignee'); + names.push(emptyTooltipLabel); + } + return names.join(', '); }, sidebarAvatarCounter() { @@ -109,7 +126,8 @@ export default { <div> <div class="sidebar-collapsed-icon sidebar-collapsed-user" - :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" + :class="{ 'multiple-users': hasMoreThanOneAssignee }" + v-tooltip data-container="body" data-placement="left" :title="collapsedTooltipTitle" diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 3c6b9c27814..b04a2eff798 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -1,9 +1,9 @@ <script> -import Flash from '../../../flash'; +import Flash from '~/flash'; +import eventHub from '~/sidebar/event_hub'; +import Store from '~/sidebar/stores/sidebar_store'; import AssigneeTitle from './assignee_title.vue'; import Assignees from './assignees.vue'; -import Store from '../../stores/sidebar_store'; -import eventHub from '../../event_hub'; export default { name: 'SidebarAssignees', @@ -25,6 +25,11 @@ export default { required: false, default: false, }, + issuableType: { + type: String, + require: true, + default: 'issue', + }, }, data() { return { @@ -90,6 +95,7 @@ export default { :users="store.assignees" :editable="store.editable" @assign-self="assignSelf" + :issuable-type="issuableType" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index ceb02309959..7f0de722f61 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -1,15 +1,19 @@ <script> -import Flash from '../../../flash'; +import { __ } from '~/locale'; +import Flash from '~/flash'; +import tooltip from '~/vue_shared/directives/tooltip'; +import Icon from '~/vue_shared/components/icon.vue'; +import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; -import Icon from '../../../vue_shared/components/icon.vue'; -import { __ } from '../../../locale'; -import eventHub from '../../event_hub'; export default { components: { editForm, Icon, }, + directives: { + tooltip, + }, props: { isConfidential: { required: true, @@ -33,6 +37,9 @@ export default { confidentialityIcon() { return this.isConfidential ? 'eye-slash' : 'eye'; }, + tooltipLabel() { + return this.isConfidential ? __('Confidential') : __('Not confidential'); + }, }, created() { eventHub.$on('closeConfidentialityForm', this.toggleForm); @@ -65,6 +72,10 @@ export default { <div class="sidebar-collapsed-icon" @click="toggleForm" + v-tooltip + data-container="body" + data-placement="left" + :title="tooltipLabel" > <icon :name="confidentialityIcon" diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index e4893451af3..1a5e7b67eca 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -1,15 +1,22 @@ <script> +import { __ } from '~/locale'; import Flash from '~/flash'; +import tooltip from '~/vue_shared/directives/tooltip'; +import issuableMixin from '~/vue_shared/mixins/issuable'; +import Icon from '~/vue_shared/components/icon.vue'; +import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; -import issuableMixin from '../../../vue_shared/mixins/issuable'; -import Icon from '../../../vue_shared/components/icon.vue'; -import eventHub from '../../event_hub'; export default { components: { editForm, Icon, }, + + directives: { + tooltip, + }, + mixins: [issuableMixin], props: { @@ -44,6 +51,10 @@ export default { isLockDialogOpen() { return this.mediator.store.isLockDialogOpen; }, + + tooltipLabel() { + return this.isLocked ? __('Locked') : __('Unlocked'); + }, }, created() { @@ -85,6 +96,10 @@ export default { <div class="sidebar-collapsed-icon" @click="toggleForm" + v-tooltip + data-container="body" + data-placement="left" + :title="tooltipLabel" > <icon :name="lockIcon" diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 006a6d2905d..6d95153af28 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -1,9 +1,13 @@ <script> - import { __, n__, sprintf } from '../../../locale'; - import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; - import userAvatarImage from '../../../vue_shared/components/user_avatar/user_avatar_image.vue'; + import { __, n__, sprintf } from '~/locale'; + import tooltip from '~/vue_shared/directives/tooltip'; + import loadingIcon from '~/vue_shared/components/loading_icon.vue'; + import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; export default { + directives: { + tooltip, + }, components: { loadingIcon, userAvatarImage, @@ -72,7 +76,13 @@ <template> <div> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + v-tooltip + data-container="body" + data-placement="left" + :title="participantLabel" + > <i class="fa fa-users" aria-hidden="true" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue index 3b86f1145d1..9d9ee9dea4d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue @@ -1,12 +1,17 @@ <script> - import icon from '../../../vue_shared/components/icon.vue'; - import { abbreviateTime } from '../../../lib/utils/pretty_time'; + import { __, sprintf } from '~/locale'; + import { abbreviateTime } from '~/lib/utils/pretty_time'; + import icon from '~/vue_shared/components/icon.vue'; + import tooltip from '~/vue_shared/directives/tooltip'; export default { name: 'TimeTrackingCollapsedState', components: { icon, }, + directives: { + tooltip, + }, props: { showComparisonState: { type: Boolean, @@ -79,6 +84,21 @@ return ''; }, + timeTrackedTooltipText() { + let title; + if (this.showComparisonState) { + title = __('Time remaining'); + } else if (this.showEstimateOnlyState) { + title = __('Estimated'); + } else if (this.showSpentOnlyState) { + title = __('Time spent'); + } + + return sprintf('%{title}: %{text}', ({ title, text: this.text })); + }, + tooltipText() { + return this.showNoTimeTrackingState ? __('Time tracking') : this.timeTrackedTooltipText; + }, }, methods: { abbreviateTime(timeStr) { @@ -89,7 +109,13 @@ </script> <template> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + v-tooltip + data-container="body" + data-placement="left" + :title="tooltipText" + > <icon name="timer" /> <div class="time-tracking-collapsed-summary"> <div :class="divClass"> diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 9f5d852260e..26eb4cffba3 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -27,6 +27,7 @@ function mountAssigneesComponent(mediator) { mediator, field: el.dataset.field, signedIn: el.hasAttribute('data-signed-in'), + issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request', }, }), }); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 520a0b3f424..8486019897d 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -5,6 +5,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; +import { __ } from './locale'; import ModalStore from './boards/stores/modal_store'; // TODO: remove eventHub hack after code splitting refactor @@ -182,7 +183,7 @@ function UsersSelect(currentUser, els, options = {}) { return axios.put(issueURL, data) .then(({ data }) => { - var user; + var user, tooltipTitle; $dropdown.trigger('loaded.gl.dropdown'); $loading.fadeOut(); if (data.assignee) { @@ -191,15 +192,17 @@ function UsersSelect(currentUser, els, options = {}) { username: data.assignee.username, avatar: data.assignee.avatar_url }; + tooltipTitle = _.escape(user.name); } else { user = { name: 'Unassigned', username: '', avatar: '' }; + tooltipTitle = __('Assignee'); } $value.html(assigneeTemplate(user)); - $collapsedSidebar.attr('title', _.escape(user.name)).tooltip('fixTitle'); + $collapsedSidebar.attr('title', tooltipTitle).tooltip('fixTitle'); return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); }); }; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue index 8211d425b1f..de6f8c32e74 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -1,18 +1,29 @@ <script> - export default { - name: 'ToggleSidebar', - props: { - collapsed: { - type: Boolean, - required: true, - }, +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + name: 'ToggleSidebar', + directives: { + tooltip, + }, + props: { + collapsed: { + type: Boolean, + required: true, + }, + }, + computed: { + tooltipLabel() { + return this.collapsed ? __('Expand sidebar') : __('Collapse sidebar'); }, - methods: { - toggle() { - this.$emit('toggle'); - }, + }, + methods: { + toggle() { + this.$emit('toggle'); }, - }; + }, +}; </script> <template> @@ -20,6 +31,10 @@ type="button" class="btn btn-blank gutter-toggle btn-sidebar-action" @click="toggle" + v-tooltip + data-container="body" + data-placement="left" + :title="tooltipLabel" > <i aria-label="toggle collapse" diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 37223175199..8c44ebc85ef 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -247,6 +247,7 @@ $btn-sm-side-margin: 7px; $btn-xs-side-margin: 5px; $issue-status-expired: $orange-500; $issuable-sidebar-color: $gl-text-color-secondary; +$sidebar-block-hover-color: #ebebeb; $group-path-color: #999; $namespace-kind-color: #aaa; $panel-heading-link-color: #777; @@ -373,6 +374,8 @@ $dropdown-hover-color: $blue-400; $link-active-background: rgba(0, 0, 0, 0.04); $link-hover-background: rgba(0, 0, 0, 0.06); $inactive-badge-background: rgba(0, 0, 0, 0.08); +$sidebar-toggle-height: 60px; +$sidebar-milestone-toggle-bottom-margin: 10px; /* * Buttons diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 2c0ed976301..b2dad4a358a 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -187,7 +187,12 @@ padding-left: 10px; &:hover { - color: $gray-darkest; + color: $gl-text-color; + } + + &:hover, + &:focus { + text-decoration: none; } } @@ -368,6 +373,14 @@ padding: 15px 0 0; border-bottom: 0; overflow: hidden; + + &:hover { + background-color: $sidebar-block-hover-color; + } + + &.issuable-sidebar-header { + padding-top: 0; + } } .participants { @@ -380,8 +393,17 @@ .gutter-toggle { width: 100%; + height: $sidebar-toggle-height; margin-left: 0; - padding-left: 25px; + padding-left: 0; + border-bottom: 1px solid $border-gray-dark; + } + + a.gutter-toggle { + display: flex; + justify-content: center; + flex-direction: column; + text-align: center; } .sidebar-collapsed-icon { @@ -428,10 +450,10 @@ .btn-clipboard { border: 0; + background: transparent; color: $issuable-sidebar-color; &:hover { - background: transparent; color: $gl-text-color; } } diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index 3af8d80daab..bac3b70c734 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -53,10 +53,6 @@ } .milestone-sidebar { - .gutter-toggle { - margin-bottom: 10px; - } - .milestone-progress { .title { padding-top: 5px; @@ -102,7 +98,17 @@ margin-right: 0; } + .right-sidebar-expanded & { + .gutter-toggle { + margin-bottom: $sidebar-milestone-toggle-bottom-margin; + } + } + .right-sidebar-collapsed & { + .milestone-progress { + padding-top: 0; + } + .reference { border-top: 1px solid $border-gray-normal; } diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 06c3e569c84..f39a62bccc8 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -9,6 +9,32 @@ module IssuablesHelper "right-sidebar-#{sidebar_gutter_collapsed? ? 'collapsed' : 'expanded'}" end + def sidebar_gutter_tooltip_text + sidebar_gutter_collapsed? ? _('Expand sidebar') : _('Collapse sidebar') + end + + def sidebar_assignee_tooltip_label(issuable) + if issuable.assignee + issuable.assignee.name + else + issuable.allows_multiple_assignees? ? _('Assignee(s)') : _('Assignee') + end + end + + def sidebar_due_date_tooltip_label(issuable) + if issuable.due_date + "#{_('Due date')}<br />#{due_date_remaining_days(issuable)}" + else + _('Due date') + end + end + + def due_date_remaining_days(issuable) + remaining_days_in_words = remaining_days_in_words(issuable) + + "#{issuable.due_date.to_s(:medium)} (#{remaining_days_in_words})" + end + def multi_label_name(current_labels, default_label) if current_labels && current_labels.any? title = current_labels.first.try(:title) @@ -153,10 +179,14 @@ module IssuablesHelper def issuable_labels_tooltip(labels, limit: 5) first, last = labels.partition.with_index { |_, i| i < limit } - label_names = first.collect(&:name) - label_names << "and #{last.size} more" unless last.empty? + if labels && labels.any? + label_names = first.collect(&:name) + label_names << "and #{last.size} more" unless last.empty? - label_names.join(', ') + label_names.join(', ') + else + _("Labels") + end end def issuables_state_counter_text(issuable_type, state, display_count) @@ -321,7 +351,7 @@ module IssuablesHelper def issuable_todo_button_data(issuable, todo, is_collapsed) { todo_text: "Add todo", - mark_text: "Mark done", + mark_text: "Mark todo as done", todo_icon: (is_collapsed ? icon('plus-square') : nil), mark_icon: (is_collapsed ? icon('check-square', class: 'todo-undone') : nil), issuable_id: issuable.id, diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index be8cb358de2..e8caab3e50c 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -1,4 +1,6 @@ module MilestonesHelper + include EntityDateHelper + def milestones_filter_path(opts = {}) if @project project_milestones_path(@project, opts) @@ -72,6 +74,19 @@ module MilestonesHelper end end + def milestone_progress_tooltip_text(milestone) + has_issues = milestone.total_issues_count(current_user) > 0 + + if has_issues + [ + _('Progress'), + _("%{percent}%% complete") % { percent: milestone.percent_complete(current_user) } + ].join('<br />') + else + _('Progress') + end + end + def milestone_progress_bar(milestone) options = { class: 'progress-bar progress-bar-success', @@ -95,27 +110,69 @@ module MilestonesHelper end def milestone_tooltip_title(milestone) - if milestone.due_date - [milestone.due_date.to_s(:medium), "(#{milestone_remaining_days(milestone)})"].join(' ') + if milestone + "#{milestone.title}<br />#{milestone_tooltip_due_date(milestone)}" + else + _('Milestone') end end - def milestone_remaining_days(milestone) - if milestone.expired? - content_tag(:strong, 'Past due') - elsif milestone.upcoming? - content_tag(:strong, 'Upcoming') - elsif milestone.due_date - time_ago = time_ago_in_words(milestone.due_date) - content = time_ago.gsub(/\d+/) { |match| "<strong>#{match}</strong>" } - content.slice!("about ") - content << " remaining" - content.html_safe - elsif milestone.start_date && milestone.start_date.past? - days = milestone.elapsed_days - content = content_tag(:strong, days) - content << " #{'day'.pluralize(days)} elapsed" + def milestone_time_for(date, date_type) + title = date_type == :start ? "Start date" : "End date" + + if date + time_ago = time_ago_in_words(date) + time_ago.slice!("about ") + + time_ago << if date.past? + " ago" + else + " remaining" + end + + content = [ + title, + "<br />", + date.to_s(:medium), + "(#{time_ago})" + ].join(" ") + content.html_safe + else + title + end + end + + def milestone_issues_tooltip_text(milestone) + issues = milestone.count_issues_by_state(current_user) + + return _("Issues") if issues.empty? + + content = [] + + content << n_("1 open issue", "%d open issues", issues["opened"]) % issues["opened"] if issues["opened"] + content << n_("1 closed issue", "%d closed issues", issues["closed"]) % issues["closed"] if issues["closed"] + + content.join('<br />').html_safe + end + + def milestone_merge_requests_tooltip_text(milestone) + merge_requests = milestone.merge_requests + + return _("Merge requests") if merge_requests.empty? + + content = [] + + content << n_("1 open merge request", "%d open merge requests", merge_requests.opened.count) % merge_requests.opened.count if merge_requests.opened.any? + content << n_("1 closed merge request", "%d closed merge requests", merge_requests.closed.count) % merge_requests.closed.count if merge_requests.closed.any? + content << n_("1 merged merge request", "%d merged merge requests", merge_requests.merged.count) % merge_requests.merged.count if merge_requests.merged.any? + + content.join('<br />').html_safe + end + + def milestone_tooltip_due_date(milestone) + if milestone.due_date + "#{milestone.due_date.to_s(:medium)} (#{remaining_days_in_words(milestone)})" end end diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 5130ecec472..967fd9c5eea 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -102,14 +102,14 @@ module Milestoneish Gitlab::TimeTrackingFormatter.output(total_issue_time_estimate) end - private - def count_issues_by_state(user) memoize_per_user(user, :count_issues_by_state) do issues_visible_to_user(user).reorder(nil).group(:state).count end end + private + def memoize_per_user(user, method_name) memoized_users[method_name][user&.id] ||= yield end diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb index 71d9a65fb58..464217123b4 100644 --- a/app/serializers/entity_date_helper.rb +++ b/app/serializers/entity_date_helper.rb @@ -1,5 +1,6 @@ module EntityDateHelper include ActionView::Helpers::DateHelper + include ActionView::Helpers::TagHelper def interval_in_words(diff) return 'Not started' unless diff @@ -34,4 +35,30 @@ module EntityDateHelper duration_hash end + + # Generates an HTML-formatted string for remaining dates based on start_date and due_date + # + # It returns "Past due" for expired entities + # It returns "Upcoming" for upcoming entities + # If due date is provided, it returns "# days|weeks|months remaining|ago" + # If start date is provided and elapsed, with no due date, it returns "# days elapsed" + def remaining_days_in_words(entity) + if entity.try(:expired?) + content_tag(:strong, 'Past due') + elsif entity.try(:upcoming?) + content_tag(:strong, 'Upcoming') + elsif entity.due_date + is_upcoming = (entity.due_date - Date.today).to_i > 0 + time_ago = time_ago_in_words(entity.due_date) + content = time_ago.gsub(/\d+/) { |match| "<strong>#{match}</strong>" } + content.slice!("about ") + content << " " + (is_upcoming ? _("remaining") : _("ago")) + content.html_safe + elsif entity.start_date && entity.start_date.past? + days = entity.elapsed_days + content = content_tag(:strong, days) + content << " #{'day'.pluralize(days)} elapsed" + content.html_safe + end + end end diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 0c58dd60e2c..e27f5658e87 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -26,7 +26,7 @@ - if issue.milestone %span.issuable-milestone.hidden-xs - = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 1, toggle: 'tooltip', title: issuable_milestone_tooltip_title(issue) } do + = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 1, toggle: 'tooltip', title: milestone_tooltip_due_date(issue.milestone) } do = icon('clock-o') = issue.milestone.title - if issue.due_date diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index a94267deeb2..027a9ff1416 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -23,7 +23,7 @@ - if merge_request.milestone %span.issuable-milestone.hidden-xs - = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), data: { html: 1, toggle: 'tooltip', title: issuable_milestone_tooltip_title(merge_request) } do + = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), data: { html: 1, toggle: 'tooltip', title: milestone_tooltip_due_date(merge_request.milestone) } do = icon('clock-o') = merge_request.milestone.title - if merge_request.target_project.default_branch != merge_request.target_branch diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index aa883b9b1fa..093033775a9 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -7,7 +7,7 @@ - if current_user %span.issuable-header-text.hide-collapsed.pull-left = _('Todo') - %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } + %a.gutter-toggle.pull-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left' } } = sidebar_gutter_toggle_icon - if current_user = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable @@ -19,12 +19,11 @@ .block.assignee = render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present? .block.milestone - .sidebar-collapsed-icon + .sidebar-collapsed-icon.has-tooltip{ title: milestone_tooltip_title(issuable.milestone), data: { container: 'body', html: 1, placement: 'left' } } = icon('clock-o', 'aria-hidden': 'true') %span.milestone-title - if issuable.milestone - %span.has-tooltip{ title: "#{issuable.milestone.title}<br>#{milestone_tooltip_title(issuable.milestone)}", data: { container: 'body', html: 1, placement: 'left' } } - = issuable.milestone.title + = issuable.milestone.title - else = _('None') .title.hide-collapsed @@ -34,7 +33,7 @@ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.hide-collapsed - if issuable.milestone - = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_title(issuable.milestone), data: { container: "body", html: 1 } + = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_due_date(issuable.milestone), data: { container: "body", html: 1 } - else %span.no-value = _('None') @@ -50,7 +49,7 @@ = icon('spinner spin', 'aria-hidden': 'true') - if issuable.has_attribute?(:due_date) .block.due_date - .sidebar-collapsed-icon + .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 1 }, title: sidebar_due_date_tooltip_label(issuable) } = icon('calendar', 'aria-hidden': 'true') %span.js-due-date-sidebar-value = issuable.due_date.try(:to_s, :medium) || 'None' diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 304df38a096..21006a76b28 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -4,7 +4,7 @@ = _('Assignee') = icon('spinner spin') - else - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: sidebar_assignee_tooltip_label(issuable) } - if issuable.assignee = link_to_member(@project, issuable.assignee, size: 24) - else diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml index b77e104c072..74327fb1ba8 100644 --- a/app/views/shared/issuable/_sidebar_todo.html.haml +++ b/app/views/shared/issuable/_sidebar_todo.html.haml @@ -1,11 +1,11 @@ - is_collapsed = local_assigns.fetch(:is_collapsed, false) -- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : _('Mark done') +- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : _('Mark todo as done') - todo_content = is_collapsed ? icon('plus-square') : _('Add todo') %button.issuable-todo-btn.js-issuable-todo{ type: 'button', class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'btn btn-default issuable-header-btn pull-right'), - title: (todo.nil? ? _('Add todo') : _('Mark done')), - 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark done')), + title: (todo.nil? ? _('Add todo') : _('Mark todo as done')), + 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark todo as done')), data: issuable_todo_button_data(issuable, todo, is_collapsed) } %span.issuable-todo-inner.js-issuable-todo-inner< - if todo diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml index bf8613b0f0d..d7740eddcca 100644 --- a/app/views/shared/issuable/form/_merge_request_assignee.html.haml +++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml @@ -1,6 +1,6 @@ - merge_request = issuable .block.assignee - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (merge_request.assignee.name if merge_request.assignee) } + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: sidebar_assignee_tooltip_label(issuable) } - if merge_request.assignee = link_to_member(@project, merge_request.assignee, size: 24) - else diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index ee134480705..8e9a1b56bb8 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -4,12 +4,8 @@ %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } .issuable-sidebar.milestone-sidebar .block.milestone-progress.issuable-sidebar-header - %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } + %a.gutter-toggle.pull-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left' } } = sidebar_gutter_toggle_icon - - .sidebar-collapsed-icon - %span== #{milestone.percent_complete(current_user)}% - = milestone_progress_bar(milestone) .title.hide-collapsed %strong.bold== #{milestone.percent_complete(current_user)}% %span.hide-collapsed @@ -17,6 +13,11 @@ .value.hide-collapsed = milestone_progress_bar(milestone) + .block.milestone-progress.hide-expanded + .sidebar-collapsed-icon.has-tooltip{ title: milestone_progress_tooltip_text(milestone), data: { container: 'body', html: 1, placement: 'left' } } + %span== #{milestone.percent_complete(current_user)}% + = milestone_progress_bar(milestone) + .block.start_date.hide-collapsed .title Start date @@ -35,19 +36,25 @@ %span.collapsed-milestone-date - if milestone.start_date && milestone.due_date - if milestone.start_date.year == milestone.due_date.year - .milestone-date= milestone.start_date.strftime('%b %-d') + .milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } } + = milestone.start_date.strftime('%b %-d') - else - .milestone-date= milestone.start_date.strftime('%b %-d %Y') + .milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } } + = milestone.start_date.strftime('%b %-d %Y') .date-separator - - .due_date= milestone.due_date.strftime('%b %-d %Y') + .due_date.has-tooltip{ title: milestone_time_for(milestone.due_date, :end), data: { container: 'body', html: 1, placement: 'left' } } + = milestone.due_date.strftime('%b %-d %Y') - elsif milestone.start_date From - .milestone-date= milestone.start_date.strftime('%b %-d %Y') + .milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } } + = milestone.start_date.strftime('%b %-d %Y') - elsif milestone.due_date Until - .milestone-date= milestone.due_date.strftime('%b %-d %Y') + .milestone-date.has-tooltip{ title: milestone_time_for(milestone.due_date, :end), data: { container: 'body', html: 1, placement: 'left' } } + = milestone.due_date.strftime('%b %-d %Y') - else - None + .has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } } + None .title.hide-collapsed Due date - if @project && can?(current_user, :admin_milestone, @project) @@ -58,14 +65,14 @@ %span.bold= milestone.due_date.to_s(:medium) - else %span.no-value No due date - - remaining_days = milestone_remaining_days(milestone) + - remaining_days = remaining_days_in_words(milestone) - if remaining_days.present? = surround '(', ')' do %span.remaining-days= remaining_days - if !project || can?(current_user, :read_issue, project) .block.issues - .sidebar-collapsed-icon + .sidebar-collapsed-icon.has-tooltip{ title: milestone_issues_tooltip_text(milestone), data: { container: 'body', html: 1, placement: 'left' } } %strong = custom_icon('issues') %span= milestone.issues_visible_to_user(current_user).count @@ -93,7 +100,7 @@ = icon('spinner spin') .block.merge-requests - .sidebar-collapsed-icon + .sidebar-collapsed-icon.has-tooltip{ title: milestone_merge_requests_tooltip_text(milestone), data: { container: 'body', html: 1, placement: 'left' } } %strong = custom_icon('mr_bold') %span= milestone.merge_requests.count |