Commit 24d3486f authored by Phil Hughes's avatar Phil Hughes

Merge branch '47008-issue-board-card-design' into 'master'

Resolve "Issue board card design"

Closes #47008

See merge request gitlab-org/gitlab-ce!21229
parents 06e8cf58 baa37edd
<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>
<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>
<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>
......@@ -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) {
......
......@@ -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;
},
'',
......
......@@ -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,8 +81,9 @@ export default {
</script>
<template>
<span>
<img
v-tooltip
ref="userAvatarImage"
:class="{
lazy: lazy,
[avatarSizeClass]: true,
......@@ -96,10 +94,17 @@ export default {
:height="size"
:alt="imgAlt"
:data-src="sanitizedSource"
:data-container="tooltipContainer"
:data-placement="tooltipPlacement"
:title="tooltipText"
class="avatar"
data-boundary="window"
/>
<gl-tooltip
:target="() => $refs.userAvatarImage"
:placement="tooltipPlacement"
boundary="window"
class="js-user-avatar-image-toolip"
>
<slot>
{{ tooltipText }}
</slot>
</gl-tooltip>
</span>
</template>
......@@ -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>
......@@ -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; }
......
......@@ -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.
......
......@@ -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 {
.board-card-assignee {
display: flex;
justify-content: flex-end;
position: absolute;
right: 15px;
height: 20px;
width: 20px;
margin-top: -$gl-padding-4;
margin-bottom: -$gl-padding-4;
.avatar-counter {
display: none;
vertical-align: middle;
min-width: 20px;
line-height: 19px;
height: 20px;
padding-left: 2px;
padding-right: 2px;
border-radius: 2em;
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;
@include media-breakpoint-down(md) {
min-width: auto;
height: $gl-padding;
border-radius: $gl-padding;
line-height: $gl-padding;
}
}
img {
vertical-align: top;
}
a {
position: relative;
margin-left: -15px;
}
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;
}
......@@ -12,6 +12,7 @@ class IssueBoardEntity < Grape::Entity
expose :project_id
expose :relative_position
expose :weight, if: -> (*) { respond_to?(:weight) }
expose :time_estimate
expose :project do |issue|
API::Entities::Project.represent issue.project, only: [:id, :path]
......
---
title: Issue board card design
merge_request: 21229
author:
type: changed
......@@ -103,6 +103,9 @@ msgstr ""
msgid "%{counter_storage} (%{counter_repositories} repositories, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS)"
msgstr ""
msgid "%{count} more assignees"
msgstr ""
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] ""
......@@ -6371,6 +6374,9 @@ msgstr ""
msgid "Time between merge request creation and merge/close"
msgstr ""
msgid "Time estimate"
msgstr ""
msgid "Time remaining"
msgstr ""
......@@ -6585,6 +6591,9 @@ msgstr ""
msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
msgstr ""
msgid "Today"
msgstr ""
msgid "Todo"
msgstr ""
......@@ -6618,6 +6627,9 @@ msgstr ""
msgid "Token"
msgstr ""
msgid "Tomorrow"
msgstr ""
msgid "Too many changes to show."
msgstr ""
......@@ -7086,6 +7098,9 @@ msgstr ""
msgid "Yes, let me map Google Code users to full names or GitLab users."
msgstr ""
msgid "Yesterday"
msgstr ""
msgid "You are an admin, which means granting access to <strong>%{client_name}</strong> will allow them to interact with GitLab as an admin as well. Proceed with caution."
msgstr ""
......
......@@ -160,7 +160,7 @@ describe 'Issue Boards add issue modal', :js do
it 'changes button text with plural' do
page.within('.add-issues-modal') do
all('.board-card .board-card-number').each do |el|
all('.board-card .js-board-card-number-container').each do |el|
el.click
end
......
......@@ -78,7 +78,7 @@ describe 'Issue Boards', :js do
end
it 'moves from bottom to top' do
drag(from_index: 2, to_index: 0)
drag(from_index: 2, to_index: 0, duration: 1020)
wait_for_requests
......@@ -130,7 +130,7 @@ describe 'Issue Boards', :js do
end
it 'moves to bottom of another list' do
drag(list_from_index: 1, list_to_index: 2, to_index: 2)
drag(list_from_index: 1, list_to_index: 2, to_index: 2, duration: 1020)
wait_for_requests
......
......@@ -89,16 +89,17 @@ describe 'Merge request > User sees avatars on diff notes', :js do
page.within find_line(position.line_code(project.repository)) do
find('.diff-notes-collapse').send_keys(:return)
expect(page).to have_selector('img.js-diff-comment-avatar', count: 1)
expect(page).to have_selector('.js-diff-comment-avatar img', count: 1)
end
end
it 'shows comment on note avatar' do
page.within find_line(position.line_code(project.repository)) do
find('.diff-notes-collapse').send_keys(:return)
expect(first('img.js-diff-comment-avatar')["data-original-title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}")
first('.js-diff-comment-avatar img').hover
end
expect(page).to have_content "#{note.author.name}: #{note.note.truncate(17)}"
end
it 'toggles comments when clicking avatar' do
......@@ -109,7 +110,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do
expect(page).not_to have_selector('.notes_holder')
page.within find_line(position.line_code(project.repository)) do
first('img.js-diff-comment-avatar').click
first('.js-diff-comment-avatar img').click
end
expect(page).to have_selector('.notes_holder')
......@@ -125,7 +126,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do
wait_for_requests
page.within find_line(position.line_code(project.repository)) do
expect(page).not_to have_selector('img.js-diff-comment-avatar')
expect(page).not_to have_selector('.js-diff-comment-avatar img')
end
end
......@@ -143,7 +144,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do
page.within find_line(position.line_code(project.repository)) do
find('.diff-notes-collapse').send_keys(:return)
expect(page).to have_selector('img.js-diff-comment-avatar', count: 2)
expect(page).to have_selector('.js-diff-comment-avatar img', count: 2)
end
end
......@@ -162,7 +163,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do
page.within find_line(position.line_code(project.repository)) do
find('.diff-notes-collapse').send_keys(:return)
expect(page).to have_selector('img.js-diff-comment-avatar', count: 3)
expect(page).to have_selector('.js-diff-comment-avatar img', count: 3)
expect(find('.diff-comments-more-count')).to have_content '+1'
end
end
......
......@@ -8,6 +8,7 @@
"due_date": { "type": "date" },
"project_id": { "type": "integer" },
"relative_position": { "type": ["integer", "null"] },
"time_estimate": { "type": "integer" },
"weight": { "type": "integer" },
"project": {
"type": "object",
......
......@@ -13,6 +13,7 @@
"confidential": { "type": "boolean" },
"due_date": { "type": ["date", "null"] },
"relative_position": { "type": "integer" },
"time_estimate": { "type": "integer" },
"issue_sidebar_endpoint": { "type": "string" },
"toggle_subscription_endpoint": { "type": "string" },
"assignable_labels_endpoint": { "type": "string" },
......
import Vue from 'vue';
import dateFormat from 'dateformat';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
describe('Issue Due Date component', () => {
let vm;
let date;
const Component = Vue.extend(IssueDueDate);
const createComponent = (dueDate = new Date()) =>
mountComponent(Component, { date: dateFormat(dueDate, 'yyyy-mm-dd', true) });
beforeEach(() => {
date = new Date();
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
it('should render "Today" if the due date is today', () => {
const timeContainer = vm.$el.querySelector('time');
expect(timeContainer.textContent.trim()).toEqual('Today');
});
it('should render "Yesterday" if the due date is yesterday', () => {
date.setDate(date.getDate() - 1);
vm = createComponent(date);
expect(vm.$el.querySelector('time').textContent.trim()).toEqual('Yesterday');
});
it('should render "Tomorrow" if the due date is one day from now', () => {
date.setDate(date.getDate() + 1);
vm = createComponent(date);
expect(vm.$el.querySelector('time').textContent.trim()).toEqual('Tomorrow');
});
it('should render day of the week if due date is one week away', () => {
date.setDate(date.getDate() + 5);
vm = createComponent(date);
expect(vm.$el.querySelector('time').textContent.trim()).toEqual(dateFormat(date, 'dddd', true));
});
it('should render month and day for other dates', () => {
date.setDate(date.getDate() + 17);
vm = createComponent(date);
expect(vm.$el.querySelector('time').textContent.trim()).toEqual(
dateFormat(date, 'mmm d', true),
);
});
it('should contain the correct `.text-danger` css class for overdue issue', () => {
date.setDate(date.getDate() - 17);
vm = createComponent(date);
expect(vm.$el.querySelector('time').classList.contains('text-danger')).toEqual(true);
});
});
import Vue from 'vue';
import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
describe('Issue Tine Estimate component', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(IssueTimeEstimate);
vm = mountComponent(Component, {
estimate: 374460,
});
});
afterEach(() => {
vm.$destroy();
});
it('renders the correct time estimate', () => {
expect(vm.$el.querySelector('time').textContent.trim()).toEqual('2w 3d 1m');
});
it('renders expanded time estimate in tooltip', () => {
expect(vm.$el.querySelector('.js-issue-time-estimate').textContent).toContain(
'2 weeks 3 days 1 minute',
);
});
it('prevents tooltip xss', done => {
const alertSpy = spyOn(window, 'alert');
vm.estimate = 'Foo <script>alert("XSS")</script>';
vm.$nextTick(() => {
expect(alertSpy).not.toHaveBeenCalled();
expect(vm.$el.querySelector('time').textContent.trim()).toEqual('0m');
expect(vm.$el.querySelector('.js-issue-time-estimate').textContent).toContain('0m');
done();
});
});
});
......@@ -117,11 +117,9 @@ describe('Issue card component', () => {
});
it('sets title', () => {
expect(
component.$el
.querySelector('.board-card-assignee img')
.getAttribute('data-original-title'),
).toContain(`Assigned to ${user.name}`);
expect(component.$el.querySelector('.js-assignee-tooltip').textContent).toContain(
`${user.name}`,
);
});
it('sets users path', () => {
......@@ -154,7 +152,7 @@ describe('Issue card component', () => {
it('displays defaults avatar if users avatar is null', () => {
expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull();
expect(component.$el.querySelector('.board-card-assignee img').getAttribute('src')).toBe(
'default_avatar?width=20',
'default_avatar?width=24',
);
});
});
......@@ -163,7 +161,6 @@ describe('Issue card component', () => {
describe('multiple assignees', () => {
beforeEach(done => {
component.issue.assignees = [
user,
new ListAssignee({
id: 2,
name: 'user2',
......@@ -187,11 +184,11 @@ describe('Issue card component', () => {
Vue.nextTick(() => done());
});
it('renders all four assignees', () => {
expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(4);
it('renders all three assignees', () => {
expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3);
});
describe('more than four assignees', () => {
describe('more than three assignees', () => {
beforeEach(done => {
component.issue.assignees.push(
new ListAssignee({
......@@ -207,12 +204,12 @@ describe('Issue card component', () => {
it('renders more avatar counter', () => {
expect(
component.$el.querySelector('.board-card-assignee .avatar-counter').innerText,
component.$el.querySelector('.board-card-assignee .avatar-counter').innerText.trim(),
).toEqual('+2');
});
it('renders three assignees', () => {
expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3);
it('renders two assignees', () => {
expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(2);
});
it('renders 99+ avatar counter', done => {
......@@ -228,7 +225,7 @@ describe('Issue card component', () => {
Vue.nextTick(() => {
expect(
component.$el.querySelector('.board-card-assignee .avatar-counter').innerText,
component.$el.querySelector('.board-card-assignee .avatar-counter').innerText.trim(),
).toEqual('99+');
done();
});
......
......@@ -102,7 +102,7 @@ describe('Job App ', () => {
.querySelector('.header-main-content')
.textContent.replace(/\s+/g, ' ')
.trim(),
).toEqual('passed Job #4757 triggered 1 year ago by Root');
).toContain('passed Job #4757 triggered 1 year ago by Root');
done();
}, 0);
});
......@@ -128,7 +128,7 @@ describe('Job App ', () => {
.querySelector('.header-main-content')
.textContent.replace(/\s+/g, ' ')
.trim(),
).toEqual('passed Job #4757 created 3 weeks ago by Root');
).toContain('passed Job #4757 created 3 weeks ago by Root');
done();
}, 0);
});
......
......@@ -336,6 +336,12 @@ describe('prettyTime methods', () => {
expect(timeString).toBe('0m');
});
it('should return non-condensed representation of time object', () => {
const timeObject = { weeks: 1, days: 0, hours: 1, minutes: 0 };
expect(datetimeUtility.stringifyTime(timeObject, true)).toEqual('1 week 1 hour');
});
});
describe('abbreviateTime', () => {
......
......@@ -51,7 +51,7 @@ describe('Pipeline details header', () => {
.querySelector('.header-main-content')
.textContent.replace(/\s+/g, ' ')
.trim(),
).toEqual('failed Pipeline #123 triggered 3 weeks ago by Foo');
).toContain('failed Pipeline #123 triggered 3 weeks ago by Foo');
});
describe('action buttons', () => {
......
......@@ -63,12 +63,15 @@ describe('Pipeline Url Component', () => {
}).$mount();
const image = component.$el.querySelector('.js-pipeline-url-user img');
const tooltip = component.$el.querySelector(
'.js-pipeline-url-user .js-user-avatar-image-toolip',
);
expect(component.$el.querySelector('.js-pipeline-url-user').getAttribute('href')).toEqual(
mockData.pipeline.user.web_url,
);
expect(image.getAttribute('data-original-title')).toEqual(mockData.pipeline.user.name);
expect(tooltip.textContent.trim()).toEqual(mockData.pipeline.user.name);
expect(image.getAttribute('src')).toEqual(`${mockData.pipeline.user.avatar_url}?width=20`);
});
......
......@@ -86,8 +86,8 @@ describe('Pipelines Table Row', () => {
expect(
component.$el
.querySelector('.table-section:nth-child(2) img')
.getAttribute('data-original-title'),
.querySelector('.table-section:nth-child(2) .js-user-avatar-image-toolip')
.textContent.trim(),
).toEqual(pipeline.user.name);
});
});
......@@ -112,8 +112,8 @@ describe('Pipelines Table Row', () => {
const commitAuthorLink = commitAuthorElement.getAttribute('href');
const commitAuthorName = commitAuthorElement
.querySelector('img.avatar')
.getAttribute('data-original-title');
.querySelector('.js-user-avatar-image-toolip')
.textContent.trim();
return { commitAuthorElement, commitAuthorLink, commitAuthorName };
};
......
......@@ -98,8 +98,8 @@ describe('Commit component', () => {
it('Should render the author avatar with title and alt attributes', () => {
expect(
component.$el
.querySelector('.commit-title .avatar-image-container img')
.getAttribute('data-original-title'),
.querySelector('.commit-title .avatar-image-container .js-user-avatar-image-toolip')
.textContent.trim(),
).toContain(props.author.username);
expect(
......
......@@ -73,7 +73,7 @@ describe('Header CI Component', () => {
});
it('should render user icon and name', () => {
expect(vm.$el.querySelector('.js-user-link').textContent.trim()).toEqual(props.user.name);
expect(vm.$el.querySelector('.js-user-link').innerText.trim()).toContain(props.user.name);
});
it('should render provided actions', () => {
......
import Vue from 'vue';
import { placeholderImage } from '~/lazy_loader';
import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper';
const DEFAULT_PROPS = {
size: 99,
......@@ -32,18 +32,12 @@ describe('User Avatar Image Component', function() {
});
it('should have <img> as a child element', function() {
expect(vm.$el.tagName).toBe('IMG');
expect(vm.$el.getAttribute('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
expect(vm.$el.getAttribute('alt')).toBe(DEFAULT_PROPS.imgAlt);
});
it('should properly compute tooltipContainer', function() {
expect(vm.tooltipContainer).toBe('body');
});
const imageElement = vm.$el.querySelector('img');
it('should properly render tooltipContainer', function() {
expect(vm.$el.getAttribute('data-container')).toBe('body');
expect(imageElement).not.toBe(null);
expect(imageElement.getAttribute('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
expect(imageElement.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
expect(imageElement.getAttribute('alt')).toBe(DEFAULT_PROPS.imgAlt);
});
it('should properly compute avatarSizeClass', function() {
......@@ -51,7 +45,7 @@ describe('User Avatar Image Component', function() {
});
it('should properly render img css', function() {
const { classList } = vm.$el;
const { classList } = vm.$el.querySelector('img');
const containsAvatar = classList.contains('avatar');
const containsSizeClass = classList.contains('s99');
const containsCustomClass = classList.contains(DEFAULT_PROPS.cssClasses);
......@@ -73,12 +67,41 @@ describe('User Avatar Image Component', function() {
});
it('should add lazy attributes', function() {
const { classList } = vm.$el;
const lazyClass = classList.contains('lazy');
const imageElement = vm.$el.querySelector('img');
const lazyClass = imageElement.classList.contains('lazy');
expect(lazyClass).toBe(true);
expect(vm.$el.getAttribute('src')).toBe(placeholderImage);
expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
expect(imageElement.getAttribute('src')).toBe(placeholderImage);
expect(imageElement.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
});
});
describe('dynamic tooltip content', () => {
const props = DEFAULT_PROPS;
const slots = {
default: ['Action!'],
};
beforeEach(() => {
vm = mountComponentWithSlots(UserAvatarImage, { props, slots }).$mount();
});
it('renders the tooltip slot', () => {
expect(vm.$el.querySelector('.js-user-avatar-image-toolip')).not.toBe(null);
});
it('renders the tooltip content', () => {
expect(vm.$el.querySelector('.js-user-avatar-image-toolip').textContent).toContain(
slots.default[0],
);
});
it('does not render tooltip data attributes for on avatar image', () => {
const avatarImg = vm.$el.querySelector('img');
expect(avatarImg.dataset.originalTitle).not.toBeDefined();
expect(avatarImg.dataset.placement).not.toBeDefined();
expect(avatarImg.dataset.container).not.toBeDefined();
});
});
});
......@@ -60,39 +60,43 @@ describe('User Avatar Link Component', function() {
it('should only render image tag in link', function() {
const childElements = this.userAvatarLink.$el.childNodes;
expect(childElements[0].tagName).toBe('IMG');
expect(this.userAvatarLink.$el.querySelector('img')).not.toBe('null');
// Vue will render the hidden component as <!---->
expect(childElements[1].tagName).toBeUndefined();
});
it('should render avatar image tooltip', function() {
expect(this.userAvatarLink.$el.querySelector('img').dataset.originalTitle).toEqual(
this.propsData.tooltipText,
);
expect(this.userAvatarLink.shouldShowUsername).toBe(false);
expect(this.userAvatarLink.avatarTooltipText).toEqual(this.propsData.tooltipText);
});
});
describe('username', function() {
it('should not render avatar image tooltip', function() {
expect(this.userAvatarLink.$el.querySelector('img').dataset.originalTitle).toEqual('');
expect(
this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip').innerText.trim(),
).toEqual('');
});
it('should render username prop in <span>', function() {
expect(this.userAvatarLink.$el.querySelector('span').innerText.trim()).toEqual(
this.propsData.username,
);
expect(
this.userAvatarLink.$el.querySelector('.js-user-avatar-link-username').innerText.trim(),
).toEqual(this.propsData.username);
});
it('should render text tooltip for <span>', function() {
expect(this.userAvatarLink.$el.querySelector('span').dataset.originalTitle).toEqual(
this.propsData.tooltipText,
);
expect(
this.userAvatarLink.$el.querySelector('.js-user-avatar-link-username').dataset
.originalTitle,
).toEqual(this.propsData.tooltipText);
});
it('should render text tooltip placement for <span>', function() {
expect(
this.userAvatarLink.$el.querySelector('span').getAttribute('tooltip-placement'),
this.userAvatarLink.$el
.querySelector('.js-user-avatar-link-username')
.getAttribute('tooltip-placement'),
).toEqual(this.propsData.tooltipPlacement);
});
});
......
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