diff options
Diffstat (limited to 'app/assets')
10 files changed, 403 insertions, 160 deletions
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index d956777a86b..2315a48a306 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -1,18 +1,24 @@ <script> -import $ from 'jquery'; +import { GlTooltipDirective } from '@gitlab-org/gitlab-ui'; +import { sprintf, __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import eventHub from '../eventhub'; -import tooltip from '../../vue_shared/directives/tooltip'; +import IssueDueDate from './issue_due_date.vue'; +import IssueTimeEstimate from './issue_time_estimate.vue'; import boardsStore from '../stores/boards_store'; export default { components: { - UserAvatarLink, Icon, + UserAvatarLink, + TooltipOnTruncate, + IssueDueDate, + IssueTimeEstimate, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { issue: { @@ -45,8 +51,8 @@ export default { }, data() { return { - limitBeforeCounter: 3, - maxRender: 4, + limitBeforeCounter: 2, + maxRender: 3, maxCounter: 99, }; }, @@ -55,7 +61,9 @@ export default { return this.issue.assignees.length - this.limitBeforeCounter; }, assigneeCounterTooltip() { - return `${this.assigneeCounterLabel} more`; + const { numberOverLimit, maxCounter } = this; + const count = numberOverLimit > maxCounter ? maxCounter : numberOverLimit; + return sprintf(__('%{count} more assignees'), { count }); }, assigneeCounterLabel() { if (this.numberOverLimit > this.maxCounter) { @@ -80,6 +88,10 @@ export default { showLabelFooter() { return this.issue.labels.find(l => this.showLabel(l)) !== undefined; }, + issueReferencePath() { + const { referencePath, groupId } = this.issue; + return !groupId ? referencePath.split('#')[0] : null; + }, }, methods: { isIndexLessThanlimit(index) { @@ -96,11 +108,9 @@ export default { return index < this.limitBeforeCounter; }, assigneeUrl(assignee) { + if (!assignee) return ''; return `${this.rootPath}${assignee.username}`; }, - assigneeUrlTitle(assignee) { - return `Assigned to ${assignee.name}`; - }, avatarUrlTitle(assignee) { return `Avatar for ${assignee.name}`; }, @@ -108,19 +118,29 @@ export default { if (!label.id) return false; return true; }, - filterByLabel(label, e) { + filterByLabel(label) { + if (!this.updateFilters) return; + const labelTitle = encodeURIComponent(label.title); + const filter = `label_name[]=${labelTitle}`; + + this.applyFilter(filter); + }, + filterByWeight(weight) { if (!this.updateFilters) return; + const issueWeight = encodeURIComponent(weight); + const filter = `weight=${issueWeight}`; + + this.applyFilter(filter); + }, + applyFilter(filter) { const filterPath = boardsStore.filter.path.split('&'); - const labelTitle = encodeURIComponent(label.title); - const param = `label_name[]=${labelTitle}`; - const labelIndex = filterPath.indexOf(param); - $(e.currentTarget).tooltip('hide'); + const filterIndex = filterPath.indexOf(filter); - if (labelIndex === -1) { - filterPath.push(param); + if (filterIndex === -1) { + filterPath.push(filter); } else { - filterPath.splice(labelIndex, 1); + filterPath.splice(filterIndex, 1); } boardsStore.filter.path = filterPath.join('&'); @@ -141,24 +161,62 @@ export default { <template> <div> <div class="board-card-header"> - <h4 class="board-card-title"> + <h4 class="board-card-title append-bottom-0 prepend-top-0"> <icon v-if="issue.confidential" + v-gl-tooltip name="eye-slash" - class="confidential-icon" - /> - <a + :title="__('Confidential')" + class="confidential-icon append-right-4" + :aria-label="__('Confidential')" + /><a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>{{ issue.title }}</a> + </h4> + </div> + <div + v-if="showLabelFooter" + class="board-card-labels prepend-top-4 d-flex flex-wrap" + > + <button + v-for="label in issue.labels" + v-if="showLabel(label)" + :key="label.id" + v-gl-tooltip + :style="labelStyle(label)" + :title="label.description" + class="badge color-label append-right-4 prepend-top-4" + type="button" + @click="filterByLabel(label)" + > + {{ label.title }} + </button> + </div> + <div class="board-card-footer d-flex justify-content-between align-items-end"> + <div class="d-flex align-items-start flex-wrap-reverse board-card-number-container js-board-card-number-container"> <span - v-if="issueId" - class="board-card-number append-right-5" + v-if="issue.referencePath" + class="board-card-number d-flex append-right-8 prepend-top-8" > - {{ issue.referencePath }} + <tooltip-on-truncate + v-if="issueReferencePath" + :title="issueReferencePath" + placement="bottom" + class="board-issue-path block-truncated bold" + >{{ issueReferencePath }}</tooltip-on-truncate>#{{ issue.iid }} </span> - </h4> + <span class="board-info-items prepend-top-8 d-inline-block"> + <issue-due-date + v-if="issue.dueDate" + :date="issue.dueDate" + /><issue-time-estimate + v-if="issue.timeEstimate" + :estimate="issue.timeEstimate" + /> + </span> + </div> <div class="board-card-assignee"> <user-avatar-link v-for="(assignee, index) in issue.assignees" @@ -167,38 +225,26 @@ export default { :link-href="assigneeUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" :img-src="assignee.avatar" - :tooltip-text="assigneeUrlTitle(assignee)" + :img-size="24" class="js-no-trigger" tooltip-placement="bottom" - /> + > + <span class="js-assignee-tooltip"> + <span class="bold d-block">Assignee</span> + {{ assignee.name }} + <span class="text-white-50">@{{ assignee.username }}</span> + </span> + </user-avatar-link> <span v-if="shouldRenderCounter" - v-tooltip + v-gl-tooltip :title="assigneeCounterTooltip" class="avatar-counter" + data-placement="bottom" > {{ assigneeCounterLabel }} </span> </div> </div> - <div - v-if="showLabelFooter" - class="board-card-footer" - > - <button - v-for="label in issue.labels" - v-if="showLabel(label)" - :key="label.id" - v-tooltip - :style="labelStyle(label)" - :title="label.description" - class="badge color-label" - type="button" - data-container="body" - @click="filterByLabel(label, $event)" - > - {{ label.title }} - </button> - </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue new file mode 100644 index 00000000000..025ef7e9743 --- /dev/null +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -0,0 +1,90 @@ +<script> +import dateFormat from 'dateformat'; +import { GlTooltip } from '@gitlab-org/gitlab-ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __ } from '~/locale'; +import { getDayDifference, getTimeago, dateInWords } from '~/lib/utils/datetime_utility'; + +export default { + components: { + Icon, + GlTooltip, + }, + props: { + date: { + type: String, + required: true, + }, + }, + computed: { + title() { + const timeago = getTimeago(); + const { timeDifference, standardDateFormat } = this; + const formatedDate = standardDateFormat; + + if (timeDifference >= -1 && timeDifference < 7) { + return `${timeago.format(this.issueDueDate)} (${formatedDate})`; + } + + return timeago.format(this.issueDueDate); + }, + body() { + const { timeDifference, issueDueDate, standardDateFormat } = this; + + if (timeDifference === 0) { + return __('Today'); + } else if (timeDifference === 1) { + return __('Tomorrow'); + } else if (timeDifference === -1) { + return __('Yesterday'); + } else if (timeDifference > 0 && timeDifference < 7) { + return dateFormat(issueDueDate, 'dddd', true); + } + + return standardDateFormat; + }, + issueDueDate() { + return new Date(this.date); + }, + timeDifference() { + const today = new Date(); + return getDayDifference(today, this.issueDueDate); + }, + isPastDue() { + if (this.timeDifference >= 0) return false; + return true; + }, + standardDateFormat() { + const today = new Date(); + const isDueInCurrentYear = today.getFullYear() === this.issueDueDate.getFullYear(); + + return dateInWords(this.issueDueDate, true, isDueInCurrentYear); + }, + }, +}; +</script> + +<template> + <span> + <span + ref="issueDueDate" + class="board-card-info card-number" + > + <icon + :class="{'text-danger': isPastDue, 'board-card-info-icon': true}" + name="calendar" + /><time + :class="{'text-danger': isPastDue}" + datetime="date" + class="board-card-info-text">{{ body }}</time> + </span> + <gl-tooltip + :target="() => $refs.issueDueDate" + placement="bottom" + > + <span class="bold">{{ __('Due date') }}</span> + <br /> + <span :class="{'text-danger-muted': isPastDue}">{{ title }}</span> + </gl-tooltip> + </span> +</template> diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue new file mode 100644 index 00000000000..efc7daf7812 --- /dev/null +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -0,0 +1,48 @@ +<script> +import { GlTooltip } from '@gitlab-org/gitlab-ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; + +export default { + components: { + Icon, + GlTooltip, + }, + props: { + estimate: { + type: Number, + required: true, + }, + }, + computed: { + title() { + return stringifyTime(parseSeconds(this.estimate), true); + }, + timeEstimate() { + return stringifyTime(parseSeconds(this.estimate)); + }, + }, +}; +</script> + +<template> + <span> + <span + ref="issueTimeEstimate" + class="board-card-info card-number" + > + <icon + name="hourglass" + css-classes="board-card-info-icon" + /><time class="board-card-info-text">{{ timeEstimate }}</time> + </span> + <gl-tooltip + :target="() => $refs.issueTimeEstimate" + placement="bottom" + class="js-issue-time-estimate" + > + <span class="bold d-block">{{ __('Time estimate') }}</span> + {{ title }} + </gl-tooltip> + </span> +</template> diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 669630edcab..5e0f0b07247 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -30,6 +30,7 @@ class ListIssue { this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; this.milestone_id = obj.milestone_id; this.project_id = obj.project_id; + this.timeEstimate = obj.time_estimate; this.assignableLabelsEndpoint = obj.assignable_labels_endpoint; if (obj.project) { diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 46740308f17..e69e56c85be 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -454,12 +454,20 @@ export const parseSeconds = (seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) /** * Accepts a timeObject (see parseSeconds) and returns a condensed string representation of it * (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included. + * If the 'fullNameFormat' param is passed it returns a non condensed string eg '1 week 3 days' */ -export const stringifyTime = timeObject => { +export const stringifyTime = (timeObject, fullNameFormat = false) => { const reducedTime = _.reduce( timeObject, (memo, unitValue, unitName) => { const isNonZero = !!unitValue; + + if (fullNameFormat && isNonZero) { + // Remove traling 's' if unit value is singular + const formatedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, ''); + return `${memo} ${unitValue} ${formatedUnitName}`; + } + return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo; }, '', diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index 7737b9f2697..4cfb1ded0a9 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -15,14 +15,14 @@ */ +import { GlTooltip } from '@gitlab-org/gitlab-ui'; import defaultAvatarUrl from 'images/no_avatar.png'; import { placeholderImage } from '../../../lazy_loader'; -import tooltip from '../../directives/tooltip'; export default { name: 'UserAvatarImage', - directives: { - tooltip, + components: { + GlTooltip, }, props: { lazy: { @@ -73,9 +73,6 @@ export default { resultantSrcAttribute() { return this.lazy ? placeholderImage : this.sanitizedSource; }, - tooltipContainer() { - return this.tooltipText ? 'body' : null; - }, avatarSizeClass() { return `s${this.size}`; }, @@ -84,22 +81,30 @@ export default { </script> <template> - <img - v-tooltip - :class="{ - lazy: lazy, - [avatarSizeClass]: true, - [cssClasses]: true - }" - :src="resultantSrcAttribute" - :width="size" - :height="size" - :alt="imgAlt" - :data-src="sanitizedSource" - :data-container="tooltipContainer" - :data-placement="tooltipPlacement" - :title="tooltipText" - class="avatar" - data-boundary="window" - /> + <span> + <img + ref="userAvatarImage" + :class="{ + lazy: lazy, + [avatarSizeClass]: true, + [cssClasses]: true + }" + :src="resultantSrcAttribute" + :width="size" + :height="size" + :alt="imgAlt" + :data-src="sanitizedSource" + class="avatar" + /> + <gl-tooltip + :target="() => $refs.userAvatarImage" + :placement="tooltipPlacement" + boundary="window" + class="js-user-avatar-image-toolip" + > + <slot> + {{ tooltipText }} + </slot> + </gl-tooltip> + </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index dd6f96e2609..351a639c6e8 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -17,9 +17,8 @@ */ -import { GlLink } from '@gitlab-org/gitlab-ui'; +import { GlLink, GlTooltipDirective } from '@gitlab-org/gitlab-ui'; import userAvatarImage from './user_avatar_image.vue'; -import tooltip from '../../directives/tooltip'; export default { name: 'UserAvatarLink', @@ -28,7 +27,7 @@ export default { userAvatarImage, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { linkHref: { @@ -94,11 +93,14 @@ export default { :size="imgSize" :tooltip-text="avatarTooltipText" :tooltip-placement="tooltipPlacement" - /><span + > + <slot></slot> + </user-avatar-image><span v-if="shouldShowUsername" - v-tooltip + v-gl-tooltip :title="tooltipText" :tooltip-placement="tooltipPlacement" + class="js-user-avatar-link-username" >{{ username }}</span><slot name="avatar-badge"></slot> </gl-link> </template> diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index fa753b13e5f..626c8f92d1d 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -33,6 +33,11 @@ color: $brand-danger; } +.text-danger-muted, +.text-danger-muted:hover { + color: $red-300; +} + .text-warning, .text-warning:hover { color: $brand-warning; @@ -345,6 +350,7 @@ img.emoji { /** COMMON CLASSES **/ .prepend-top-0 { margin-top: 0; } .prepend-top-2 { margin-top: 2px; } +.prepend-top-4 { margin-top: $gl-padding-4; } .prepend-top-5 { margin-top: 5px; } .prepend-top-8 { margin-top: $grid-size; } .prepend-top-10 { margin-top: 10px; } @@ -365,6 +371,7 @@ img.emoji { .append-right-default { margin-right: $gl-padding; } .append-right-20 { margin-right: 20px; } .append-bottom-0 { margin-bottom: 0; } +.append-bottom-4 { margin-bottom: $gl-padding-4; } .append-bottom-5 { margin-bottom: 5px; } .append-bottom-8 { margin-bottom: $grid-size; } .append-bottom-10 { margin-bottom: 10px; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index bfcac3f1c3f..016fee862e8 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -195,6 +195,7 @@ $well-light-text-color: #5b6169; * Text */ $gl-font-size: 14px; +$gl-font-size-xs: 11px; $gl-font-size-small: 12px; $gl-font-weight-normal: 400; $gl-font-weight-bold: 600; @@ -440,7 +441,7 @@ $ci-skipped-color: #888; * Boards */ $issue-boards-font-size: 14px; -$issue-boards-card-shadow: rgba(186, 186, 186, 0.5); +$issue-boards-card-shadow: rgba(0, 0, 0, 0.1); /* The following heights are used in boards.scss and are used for calculation of the board height. They probably should be derived in a smarter way. diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 54fbd40cece..c6074eb9df4 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -90,20 +90,14 @@ } .with-performance-bar & { - height: calc( - 100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height} - ); + height: calc(100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height}); @include media-breakpoint-only(sm) { - height: calc( - 100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height} - ); + height: calc(100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height}); } @include media-breakpoint-up(md) { - height: calc( - 100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height} - ); + height: calc(100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height}); } } } @@ -271,7 +265,7 @@ height: 100%; width: 100%; margin-bottom: 0; - padding: 5px; + padding: $gl-padding-4; list-style: none; overflow-y: auto; overflow-x: hidden; @@ -284,14 +278,16 @@ .board-card { position: relative; - padding: 11px 10px 11px $gl-padding; + padding: $gl-padding; background: $white-light; border-radius: $border-radius-default; + border: 1px solid $theme-gray-200; box-shadow: 0 1px 2px $issue-boards-card-shadow; list-style: none; + line-height: $gl-padding; &:not(:last-child) { - margin-bottom: 5px; + margin-bottom: $gl-padding-8; } &.is-active, @@ -302,113 +298,120 @@ .badge { border: 0; outline: 0; + + &:hover { + text-decoration: underline; + } + + @include media-breakpoint-down(lg) { + font-size: $gl-font-size-xs; + padding-left: $gl-padding-4; + padding-right: $gl-padding-4; + font-weight: $gl-font-weight-bold; + } + } + + svg { + vertical-align: top; } .confidential-icon { - vertical-align: text-top; - margin-right: 5px; + color: $orange-600; + cursor: help; + } + + @include media-breakpoint-down(md) { + padding: $gl-padding-8; } } .board-card-title { @include overflow-break-word(); - margin: 0 30px 0 0; font-size: 1em; - line-height: inherit; a { color: $gl-text-color; - margin-right: 2px; + } + + @include media-breakpoint-down(md) { + font-size: $label-font-size; } } .board-card-header { display: flex; - min-height: 20px; - - .board-card-assignee { - display: flex; - justify-content: flex-end; - position: absolute; - right: 15px; - height: 20px; - width: 20px; +} - .avatar-counter { - display: none; - vertical-align: middle; - min-width: 20px; - line-height: 19px; - height: 20px; - padding-left: 2px; - padding-right: 2px; - border-radius: 2em; - } +.board-card-assignee { + display: flex; + margin-top: -$gl-padding-4; + margin-bottom: -$gl-padding-4; + + .avatar-counter { + vertical-align: middle; + line-height: $gl-padding-24; + min-width: $gl-padding-24; + height: $gl-padding-24; + border-radius: $gl-padding-24; + background-color: $gl-text-color-tertiary; + font-size: $gl-font-size-xs; + cursor: help; + font-weight: $gl-font-weight-bold; + margin-left: -$gl-padding-4; + border: 0; + padding: 0 $gl-padding-4; - img { - vertical-align: top; + @include media-breakpoint-down(md) { + min-width: auto; + height: $gl-padding; + border-radius: $gl-padding; + line-height: $gl-padding; } + } - a { - position: relative; - margin-left: -15px; - } + img { + vertical-align: top; + } - a:nth-child(1) { - z-index: 3; - } + .user-avatar-link:not(:only-child) { + margin-left: -$gl-padding-4; - a:nth-child(2) { + &:nth-of-type(1) { z-index: 2; } - a:nth-child(3) { + &:nth-of-type(2) { z-index: 1; } + } - a:nth-child(4) { - display: none; - } - - &:hover { - .avatar-counter { - display: inline-block; - } - - a { - position: static; - background-color: $white-light; - transition: background-color 0s; - margin-left: auto; - - &:nth-child(4) { - display: block; - } + .avatar { + margin: 0; - &:first-child:not(:only-child) { - box-shadow: -10px 0 10px 1px $white-light; - } - } + @include media-breakpoint-down(md) { + width: $gl-padding; + height: $gl-padding; } } - .avatar { - margin: 0; + @include media-breakpoint-down(md) { + margin-top: 0; + margin-bottom: 0; } } -.board-card-footer { - margin: 0 0 5px; +.board-card-number { + font-size: $gl-font-size-xs; + color: $gl-text-color-secondary; + overflow: hidden; - .badge { - margin-top: 5px; - margin-right: 6px; + @include media-breakpoint-up(md) { + font-size: $label-font-size; } } -.board-card-number { - font-size: 12px; - color: $gl-text-color-secondary; +.board-card-number-container { + overflow: hidden; } .issue-boards-search { @@ -474,8 +477,7 @@ .right-sidebar.right-sidebar-expanded { &.boards-sidebar-slide-enter-active, &.boards-sidebar-slide-leave-active { - transition: width $sidebar-transition-duration, - padding $sidebar-transition-duration; + transition: width $sidebar-transition-duration, padding $sidebar-transition-duration; } &.boards-sidebar-slide-enter, @@ -650,3 +652,36 @@ } } } + +.board-card-info { + color: $gl-text-color-secondary; + white-space: nowrap; + margin-right: $gl-padding-8; + + &:not(.board-card-weight) { + cursor: help; + } + + &.board-card-weight { + color: $gl-text-color; + cursor: pointer; + + &:hover { + color: initial; + text-decoration: underline; + } + } + + .board-card-info-icon { + color: $theme-gray-600; + margin-right: $gl-padding-4; + } + + @include media-breakpoint-down(md) { + font-size: $label-font-size; + } +} + +.board-issue-path.js-show-tooltip { + cursor: help; +} |