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,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>
......@@ -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 {
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;