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> <script>
import $ from 'jquery'; import { GlTooltipDirective } from '@gitlab-org/gitlab-ui';
import { sprintf, __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue'; 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 UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../eventhub'; 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'; import boardsStore from '../stores/boards_store';
export default { export default {
components: { components: {
UserAvatarLink,
Icon, Icon,
UserAvatarLink,
TooltipOnTruncate,
IssueDueDate,
IssueTimeEstimate,
}, },
directives: { directives: {
tooltip, GlTooltip: GlTooltipDirective,
}, },
props: { props: {
issue: { issue: {
...@@ -45,8 +51,8 @@ export default { ...@@ -45,8 +51,8 @@ export default {
}, },
data() { data() {
return { return {
limitBeforeCounter: 3, limitBeforeCounter: 2,
maxRender: 4, maxRender: 3,
maxCounter: 99, maxCounter: 99,
}; };
}, },
...@@ -55,7 +61,9 @@ export default { ...@@ -55,7 +61,9 @@ export default {
return this.issue.assignees.length - this.limitBeforeCounter; return this.issue.assignees.length - this.limitBeforeCounter;
}, },
assigneeCounterTooltip() { assigneeCounterTooltip() {
return `${this.assigneeCounterLabel} more`; const { numberOverLimit, maxCounter } = this;
const count = numberOverLimit > maxCounter ? maxCounter : numberOverLimit;
return sprintf(__('%{count} more assignees'), { count });
}, },
assigneeCounterLabel() { assigneeCounterLabel() {
if (this.numberOverLimit > this.maxCounter) { if (this.numberOverLimit > this.maxCounter) {
...@@ -80,6 +88,10 @@ export default { ...@@ -80,6 +88,10 @@ export default {
showLabelFooter() { showLabelFooter() {
return this.issue.labels.find(l => this.showLabel(l)) !== undefined; return this.issue.labels.find(l => this.showLabel(l)) !== undefined;
}, },
issueReferencePath() {
const { referencePath, groupId } = this.issue;
return !groupId ? referencePath.split('#')[0] : null;
},
}, },
methods: { methods: {
isIndexLessThanlimit(index) { isIndexLessThanlimit(index) {
...@@ -96,11 +108,9 @@ export default { ...@@ -96,11 +108,9 @@ export default {
return index < this.limitBeforeCounter; return index < this.limitBeforeCounter;
}, },
assigneeUrl(assignee) { assigneeUrl(assignee) {
if (!assignee) return '';
return `${this.rootPath}${assignee.username}`; return `${this.rootPath}${assignee.username}`;
}, },
assigneeUrlTitle(assignee) {
return `Assigned to ${assignee.name}`;
},
avatarUrlTitle(assignee) { avatarUrlTitle(assignee) {
return `Avatar for ${assignee.name}`; return `Avatar for ${assignee.name}`;
}, },
...@@ -108,19 +118,29 @@ export default { ...@@ -108,19 +118,29 @@ export default {
if (!label.id) return false; if (!label.id) return false;
return true; 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; if (!this.updateFilters) return;
const issueWeight = encodeURIComponent(weight);
const filter = `weight=${issueWeight}`;
this.applyFilter(filter);
},
applyFilter(filter) {
const filterPath = boardsStore.filter.path.split('&'); const filterPath = boardsStore.filter.path.split('&');
const labelTitle = encodeURIComponent(label.title); const filterIndex = filterPath.indexOf(filter);
const param = `label_name[]=${labelTitle}`;
const labelIndex = filterPath.indexOf(param);
$(e.currentTarget).tooltip('hide');
if (labelIndex === -1) { if (filterIndex === -1) {
filterPath.push(param); filterPath.push(filter);
} else { } else {
filterPath.splice(labelIndex, 1); filterPath.splice(filterIndex, 1);
} }
boardsStore.filter.path = filterPath.join('&'); boardsStore.filter.path = filterPath.join('&');
...@@ -141,24 +161,62 @@ export default { ...@@ -141,24 +161,62 @@ export default {
<template> <template>
<div> <div>
<div class="board-card-header"> <div class="board-card-header">
<h4 class="board-card-title"> <h4 class="board-card-title append-bottom-0 prepend-top-0">
<icon <icon
v-if="issue.confidential" v-if="issue.confidential"
v-gl-tooltip
name="eye-slash" name="eye-slash"
class="confidential-icon" :title="__('Confidential')"
/> class="confidential-icon append-right-4"
<a :aria-label="__('Confidential')"
/><a
:href="issue.path" :href="issue.path"
:title="issue.title" :title="issue.title"
class="js-no-trigger" class="js-no-trigger"
@mousemove.stop>{{ issue.title }}</a> @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 <span
v-if="issueId" v-if="issue.referencePath"
class="board-card-number append-right-5" 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> </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"> <div class="board-card-assignee">
<user-avatar-link <user-avatar-link
v-for="(assignee, index) in issue.assignees" v-for="(assignee, index) in issue.assignees"
...@@ -167,38 +225,26 @@ export default { ...@@ -167,38 +225,26 @@ export default {
:link-href="assigneeUrl(assignee)" :link-href="assigneeUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)" :img-alt="avatarUrlTitle(assignee)"
:img-src="assignee.avatar" :img-src="assignee.avatar"
:tooltip-text="assigneeUrlTitle(assignee)" :img-size="24"
class="js-no-trigger" class="js-no-trigger"
tooltip-placement="bottom" 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 <span
v-if="shouldRenderCounter" v-if="shouldRenderCounter"
v-tooltip v-gl-tooltip
:title="assigneeCounterTooltip" :title="assigneeCounterTooltip"
class="avatar-counter" class="avatar-counter"
data-placement="bottom"
> >
{{ assigneeCounterLabel }} {{ assigneeCounterLabel }}
</span> </span>
</div> </div>
</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> </div>
</template> </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 { ...@@ -30,6 +30,7 @@ class ListIssue {
this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
this.milestone_id = obj.milestone_id; this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id; this.project_id = obj.project_id;
this.timeEstimate = obj.time_estimate;
this.assignableLabelsEndpoint = obj.assignable_labels_endpoint; this.assignableLabelsEndpoint = obj.assignable_labels_endpoint;
if (obj.project) { if (obj.project) {
......
...@@ -454,12 +454,20 @@ export const parseSeconds = (seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) ...@@ -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 * 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. * (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( const reducedTime = _.reduce(
timeObject, timeObject,
(memo, unitValue, unitName) => { (memo, unitValue, unitName) => {
const isNonZero = !!unitValue; 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; return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
}, },
'', '',
......
...@@ -15,14 +15,14 @@ ...@@ -15,14 +15,14 @@
*/ */
import { GlTooltip } from '@gitlab-org/gitlab-ui';
import defaultAvatarUrl from 'images/no_avatar.png'; import defaultAvatarUrl from 'images/no_avatar.png';
import { placeholderImage } from '../../../lazy_loader'; import { placeholderImage } from '../../../lazy_loader';
import tooltip from '../../directives/tooltip';
export default { export default {
name: 'UserAvatarImage', name: 'UserAvatarImage',
directives: { components: {
tooltip, GlTooltip,
}, },
props: { props: {
lazy: { lazy: {
...@@ -73,9 +73,6 @@ export default { ...@@ -73,9 +73,6 @@ export default {
resultantSrcAttribute() { resultantSrcAttribute() {
return this.lazy ? placeholderImage : this.sanitizedSource; return this.lazy ? placeholderImage : this.sanitizedSource;
}, },
tooltipContainer() {
return this.tooltipText ? 'body' : null;
},
avatarSizeClass() { avatarSizeClass() {
return `s${this.size}`; return `s${this.size}`;
}, },
...@@ -84,22 +81,30 @@ export default { ...@@ -84,22 +81,30 @@ export default {
</script> </script>
<template> <template>
<img <span>
v-tooltip <img
:class="{ ref="userAvatarImage"
lazy: lazy, :class="{
[avatarSizeClass]: true, lazy: lazy,
[cssClasses]: true [avatarSizeClass]: true,
}" [cssClasses]: true
:src="resultantSrcAttribute" }"
:width="size" :src="resultantSrcAttribute"
:height="size" :width="size"
:alt="imgAlt" :height="size"
:data-src="sanitizedSource" :alt="imgAlt"
:data-container="tooltipContainer" :data-src="sanitizedSource"
:data-placement="tooltipPlacement" class="avatar"
:title="tooltipText" />
class="avatar" <gl-tooltip
data-boundary="window" :target="() => $refs.userAvatarImage"
/> :placement="tooltipPlacement"
boundary="window"
class="js-user-avatar-image-toolip"
>
<slot>
{{ tooltipText }}
</slot>
</gl-tooltip>
</span>
</template> </template>
...@@ -17,9 +17,8 @@ ...@@ -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 userAvatarImage from './user_avatar_image.vue';
import tooltip from '../../directives/tooltip';
export default { export default {
name: 'UserAvatarLink', name: 'UserAvatarLink',
...@@ -28,7 +27,7 @@ export default { ...@@ -28,7 +27,7 @@ export default {
userAvatarImage, userAvatarImage,
}, },
directives: { directives: {
tooltip, GlTooltip: GlTooltipDirective,
}, },
props: { props: {
linkHref: { linkHref: {
...@@ -94,11 +93,14 @@ export default { ...@@ -94,11 +93,14 @@ export default {
:size="imgSize" :size="imgSize"
:tooltip-text="avatarTooltipText" :tooltip-text="avatarTooltipText"
:tooltip-placement="tooltipPlacement" :tooltip-placement="tooltipPlacement"
/><span >
<slot></slot>
</user-avatar-image><span
v-if="shouldShowUsername" v-if="shouldShowUsername"
v-tooltip v-gl-tooltip
:title="tooltipText" :title="tooltipText"
:tooltip-placement="tooltipPlacement" :tooltip-placement="tooltipPlacement"
class="js-user-avatar-link-username"
>{{ username }}</span><slot name="avatar-badge"></slot> >{{ username }}</span><slot name="avatar-badge"></slot>
</gl-link> </gl-link>
</template> </template>
...@@ -33,6 +33,11 @@ ...@@ -33,6 +33,11 @@
color: $brand-danger; color: $brand-danger;
} }
.text-danger-muted,
.text-danger-muted:hover {
color: $red-300;
}
.text-warning, .text-warning,
.text-warning:hover { .text-warning:hover {
color: $brand-warning; color: $brand-warning;
...@@ -345,6 +350,7 @@ img.emoji { ...@@ -345,6 +350,7 @@ img.emoji {
/** COMMON CLASSES **/ /** COMMON CLASSES **/
.prepend-top-0 { margin-top: 0; } .prepend-top-0 { margin-top: 0; }
.prepend-top-2 { margin-top: 2px; } .prepend-top-2 { margin-top: 2px; }
.prepend-top-4 { margin-top: $gl-padding-4; }
.prepend-top-5 { margin-top: 5px; } .prepend-top-5 { margin-top: 5px; }
.prepend-top-8 { margin-top: $grid-size; } .prepend-top-8 { margin-top: $grid-size; }
.prepend-top-10 { margin-top: 10px; } .prepend-top-10 { margin-top: 10px; }
...@@ -365,6 +371,7 @@ img.emoji { ...@@ -365,6 +371,7 @@ img.emoji {
.append-right-default { margin-right: $gl-padding; } .append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; } .append-right-20 { margin-right: 20px; }
.append-bottom-0 { margin-bottom: 0; } .append-bottom-0 { margin-bottom: 0; }
.append-bottom-4 { margin-bottom: $gl-padding-4; }
.append-bottom-5 { margin-bottom: 5px; } .append-bottom-5 { margin-bottom: 5px; }
.append-bottom-8 { margin-bottom: $grid-size; } .append-bottom-8 { margin-bottom: $grid-size; }
.append-bottom-10 { margin-bottom: 10px; } .append-bottom-10 { margin-bottom: 10px; }
......
...@@ -195,6 +195,7 @@ $well-light-text-color: #5b6169; ...@@ -195,6 +195,7 @@ $well-light-text-color: #5b6169;
* Text * Text
*/ */
$gl-font-size: 14px; $gl-font-size: 14px;
$gl-font-size-xs: 11px;
$gl-font-size-small: 12px; $gl-font-size-small: 12px;
$gl-font-weight-normal: 400; $gl-font-weight-normal: 400;
$gl-font-weight-bold: 600; $gl-font-weight-bold: 600;
...@@ -440,7 +441,7 @@ $ci-skipped-color: #888; ...@@ -440,7 +441,7 @@ $ci-skipped-color: #888;
* Boards * Boards
*/ */
$issue-boards-font-size: 14px; $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. 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. They probably should be derived in a smarter way.
......
...@@ -90,20 +90,14 @@ ...@@ -90,20 +90,14 @@
} }
.with-performance-bar & { .with-performance-bar & {
height: calc( height: calc(100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height});
100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height}
);
@include media-breakpoint-only(sm) { @include media-breakpoint-only(sm) {
height: calc( height: calc(100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height});
100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height}
);
} }
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
height: calc( height: calc(100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height});
100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height}
);
} }
} }
} }
...@@ -271,7 +265,7 @@ ...@@ -271,7 +265,7 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
margin-bottom: 0; margin-bottom: 0;
padding: 5px; padding: $gl-padding-4;
list-style: none; list-style: none;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
...@@ -284,14 +278,16 @@ ...@@ -284,14 +278,16 @@
.board-card { .board-card {
position: relative; position: relative;
padding: 11px 10px 11px $gl-padding; padding: $gl-padding;
background: $white-light; background: $white-light;
border-radius: $border-radius-default; border-radius: $border-radius-default;
border: 1px solid $theme-gray-200;
box-shadow: 0 1px 2px $issue-boards-card-shadow; box-shadow: 0 1px 2px $issue-boards-card-shadow;
list-style: none; list-style: none;
line-height: $gl-padding;
&:not(:last-child) { &:not(:last-child) {
margin-bottom: 5px; margin-bottom: $gl-padding-8;
} }
&.is-active, &.is-active,
...@@ -302,113 +298,120 @@ ...@@ -302,113 +298,120 @@
.badge { .badge {
border: 0; border: 0;
outline: 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 { .confidential-icon {
vertical-align: text-top; color: $orange-600;
margin-right: 5px; cursor: help;
}
@include media-breakpoint-down(md) {
padding: $gl-padding-8;
} }
} }
.board-card-title { .board-card-title {
@include overflow-break-word(); @include overflow-break-word();
margin: 0 30px 0 0;
font-size: 1em; font-size: 1em;
line-height: inherit;
a { a {
color: $gl-text-color; color: $gl-text-color;
margin-right: 2px; }
@include media-breakpoint-down(md) {
font-size: $label-font-size;
} }
} }
.board-card-header { .board-card-header {
display: flex; display: flex;
min-height: 20px; }
.board-card-assignee {
display: flex;
justify-content: flex-end;
position: absolute;
right: 15px;
height: 20px;
width: 20px;
.avatar-counter { .board-card-assignee {
display: none; display: flex;
vertical-align: middle; margin-top: -$gl-padding-4;
min-width: 20px; margin-bottom: -$gl-padding-4;
line-height: 19px;
height: 20px; .avatar-counter {
padding-left: 2px; vertical-align: middle;
padding-right: 2px; line-height: $gl-padding-24;
border-radius: 2em; 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 { @include media-breakpoint-down(md) {
vertical-align: top; min-width: auto;
height: $gl-padding;
border-radius: $gl-padding;
line-height: $gl-padding;
} }
}
a { img {
position: relative; vertical-align: top;
margin-left: -15px; }
}
a:nth-child(1) { .user-avatar-link:not(:only-child) {
z-index: 3; margin-left: -$gl-padding-4;
}
a:nth-child(2) { &:nth-of-type(1) {
z-index: 2; z-index: 2;
} }
a:nth-child(3) { &:nth-of-type(2) {
z-index: 1; z-index: 1;
} }
}
a:nth-child(4) { .avatar {
display: none; margin: 0;
}
&: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;
}
&:first-child:not(:only-child) { @include media-breakpoint-down(md) {
box-shadow: -10px 0 10px 1px $white-light; width: $gl-padding;
} height: $gl-padding;
}
} }
} }
.avatar { @include media-breakpoint-down(md) {
margin: 0; margin-top: 0;
margin-bottom: 0;
} }
} }
.board-card-footer { .board-card-number {
margin: 0 0 5px; font-size: $gl-font-size-xs;
color: $gl-text-color-secondary;
overflow: hidden;
.badge { @include media-breakpoint-up(md) {
margin-top: 5px; font-size: $label-font-size;
margin-right: 6px;
} }
} }
.board-card-number { .board-card-number-container {
font-size: 12px; overflow: hidden;
color: $gl-text-color-secondary;
} }
.issue-boards-search { .issue-boards-search {
...@@ -474,8 +477,7 @@ ...@@ -474,8 +477,7 @@
.right-sidebar.right-sidebar-expanded { .right-sidebar.right-sidebar-expanded {
&.boards-sidebar-slide-enter-active, &.boards-sidebar-slide-enter-active,
&.boards-sidebar-slide-leave-active { &.boards-sidebar-slide-leave-active {
transition: width $sidebar-transition-duration, transition: width $sidebar-transition-duration, padding $sidebar-transition-duration;
padding $sidebar-transition-duration;
} }
&.boards-sidebar-slide-enter, &.boards-sidebar-slide-enter,
...@@ -650,3 +652,36 @@ ...@@ -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 ...@@ -12,6 +12,7 @@ class IssueBoardEntity < Grape::Entity
expose :project_id expose :project_id
expose :relative_position expose :relative_position
expose :weight, if: -> (*) { respond_to?(:weight) } expose :weight, if: -> (*) { respond_to?(:weight) }
expose :time_estimate
expose :project do |issue| expose :project do |issue|
API::Entities::Project.represent issue.project, only: [:id, :path] 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 "" ...@@ -103,6 +103,9 @@ msgstr ""
msgid "%{counter_storage} (%{counter_repositories} repositories, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS)" msgid "%{counter_storage} (%{counter_repositories} repositories, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS)"
msgstr "" msgstr ""
msgid "%{count} more assignees"
msgstr ""
msgid "%{count} participant" msgid "%{count} participant"
msgid_plural "%{count} participants" msgid_plural "%{count} participants"
msgstr[0] "" msgstr[0] ""
...@@ -6371,6 +6374,9 @@ msgstr "" ...@@ -6371,6 +6374,9 @@ msgstr ""
msgid "Time between merge request creation and merge/close" msgid "Time between merge request creation and merge/close"
msgstr "" msgstr ""
msgid "Time estimate"
msgstr ""
msgid "Time remaining" msgid "Time remaining"
msgstr "" msgstr ""
...@@ -6585,6 +6591,9 @@ 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." msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
msgstr "" msgstr ""
msgid "Today"
msgstr ""
msgid "Todo" msgid "Todo"
msgstr "" msgstr ""
...@@ -6618,6 +6627,9 @@ msgstr "" ...@@ -6618,6 +6627,9 @@ msgstr ""
msgid "Token" msgid "Token"
msgstr "" msgstr ""
msgid "Tomorrow"
msgstr ""
msgid "Too many changes to show." msgid "Too many changes to show."
msgstr "" msgstr ""
...@@ -7086,6 +7098,9 @@ msgstr "" ...@@ -7086,6 +7098,9 @@ msgstr ""
msgid "Yes, let me map Google Code users to full names or GitLab users." msgid "Yes, let me map Google Code users to full names or GitLab users."
msgstr "" 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." 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 "" msgstr ""
......
...@@ -160,7 +160,7 @@ describe 'Issue Boards add issue modal', :js do ...@@ -160,7 +160,7 @@ describe 'Issue Boards add issue modal', :js do
it 'changes button text with plural' do it 'changes button text with plural' do
page.within('.add-issues-modal') 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 el.click
end end
......
...@@ -78,7 +78,7 @@ describe 'Issue Boards', :js do ...@@ -78,7 +78,7 @@ describe 'Issue Boards', :js do
end end
it 'moves from bottom to top' do 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 wait_for_requests
...@@ -130,7 +130,7 @@ describe 'Issue Boards', :js do ...@@ -130,7 +130,7 @@ describe 'Issue Boards', :js do
end end
it 'moves to bottom of another list' do 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 wait_for_requests
......
...@@ -89,16 +89,17 @@ describe 'Merge request > User sees avatars on diff notes', :js do ...@@ -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 page.within find_line(position.line_code(project.repository)) do
find('.diff-notes-collapse').send_keys(:return) 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
end end
it 'shows comment on note avatar' do it 'shows comment on note avatar' do
page.within find_line(position.line_code(project.repository)) do page.within find_line(position.line_code(project.repository)) do
find('.diff-notes-collapse').send_keys(:return) find('.diff-notes-collapse').send_keys(:return)
first('.js-diff-comment-avatar img').hover
expect(first('img.js-diff-comment-avatar')["data-original-title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}")
end end
expect(page).to have_content "#{note.author.name}: #{note.note.truncate(17)}"
end end
it 'toggles comments when clicking avatar' do it 'toggles comments when clicking avatar' do
...@@ -109,7 +110,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do ...@@ -109,7 +110,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do
expect(page).not_to have_selector('.notes_holder') expect(page).not_to have_selector('.notes_holder')
page.within find_line(position.line_code(project.repository)) do 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 end
expect(page).to have_selector('.notes_holder') expect(page).to have_selector('.notes_holder')
...@@ -125,7 +126,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do ...@@ -125,7 +126,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do
wait_for_requests wait_for_requests
page.within find_line(position.line_code(project.repository)) do 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
end end
...@@ -143,7 +144,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do ...@@ -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 page.within find_line(position.line_code(project.repository)) do
find('.diff-notes-collapse').send_keys(:return) 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
end end
...@@ -162,7 +163,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do ...@@ -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 page.within find_line(position.line_code(project.repository)) do
find('.diff-notes-collapse').send_keys(:return) 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' expect(find('.diff-comments-more-count')).to have_content '+1'
end end
end end
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
"due_date": { "type": "date" }, "due_date": { "type": "date" },
"project_id": { "type": "integer" }, "project_id": { "type": "integer" },
"relative_position": { "type": ["integer", "null"] }, "relative_position": { "type": ["integer", "null"] },
"time_estimate": { "type": "integer" },
"weight": { "type": "integer" }, "weight": { "type": "integer" },
"project": { "project": {
"type": "object", "type": "object",
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
"confidential": { "type": "boolean" }, "confidential": { "type": "boolean" },
"due_date": { "type": ["date", "null"] }, "due_date": { "type": ["date", "null"] },
"relative_position": { "type": "integer" }, "relative_position": { "type": "integer" },
"time_estimate": { "type": "integer" },
"issue_sidebar_endpoint": { "type": "string" }, "issue_sidebar_endpoint": { "type": "string" },
"toggle_subscription_endpoint": { "type": "string" }, "toggle_subscription_endpoint": { "type": "string" },
"assignable_labels_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', () => { ...@@ -117,11 +117,9 @@ describe('Issue card component', () => {
}); });
it('sets title', () => { it('sets title', () => {
expect( expect(component.$el.querySelector('.js-assignee-tooltip').textContent).toContain(
component.$el `${user.name}`,
.querySelector('.board-card-assignee img') );
.getAttribute('data-original-title'),
).toContain(`Assigned to ${user.name}`);
}); });
it('sets users path', () => { it('sets users path', () => {
...@@ -154,7 +152,7 @@ describe('Issue card component', () => { ...@@ -154,7 +152,7 @@ describe('Issue card component', () => {
it('displays defaults avatar if users avatar is null', () => { 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')).not.toBeNull();
expect(component.$el.querySelector('.board-card-assignee img').getAttribute('src')).toBe( 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', () => { ...@@ -163,7 +161,6 @@ describe('Issue card component', () => {
describe('multiple assignees', () => { describe('multiple assignees', () => {
beforeEach(done => { beforeEach(done => {
component.issue.assignees = [ component.issue.assignees = [
user,
new ListAssignee({ new ListAssignee({
id: 2, id: 2,
name: 'user2', name: 'user2',
...@@ -187,11 +184,11 @@ describe('Issue card component', () => { ...@@ -187,11 +184,11 @@ describe('Issue card component', () => {
Vue.nextTick(() => done()); Vue.nextTick(() => done());
}); });
it('renders all four assignees', () => { it('renders all three assignees', () => {
expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(4); expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3);
}); });
describe('more than four assignees', () => { describe('more than three assignees', () => {
beforeEach(done => { beforeEach(done => {
component.issue.assignees.push( component.issue.assignees.push(
new ListAssignee({ new ListAssignee({
...@@ -207,12 +204,12 @@ describe('Issue card component', () => { ...@@ -207,12 +204,12 @@ describe('Issue card component', () => {
it('renders more avatar counter', () => { it('renders more avatar counter', () => {
expect( expect(
component.$el.querySelector('.board-card-assignee .avatar-counter').innerText, component.$el.querySelector('.board-card-assignee .avatar-counter').innerText.trim(),
).toEqual('+2'); ).toEqual('+2');
}); });
it('renders three assignees', () => { it('renders two assignees', () => {
expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3); expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(2);
}); });
it('renders 99+ avatar counter', done => { it('renders 99+ avatar counter', done => {
...@@ -228,7 +225,7 @@ describe('Issue card component', () => { ...@@ -228,7 +225,7 @@ describe('Issue card component', () => {
Vue.nextTick(() => { Vue.nextTick(() => {
expect( expect(
component.$el.querySelector('.board-card-assignee .avatar-counter').innerText, component.$el.querySelector('.board-card-assignee .avatar-counter').innerText.trim(),
).toEqual('99+'); ).toEqual('99+');
done(); done();
}); });
......
...@@ -102,7 +102,7 @@ describe('Job App ', () => { ...@@ -102,7 +102,7 @@ describe('Job App ', () => {
.querySelector('.header-main-content') .querySelector('.header-main-content')
.textContent.replace(/\s+/g, ' ') .textContent.replace(/\s+/g, ' ')
.trim(), .trim(),
).toEqual('passed Job #4757 triggered 1 year ago by Root'); ).toContain('passed Job #4757 triggered 1 year ago by Root');
done(); done();
}, 0); }, 0);
}); });
...@@ -128,7 +128,7 @@ describe('Job App ', () => { ...@@ -128,7 +128,7 @@ describe('Job App ', () => {
.querySelector('.header-main-content') .querySelector('.header-main-content')
.textContent.replace(/\s+/g, ' ') .textContent.replace(/\s+/g, ' ')
.trim(), .trim(),
).toEqual('passed Job #4757 created 3 weeks ago by Root'); ).toContain('passed Job #4757 created 3 weeks ago by Root');
done(); done();
}, 0); }, 0);
}); });
......
...@@ -336,6 +336,12 @@ describe('prettyTime methods', () => { ...@@ -336,6 +336,12 @@ describe('prettyTime methods', () => {
expect(timeString).toBe('0m'); 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', () => { describe('abbreviateTime', () => {
......
...@@ -51,7 +51,7 @@ describe('Pipeline details header', () => { ...@@ -51,7 +51,7 @@ describe('Pipeline details header', () => {
.querySelector('.header-main-content') .querySelector('.header-main-content')
.textContent.replace(/\s+/g, ' ') .textContent.replace(/\s+/g, ' ')
.trim(), .trim(),
).toEqual('failed Pipeline #123 triggered 3 weeks ago by Foo'); ).toContain('failed Pipeline #123 triggered 3 weeks ago by Foo');
}); });
describe('action buttons', () => { describe('action buttons', () => {
......
...@@ -63,12 +63,15 @@ describe('Pipeline Url Component', () => { ...@@ -63,12 +63,15 @@ describe('Pipeline Url Component', () => {
}).$mount(); }).$mount();
const image = component.$el.querySelector('.js-pipeline-url-user img'); 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( expect(component.$el.querySelector('.js-pipeline-url-user').getAttribute('href')).toEqual(
mockData.pipeline.user.web_url, 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`); expect(image.getAttribute('src')).toEqual(`${mockData.pipeline.user.avatar_url}?width=20`);
}); });
......
...@@ -86,8 +86,8 @@ describe('Pipelines Table Row', () => { ...@@ -86,8 +86,8 @@ describe('Pipelines Table Row', () => {
expect( expect(
component.$el component.$el
.querySelector('.table-section:nth-child(2) img') .querySelector('.table-section:nth-child(2) .js-user-avatar-image-toolip')
.getAttribute('data-original-title'), .textContent.trim(),
).toEqual(pipeline.user.name); ).toEqual(pipeline.user.name);
}); });
}); });
...@@ -112,8 +112,8 @@ describe('Pipelines Table Row', () => { ...@@ -112,8 +112,8 @@ describe('Pipelines Table Row', () => {
const commitAuthorLink = commitAuthorElement.getAttribute('href'); const commitAuthorLink = commitAuthorElement.getAttribute('href');
const commitAuthorName = commitAuthorElement const commitAuthorName = commitAuthorElement
.querySelector('img.avatar') .querySelector('.js-user-avatar-image-toolip')
.getAttribute('data-original-title'); .textContent.trim();
return { commitAuthorElement, commitAuthorLink, commitAuthorName }; return { commitAuthorElement, commitAuthorLink, commitAuthorName };
}; };
......
...@@ -98,8 +98,8 @@ describe('Commit component', () => { ...@@ -98,8 +98,8 @@ describe('Commit component', () => {
it('Should render the author avatar with title and alt attributes', () => { it('Should render the author avatar with title and alt attributes', () => {
expect( expect(
component.$el component.$el
.querySelector('.commit-title .avatar-image-container img') .querySelector('.commit-title .avatar-image-container .js-user-avatar-image-toolip')
.getAttribute('data-original-title'), .textContent.trim(),
).toContain(props.author.username); ).toContain(props.author.username);
expect( expect(
......
...@@ -73,7 +73,7 @@ describe('Header CI Component', () => { ...@@ -73,7 +73,7 @@ describe('Header CI Component', () => {
}); });
it('should render user icon and name', () => { 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', () => { it('should render provided actions', () => {
......
import Vue from 'vue'; import Vue from 'vue';
import { placeholderImage } from '~/lazy_loader'; import { placeholderImage } from '~/lazy_loader';
import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; 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 = { const DEFAULT_PROPS = {
size: 99, size: 99,
...@@ -32,18 +32,12 @@ describe('User Avatar Image Component', function() { ...@@ -32,18 +32,12 @@ describe('User Avatar Image Component', function() {
}); });
it('should have <img> as a child element', function() { it('should have <img> as a child element', function() {
expect(vm.$el.tagName).toBe('IMG'); const imageElement = vm.$el.querySelector('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');
});
it('should properly render tooltipContainer', function() { expect(imageElement).not.toBe(null);
expect(vm.$el.getAttribute('data-container')).toBe('body'); 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() { it('should properly compute avatarSizeClass', function() {
...@@ -51,7 +45,7 @@ describe('User Avatar Image Component', function() { ...@@ -51,7 +45,7 @@ describe('User Avatar Image Component', function() {
}); });
it('should properly render img css', function() { it('should properly render img css', function() {
const { classList } = vm.$el; const { classList } = vm.$el.querySelector('img');
const containsAvatar = classList.contains('avatar'); const containsAvatar = classList.contains('avatar');
const containsSizeClass = classList.contains('s99'); const containsSizeClass = classList.contains('s99');
const containsCustomClass = classList.contains(DEFAULT_PROPS.cssClasses); const containsCustomClass = classList.contains(DEFAULT_PROPS.cssClasses);
...@@ -73,12 +67,41 @@ describe('User Avatar Image Component', function() { ...@@ -73,12 +67,41 @@ describe('User Avatar Image Component', function() {
}); });
it('should add lazy attributes', function() { it('should add lazy attributes', function() {
const { classList } = vm.$el; const imageElement = vm.$el.querySelector('img');
const lazyClass = classList.contains('lazy'); const lazyClass = imageElement.classList.contains('lazy');
expect(lazyClass).toBe(true); expect(lazyClass).toBe(true);
expect(vm.$el.getAttribute('src')).toBe(placeholderImage); expect(imageElement.getAttribute('src')).toBe(placeholderImage);
expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); 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() { ...@@ -60,39 +60,43 @@ describe('User Avatar Link Component', function() {
it('should only render image tag in link', function() { it('should only render image tag in link', function() {
const childElements = this.userAvatarLink.$el.childNodes; 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 <!----> // Vue will render the hidden component as <!---->
expect(childElements[1].tagName).toBeUndefined(); expect(childElements[1].tagName).toBeUndefined();
}); });
it('should render avatar image tooltip', function() { it('should render avatar image tooltip', function() {
expect(this.userAvatarLink.$el.querySelector('img').dataset.originalTitle).toEqual( expect(this.userAvatarLink.shouldShowUsername).toBe(false);
this.propsData.tooltipText, expect(this.userAvatarLink.avatarTooltipText).toEqual(this.propsData.tooltipText);
);
}); });
}); });
describe('username', function() { describe('username', function() {
it('should not render avatar image tooltip', 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() { it('should render username prop in <span>', function() {
expect(this.userAvatarLink.$el.querySelector('span').innerText.trim()).toEqual( expect(
this.propsData.username, this.userAvatarLink.$el.querySelector('.js-user-avatar-link-username').innerText.trim(),
); ).toEqual(this.propsData.username);
}); });
it('should render text tooltip for <span>', function() { it('should render text tooltip for <span>', function() {
expect(this.userAvatarLink.$el.querySelector('span').dataset.originalTitle).toEqual( expect(
this.propsData.tooltipText, this.userAvatarLink.$el.querySelector('.js-user-avatar-link-username').dataset
); .originalTitle,
).toEqual(this.propsData.tooltipText);
}); });
it('should render text tooltip placement for <span>', function() { it('should render text tooltip placement for <span>', function() {
expect( 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); ).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