Commit 9652f792 authored by Felipe Artur's avatar Felipe Artur Committed by Phil Hughes

Epic issue list and related issue list re-design

parent 245e1483
...@@ -15,6 +15,16 @@ export default { ...@@ -15,6 +15,16 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
cssClass: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'bottom',
},
}, },
computed: { computed: {
title() { title() {
...@@ -66,15 +76,13 @@ export default { ...@@ -66,15 +76,13 @@ export default {
<template> <template>
<span> <span>
<span ref="issueDueDate" class="board-card-info card-number"> <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number">
<icon <icon :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" name="calendar" />
:class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
name="calendar"
/><time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
body body
}}</time> }}</time>
</span> </span>
<gl-tooltip :target="() => $refs.issueDueDate" placement="bottom"> <gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement">
<span class="bold">{{ __('Due date') }}</span> <br /> <span class="bold">{{ __('Due date') }}</span> <br />
<span :class="{ 'text-danger-muted': isPastDue }">{{ title }}</span> <span :class="{ 'text-danger-muted': isPastDue }">{{ title }}</span>
</gl-tooltip> </gl-tooltip>
......
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
components: {
UserAvatarLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
assignees: {
type: Array,
required: true,
},
},
data() {
return {
maxVisibleAssignees: 2,
maxAssigneeAvatars: 3,
maxAssignees: 99,
};
},
computed: {
countOverLimit() {
return this.assignees.length - this.maxVisibleAssignees;
},
assigneesToShow() {
if (this.assignees.length > this.maxAssigneeAvatars) {
return this.assignees.slice(0, this.maxVisibleAssignees);
}
return this.assignees;
},
assigneesCounterTooltip() {
const { countOverLimit, maxAssignees } = this;
const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit;
return sprintf(__('%{count} more assignees'), { count });
},
shouldRenderAssigneesCounter() {
const assigneesCount = this.assignees.length;
if (assigneesCount <= this.maxAssigneeAvatars) {
return false;
}
return assigneesCount > this.countOverLimit;
},
assigneeCounterLabel() {
if (this.countOverLimit > this.maxAssignees) {
return `${this.maxAssignees}+`;
}
return `+${this.countOverLimit}`;
},
},
methods: {
avatarUrlTitle(assignee) {
return sprintf(__('Avatar for %{assigneeName}'), {
assigneeName: assignee.name,
});
},
},
};
</script>
<template>
<div class="issue-assignees">
<user-avatar-link
v-for="assignee in assigneesToShow"
:key="assignee.id"
:link-href="assignee.web_url"
:img-alt="avatarUrlTitle(assignee)"
:img-src="assignee.avatar_url"
: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="shouldRenderAssigneesCounter"
v-gl-tooltip
:title="assigneesCounterTooltip"
class="avatar-counter"
data-placement="bottom"
>{{ assigneeCounterLabel }}</span
>
</div>
</template>
<script>
import { GlTooltip } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
GlTooltip,
},
mixins: [timeagoMixin],
props: {
milestone: {
type: Object,
required: true,
},
},
data() {
return {
milestoneDue: this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null,
milestoneStart: this.milestone.start_date
? parsePikadayDate(this.milestone.start_date)
: null,
};
},
computed: {
isMilestoneStarted() {
if (!this.milestoneStart) {
return false;
}
return Date.now() > this.milestoneStart;
},
isMilestonePastDue() {
if (!this.milestoneDue) {
return false;
}
return Date.now() > this.milestoneDue;
},
milestoneDatesAbsolute() {
if (this.milestoneDue) {
return `(${dateInWords(this.milestoneDue)})`;
} else if (this.milestoneStart) {
return `(${dateInWords(this.milestoneStart)})`;
}
return '';
},
milestoneDatesHuman() {
if (this.milestoneStart || this.milestoneDue) {
if (this.milestoneDue) {
return timeFor(
this.milestoneDue,
sprintf(__('Expired %{expiredOn}'), {
expiredOn: this.timeFormated(this.milestoneDue),
}),
);
}
return sprintf(
this.isMilestoneStarted ? __('Started %{startsIn}') : __('Starts %{startsIn}'),
{
startsIn: this.timeFormated(this.milestoneStart),
},
);
}
return '';
},
},
};
</script>
<template>
<div ref="milestoneDetails" class="issue-milestone-details">
<icon :size="16" class="inline icon" name="clock" />
<span class="milestone-title">{{ milestone.title }}</span>
<gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone">
<span class="bold">{{ __('Milestone') }}</span> <br />
<span>{{ milestone.title }}</span> <br />
<span
v-if="milestoneStart || milestoneDue"
:class="{
'text-danger-muted': isMilestonePastDue,
'text-tertiary': !isMilestonePastDue,
}"
><span>{{ milestoneDatesHuman }}</span
><br /><span>{{ milestoneDatesAbsolute }}</span>
</span>
</gl-tooltip>
</div>
</template>
<script> <script>
import { GlTooltipDirective } from '@gitlab/ui'; import { GlTooltip } from '@gitlab/ui';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
export default { export default {
name: 'IssueCardWeight', name: 'IssueCardWeight',
components: { components: {
icon, icon,
}, GlTooltip,
directives: {
GlTooltip: GlTooltipDirective,
}, },
props: { props: {
weight: { weight: {
...@@ -21,16 +19,19 @@ export default { ...@@ -21,16 +19,19 @@ export default {
<template> <template>
<a <a
v-gl-tooltip ref="itemWeight"
:title="__('Weight')"
class="board-card-info card-number board-card-weight" class="board-card-info card-number board-card-weight"
data-container="body"
data-placement="bottom"
tabindex="1" tabindex="1"
v-on="$listeners" v-on="$listeners"
> >
<icon name="weight" css-classes="board-card-info-icon" /><span class="board-card-info-text">{{ <icon name="weight" css-classes="board-card-info-icon" />
weight <span class="board-card-info-text"> {{ weight }} </span>
}}</span> <gl-tooltip
:target="() => $refs.itemWeight"
placement="bottom"
container="body"
class="js-item-weight"
>{{ __('Weight') }}<br /><span class="text-tertiary">{{ weight }}</span>
</gl-tooltip>
</a> </a>
</template> </template>
...@@ -31,6 +31,10 @@ export default { ...@@ -31,6 +31,10 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
pathIdSeparator: {
type: String,
required: true,
},
}, },
data() { data() {
...@@ -135,6 +139,7 @@ export default { ...@@ -135,6 +139,7 @@ export default {
:display-reference="reference" :display-reference="reference"
:can-remove="true" :can-remove="true"
:is-condensed="true" :is-condensed="true"
:path-id-separator="pathIdSeparator"
event-namespace="pendingIssuable" event-namespace="pendingIssuable"
/> />
</li> </li>
......
<script> <script>
import { __ } from '~/locale'; import { GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import IssueWeight from 'ee/boards/components/issue_card_weight.vue';
import relatedIssueMixin from '../mixins/related_issues_mixin'; import relatedIssueMixin from '../mixins/related_issues_mixin';
export default { export default {
name: 'IssueItem', name: 'IssueItem',
components: {
IssueMilestone,
IssueDueDate,
IssueAssignees,
IssueWeight,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [relatedIssueMixin], mixins: [relatedIssueMixin],
props: { props: {
canReorder: { canReorder: {
...@@ -14,7 +28,14 @@ export default { ...@@ -14,7 +28,14 @@ export default {
}, },
computed: { computed: {
stateTitle() { stateTitle() {
return this.isOpen ? __('Open') : __('Closed'); return sprintf(
'<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>',
{
state: this.isOpen ? __('Opened') : __('Closed'),
timeInWords: this.isOpen ? this.createdAtInWords : this.closedAtInWords,
timestamp: this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp,
},
);
}, },
}, },
}; };
...@@ -26,22 +47,70 @@ export default { ...@@ -26,22 +47,70 @@ export default {
'issuable-info-container': !canReorder, 'issuable-info-container': !canReorder,
'card-body': canReorder, 'card-body': canReorder,
}" }"
class="flex" class="item-body"
> >
<div class="block-truncated append-right-8 d-inline-flex"> <div class="item-contents">
<div class="block text-secondary append-right-default"> <div class="item-title d-flex align-items-center">
<icon
v-if="hasState"
v-tooltip
:css-classes="iconClass"
:name="iconName"
:size="16"
:title="stateTitle"
:aria-label="state"
data-html="true"
/>
<icon
v-if="confidential"
v-gl-tooltip
name="eye-slash"
:size="16"
:title="__('Confidential')"
class="confidential-icon append-right-4"
:aria-label="__('Confidential')"
/>
<a :href="computedPath" class="sortable-link">{{ title }}</a>
</div>
<div class="item-meta">
<div class="d-flex align-items-center item-path-id">
<icon <icon
v-if="hasState" v-if="hasState"
v-tooltip v-tooltip
:css-classes="iconClass" :css-classes="iconClass"
:name="iconName" :name="iconName"
:size="12" :size="16"
:title="stateTitle" :title="stateTitle"
:aria-label="state" :aria-label="state"
data-html="true"
/>
<span v-tooltip :title="itemPath" class="path-id-text">{{ itemPath }}</span>
{{ pathIdSeparator }}{{ itemId }}
</div>
<div class="item-meta-child d-flex align-items-center">
<issue-milestone
v-if="milestone"
:milestone="milestone"
class="d-flex align-items-center item-milestone"
/>
<issue-due-date
v-if="dueDate"
:date="dueDate"
tooltip-placement="top"
css-class="item-due-date d-flex align-items-center"
/>
<issue-weight
v-if="weight"
:weight="weight"
class="item-weight d-flex align-items-center"
/>
</div>
<issue-assignees
v-if="assignees.length"
:assignees="assignees"
class="item-assignees d-inline-flex"
/> />
{{ displayReference }}
</div> </div>
<a :href="computedPath" class="issue-token-title-text sortable-link"> {{ title }} </a>
</div> </div>
<button <button
v-if="canRemove" v-if="canRemove"
...@@ -49,13 +118,12 @@ export default { ...@@ -49,13 +118,12 @@ export default {
v-tooltip v-tooltip
:disabled="removeDisabled" :disabled="removeDisabled"
type="button" type="button"
class="btn btn-default js-issue-item-remove-button issue-item-remove-button flex-align-self-center flex-right class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button"
qa-remove-issue-button"
title="Remove" title="Remove"
aria-label="Remove" aria-label="Remove"
@click="onRemoveRequest" @click="onRemoveRequest"
> >
<i class="fa fa-times" aria-hidden="true"> </i> <icon :size="16" class="btn-item-remove-icon" name="close" />
</button> </button>
</div> </div>
</template> </template>
...@@ -60,6 +60,11 @@ export default { ...@@ -60,6 +60,11 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
pathIdSeparator: {
type: String,
required: false,
default: '#',
},
helpPath: { helpPath: {
type: String, type: String,
required: false, required: false,
...@@ -148,16 +153,13 @@ export default { ...@@ -148,16 +153,13 @@ export default {
{{ title }} {{ title }}
<a v-if="hasHelpPath" :href="helpPath"> <a v-if="hasHelpPath" :href="helpPath">
<i <i
class="related-issues-header-help-icon class="related-issues-header-help-icon fa fa-question-circle"
fa fa-question-circle"
aria-label="Read more about related issues" aria-label="Read more about related issues"
> ></i>
</i>
</a> </a>
<div class="d-inline-flex lh-100 align-middle"> <div class="d-inline-flex lh-100 align-middle">
<div <div
class="js-related-issues-header-issue-count class="js-related-issues-header-issue-count related-issues-header-issue-count issue-count-badge mx-1"
related-issues-header-issue-count issue-count-badge mx-1"
> >
<span class="issue-count-badge-count"> <span class="issue-count-badge-count">
<icon name="issues" class="mr-1 text-secondary" /> {{ badgeLabel }} <icon name="issues" class="mr-1 text-secondary" /> {{ badgeLabel }}
...@@ -167,13 +169,12 @@ fa fa-question-circle" ...@@ -167,13 +169,12 @@ fa fa-question-circle"
v-if="canAdmin" v-if="canAdmin"
ref="issueCountBadgeAddButton" ref="issueCountBadgeAddButton"
type="button" type="button"
class="js-issue-count-badge-add-button class="js-issue-count-badge-add-button issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
aria-label="Add an issue" aria-label="Add an issue"
data-placement="top" data-placement="top"
@click="toggleAddRelatedIssuesForm" @click="toggleAddRelatedIssuesForm"
> >
<i class="fa fa-plus" aria-hidden="true"> </i> <i class="fa fa-plus" aria-hidden="true"></i>
</button> </button>
</div> </div>
</h3> </h3>
...@@ -190,6 +191,7 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button" ...@@ -190,6 +191,7 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
:input-value="inputValue" :input-value="inputValue"
:pending-references="pendingReferences" :pending-references="pendingReferences"
:auto-complete-sources="autoCompleteSources" :auto-complete-sources="autoCompleteSources"
:path-id-separator="pathIdSeparator"
/> />
</div> </div>
<div <div
...@@ -206,7 +208,7 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button" ...@@ -206,7 +208,7 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
class="prepend-top-5" class="prepend-top-5"
/> />
</div> </div>
<ul ref="list" :class="{ 'content-list': !canReorder }" class="flex-list issuable-list"> <ul ref="list" :class="{ 'content-list': !canReorder }" class="related-items-list">
<li <li
v-for="issue in relatedIssues" v-for="issue in relatedIssues"
:key="issue.id" :key="issue.id"
...@@ -217,16 +219,24 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button" ...@@ -217,16 +219,24 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
}" }"
:data-key="issue.id" :data-key="issue.id"
:data-epic-issue-id="issue.epic_issue_id" :data-epic-issue-id="issue.epic_issue_id"
class="js-related-issues-token-list-item related-issues-list-item pt-0 pb-0" class="js-related-issues-token-list-item list-item pt-0 pb-0"
> >
<issue-item <issue-item
:id-key="issue.id" :id-key="issue.id"
:display-reference="issue.reference" :display-reference="issue.reference"
:confidential="issue.confidential"
:title="issue.title" :title="issue.title"
:path="issue.path" :path="issue.path"
:state="issue.state" :state="issue.state"
:milestone="issue.milestone"
:due-date="issue.due_date"
:assignees="issue.assignees"
:weight="issue.weight"
:created-at="issue.created_at"
:closed-at="issue.closed_at"
:can-remove="canAdmin" :can-remove="canAdmin"
:can-reorder="canReorder" :can-reorder="canReorder"
:path-id-separator="pathIdSeparator"
event-namespace="relatedIssue" event-namespace="relatedIssue"
/> />
</li> </li>
......
...@@ -246,6 +246,7 @@ export default { ...@@ -246,6 +246,7 @@ export default {
:input-value="inputValue" :input-value="inputValue"
:auto-complete-sources="autoCompleteSources" :auto-complete-sources="autoCompleteSources"
:title="title" :title="title"
path-id-separator="#"
@saveReorder="saveIssueOrder" @saveReorder="saveIssueOrder"
/> />
</template> </template>
import { formatDate } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
const mixins = { const mixins = {
...@@ -17,11 +19,20 @@ const mixins = { ...@@ -17,11 +19,20 @@ const mixins = {
type: String, type: String,
required: true, required: true,
}, },
pathIdSeparator: {
type: String,
required: true,
},
eventNamespace: { eventNamespace: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
}, },
confidential: {
type: Boolean,
required: false,
default: false,
},
title: { title: {
type: String, type: String,
required: false, required: false,
...@@ -37,6 +48,36 @@ const mixins = { ...@@ -37,6 +48,36 @@ const mixins = {
required: false, required: false,
default: '', default: '',
}, },
createdAt: {
type: String,
required: false,
default: '',
},
closedAt: {
type: String,
required: false,
default: '',
},
milestone: {
type: Object,
required: false,
default: () => ({}),
},
dueDate: {
type: String,
required: false,
default: '',
},
assignees: {
type: Array,
required: false,
default: () => [],
},
weight: {
type: Number,
required: false,
default: 0,
},
canRemove: { canRemove: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -49,6 +90,7 @@ const mixins = { ...@@ -49,6 +90,7 @@ const mixins = {
directives: { directives: {
tooltip, tooltip,
}, },
mixins: [timeagoMixin],
computed: { computed: {
hasState() { hasState() {
return this.state && this.state.length > 0; return this.state && this.state.length > 0;
...@@ -63,7 +105,7 @@ const mixins = { ...@@ -63,7 +105,7 @@ const mixins = {
return this.title.length > 0; return this.title.length > 0;
}, },
iconName() { iconName() {
return this.isOpen ? 'issue-open' : 'issue-close'; return this.isOpen ? 'issue-open-m' : 'issue-close';
}, },
iconClass() { iconClass() {
return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed'; return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
...@@ -74,6 +116,24 @@ const mixins = { ...@@ -74,6 +116,24 @@ const mixins = {
computedPath() { computedPath() {
return this.path.length ? this.path : null; return this.path.length ? this.path : null;
}, },
itemPath() {
return this.displayReference.split(this.pathIdSeparator)[0];
},
itemId() {
return this.displayReference.split(this.pathIdSeparator).pop();
},
createdAtInWords() {
return this.createdAt ? this.timeFormated(this.createdAt) : '';
},
createdAtTimestamp() {
return this.createdAt ? formatDate(new Date(this.createdAt)) : '';
},
closedAtInWords() {
return this.closedAt ? this.timeFormated(this.closedAt) : '';
},
closedAtTimestamp() {
return this.closedAt ? formatDate(new Date(this.closedAt)) : '';
},
}, },
methods: { methods: {
onRemoveRequest() { onRemoveRequest() {
......
$item-path-max-width: 160px;
$item-milestone-max-width: 120px;
$item-weight-max-width: 48px;
.related-items-list {
padding: $gl-padding-4;
&,
.list-item:last-child {
margin-bottom: 0;
}
}
.item-body {
display: flex;
position: relative;
align-items: center;
padding: $gl-padding-8;
line-height: $gl-line-height;
.item-contents {
display: flex;
align-items: center;
flex-wrap: wrap;
flex-grow: 1;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed,
.confidential-icon,
.item-milestone .icon,
.item-weight .board-card-info-icon {
min-width: $gl-padding;
cursor: help;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
margin-right: $gl-padding-4;
}
.confidential-icon {
align-self: baseline;
color: $orange-600;
margin-right: $gl-padding-4;
}
.item-title {
flex-basis: 100%;
margin-bottom: $gl-padding-8;
font-size: $gl-font-size-small;
.sortable-link {
max-width: 85%;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: none;
}
}
.item-meta {
display: flex;
flex-wrap: wrap;
flex-basis: 100%;
font-size: $gl-font-size-small;
color: $gl-text-color-secondary;
.item-meta-child {
order: 0;
display: flex;
flex-wrap: wrap;
flex-basis: 100%;
.item-due-date,
.item-weight {
margin-left: $gl-padding-8;
}
.item-milestone,
.item-weight {
cursor: help;
text-decoration: none;
}
.item-milestone {
max-width: $item-milestone-max-width;
}
.item-due-date {
margin-right: 0;
}
.item-weight {
margin-right: 0;
max-width: $item-weight-max-width;
}
}
.item-path-id .path-id-text,
.item-milestone .milestone-title,
.item-due-date,
.item-weight .board-card-info-text {
color: $gl-text-color-secondary;
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.item-path-id {
order: 1;
margin-top: $gl-padding-4;
font-size: $gl-font-size-xs;
.path-id-text {
font-weight: $gl-font-weight-bold;
max-width: $item-path-max-width;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: block;
}
}
.item-milestone .ic-clock {
color: $gl-text-color-tertiary;
margin-right: $gl-padding-4;
}
.item-assignees {
order: 2;
align-self: flex-end;
align-items: center;
margin-left: auto;
.user-avatar-link {
margin-right: -$gl-padding-4;
&:nth-of-type(1) {
z-index: 2;
}
&:nth-of-type(2) {
z-index: 1;
}
&:last-child {
margin-right: 0;
}
}
.avatar {
height: $gl-padding;
width: $gl-padding;
margin-right: 0;
vertical-align: bottom;
}
.avatar-counter {
height: $gl-padding;
border: 1px solid transparent;
background-color: $gl-text-color-tertiary;
font-weight: $gl-font-weight-bold;
padding: 0 $gl-padding-4;
line-height: $gl-padding;
}
}
}
.btn-item-remove {
position: absolute;
right: 0;
top: $gl-padding-4 / 2;
padding: $gl-padding-4;
margin-right: $gl-padding-4 / 2;
line-height: 0;
border-color: transparent;
color: $gl-text-color-secondary;
&:hover {
color: $gl-text-color;
}
}
}
@include media-breakpoint-up(sm) {
.item-body {
.item-contents .item-title .sortable-link {
max-width: 90%;
}
}
}
/* Small devices (landscape phones, 768px and up) */
@include media-breakpoint-up(md) {
.item-body {
.item-contents {
min-width: 0;
.item-title {
flex-basis: unset;
// 98% because we compensate
// for remove button which is
// positioned absolutely
width: 95%;
margin-bottom: $gl-padding-4;
.sortable-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
}
}
.item-meta {
.item-path-id {
order: 0;
margin-top: 0;
}
.item-meta-child {
flex-basis: unset;
margin-left: auto;
margin-right: $gl-padding-4;
~ .item-assignees {
margin-left: $gl-padding-4;
}
}
.item-assignees {
margin-bottom: 0;
margin-left: 0;
order: 2;
}
}
}
.btn-item-remove {
order: 1;
}
}
}
/* Medium devices (desktops, 992px and up) */
@include media-breakpoint-up(lg) {
.item-body {
padding: $gl-padding;
.item-title {
font-size: $gl-font-size;
}
.item-meta .item-path-id {
font-size: inherit; // Base size given to `item-meta` is `$gl-font-size-small`
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
margin-right: $gl-padding-4;
}
}
}
/* Large devices (large desktops, 1200px and up) */
@include media-breakpoint-up(xl) {
.item-body {
padding: $gl-padding-8;
padding-left: $gl-padding;
.item-contents {
flex-wrap: nowrap;
overflow: hidden;
.item-title {
display: flex;
margin-bottom: 0;
min-width: 0;
width: auto;
flex-basis: unset;
font-weight: $gl-font-weight-normal;
.sortable-link {
display: block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: block;
margin-right: $gl-padding-8;
}
.confidential-icon {
align-self: auto;
margin-top: 0;
}
}
.item-meta {
margin-top: 0;
justify-content: flex-end;
flex: 1;
flex-wrap: nowrap;
.item-path-id {
order: 0;
margin-top: 0;
margin-left: $gl-padding-8;
margin-right: auto;
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: none;
}
}
.item-meta-child {
margin-left: $gl-padding-8;
flex-wrap: nowrap;
}
.item-assignees {
flex-grow: 0;
margin-top: 0;
margin-right: $gl-padding-4;
.avatar {
height: $gl-padding-24;
width: $gl-padding-24;
}
.avatar-counter {
height: $gl-padding-24;
line-height: $gl-padding-24;
border-radius: $gl-padding-24;
}
}
}
}
.btn-item-remove {
position: relative;
align-self: center;
top: initial;
right: 0;
margin-right: 0;
padding: $btn-sm-side-margin;
&:hover {
border-color: $border-color;
}
}
}
}
...@@ -33,7 +33,6 @@ $token-spacing-bottom: 0.5em; ...@@ -33,7 +33,6 @@ $token-spacing-bottom: 0.5em;
li .issuable-info-container { li .issuable-info-container {
padding-left: $gl-padding; padding-left: $gl-padding;
padding-right: $gl-padding-4;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
padding-left: $gl-padding-8; padding-left: $gl-padding-8;
......
...@@ -219,9 +219,10 @@ module EE ...@@ -219,9 +219,10 @@ module EE
def update_project_counter_caches def update_project_counter_caches
end end
def issues_readable_by(current_user) def issues_readable_by(current_user, preload: nil)
related_issues = ::Issue.select('issues.*, epic_issues.id as epic_issue_id, epic_issues.relative_position') related_issues = ::Issue.select('issues.*, epic_issues.id as epic_issue_id, epic_issues.relative_position')
.joins(:epic_issue) .joins(:epic_issue)
.preload(preload)
.where("epic_issues.epic_id = #{id}") .where("epic_issues.epic_id = #{id}")
.order('epic_issues.relative_position, epic_issues.id') .order('epic_issues.relative_position, epic_issues.id')
......
...@@ -7,7 +7,7 @@ module EpicIssues ...@@ -7,7 +7,7 @@ module EpicIssues
def child_issuables def child_issuables
return [] unless issuable&.group&.feature_available?(:epics) return [] unless issuable&.group&.feature_available?(:epics)
issuable.issues_readable_by(current_user) issuable.issues_readable_by(current_user, preload: preload_for_collection)
end end
def relation_path(issue) def relation_path(issue)
......
...@@ -18,6 +18,10 @@ module IssuableLinks ...@@ -18,6 +18,10 @@ module IssuableLinks
private private
def preload_for_collection
[{ project: :namespace }, :assignees]
end
def relation_path(object) def relation_path(object)
raise NotImplementedError raise NotImplementedError
end end
...@@ -30,15 +34,24 @@ module IssuableLinks ...@@ -30,15 +34,24 @@ module IssuableLinks
project_issue_path(object.project, object.iid) project_issue_path(object.project, object.iid)
end end
# rubocop: disable CodeReuse/Serializer
def to_hash(object) def to_hash(object)
{ {
id: object.id, id: object.id,
confidential: object.confidential,
title: object.title, title: object.title,
assignees: UserSerializer.new.represent(object.assignees),
state: object.state, state: object.state,
milestone: MilestoneSerializer.new.represent(object.milestone),
weight: object.weight,
reference: reference(object), reference: reference(object),
path: issuable_path(object), path: issuable_path(object),
relation_path: relation_path(object) relation_path: relation_path(object),
due_date: object.due_date,
created_at: object.created_at&.to_s,
closed_at: object.closed_at
} }
end end
# rubocop: enable CodeReuse/Serializer
end end
end end
...@@ -7,7 +7,7 @@ module IssueLinks ...@@ -7,7 +7,7 @@ module IssueLinks
private private
def child_issuables def child_issuables
issuable.related_issues(current_user, preload: { project: :namespace }) issuable.related_issues(current_user, preload: preload_for_collection)
end end
def relation_path(issue) def relation_path(issue)
......
---
title: Epic issue list and related issue list re-design
merge_request:
author:
type: changed
...@@ -3,9 +3,10 @@ require 'spec_helper' ...@@ -3,9 +3,10 @@ require 'spec_helper'
describe Groups::EpicIssuesController do describe Groups::EpicIssuesController do
let(:group) { create(:group, :public) } let(:group) { create(:group, :public) }
let(:project) { create(:project, :public, group: group) } let(:project) { create(:project, :public, group: group) }
let(:milestone) { create(:milestone, project: project) }
let(:epic) { create(:epic, group: group) } let(:epic) { create(:epic, group: group) }
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:issue) { create(:issue, project: project, milestone: milestone, assignees: [user]) }
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
...@@ -45,18 +46,7 @@ describe Groups::EpicIssuesController do ...@@ -45,18 +46,7 @@ describe Groups::EpicIssuesController do
end end
it 'returns the correct json' do it 'returns the correct json' do
expected_result = [ expect(JSON.parse(response.body)).to match_schema('related_issues', dir: 'ee')
{
'id' => issue.id,
'title' => issue.title,
'state' => issue.state,
'reference' => "#{project.full_path}##{issue.iid}",
'path' => "/#{project.full_path}/issues/#{issue.iid}",
'relation_path' => "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue.id}",
'epic_issue_id' => epic_issue.id
}
]
expect(JSON.parse(response.body)).to eq(expected_result)
end end
end end
end end
......
...@@ -32,7 +32,7 @@ describe 'Epic Issues', :js do ...@@ -32,7 +32,7 @@ describe 'Epic Issues', :js do
end end
it 'user can see issues from public project but cannot delete the associations' do it 'user can see issues from public project but cannot delete the associations' do
within('.related-issues-block ul.issuable-list') do within('.related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 1) expect(page).to have_selector('li', count: 1)
expect(page).to have_content(public_issue.title) expect(page).to have_content(public_issue.title)
expect(page).not_to have_selector('button.js-issue-item-remove-button') expect(page).not_to have_selector('button.js-issue-item-remove-button')
...@@ -70,7 +70,7 @@ describe 'Epic Issues', :js do ...@@ -70,7 +70,7 @@ describe 'Epic Issues', :js do
end end
it 'user can see all issues of the group and delete the associations' do it 'user can see all issues of the group and delete the associations' do
within('.related-issues-block ul.issuable-list') do within('.related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 2) expect(page).to have_selector('li', count: 2)
expect(page).to have_content(public_issue.title) expect(page).to have_content(public_issue.title)
expect(page).to have_content(private_issue.title) expect(page).to have_content(private_issue.title)
...@@ -80,7 +80,7 @@ describe 'Epic Issues', :js do ...@@ -80,7 +80,7 @@ describe 'Epic Issues', :js do
wait_for_requests wait_for_requests
within('.related-issues-block ul.issuable-list') do within('.related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 1) expect(page).to have_selector('li', count: 1)
end end
end end
...@@ -100,7 +100,7 @@ describe 'Epic Issues', :js do ...@@ -100,7 +100,7 @@ describe 'Epic Issues', :js do
expect(page).not_to have_selector('.content-wrapper .alert-wrapper .flash-text') expect(page).not_to have_selector('.content-wrapper .alert-wrapper .flash-text')
expect(page).not_to have_content('No Issue found for given params') expect(page).not_to have_content('No Issue found for given params')
within('.related-issues-block ul.issuable-list') do within('.related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 3) expect(page).to have_selector('li', count: 3)
expect(page).to have_content(issue_to_add.title) expect(page).to have_content(issue_to_add.title)
end end
...@@ -110,7 +110,7 @@ describe 'Epic Issues', :js do ...@@ -110,7 +110,7 @@ describe 'Epic Issues', :js do
expect(first('.js-related-issues-token-list-item')).to have_content(public_issue.title) expect(first('.js-related-issues-token-list-item')).to have_content(public_issue.title)
expect(page.all('.js-related-issues-token-list-item').last).to have_content(private_issue.title) expect(page.all('.js-related-issues-token-list-item').last).to have_content(private_issue.title)
drag_to(selector: '.issuable-list', to_index: 1) drag_to(selector: '.related-items-list', to_index: 1)
expect(first('.js-related-issues-token-list-item')).to have_content(private_issue.title) expect(first('.js-related-issues-token-list-item')).to have_content(private_issue.title)
expect(page.all('.js-related-issues-token-list-item').last).to have_content(public_issue.title) expect(page.all('.js-related-issues-token-list-item').last).to have_content(public_issue.title)
......
...@@ -258,7 +258,7 @@ describe 'Related issues', :js do ...@@ -258,7 +258,7 @@ describe 'Related issues', :js do
wait_for_requests wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text') items = all('.item-title a')
# Form gets hidden after submission # Form gets hidden after submission
expect(page).not_to have_selector('.js-add-related-issues-form-area') expect(page).not_to have_selector('.js-add-related-issues-form-area')
...@@ -275,7 +275,7 @@ describe 'Related issues', :js do ...@@ -275,7 +275,7 @@ describe 'Related issues', :js do
wait_for_requests wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text') items = all('.item-title a')
expect(items.count).to eq(1) expect(items.count).to eq(1)
expect(items[0].text).to eq(issue_project_b_a.title) expect(items[0].text).to eq(issue_project_b_a.title)
...@@ -289,7 +289,7 @@ describe 'Related issues', :js do ...@@ -289,7 +289,7 @@ describe 'Related issues', :js do
wait_for_requests wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text') items = all('.item-title a')
expect(items.count).to eq(1) expect(items.count).to eq(1)
expect(items[0].text).to eq(issue_project_b_a.title) expect(items[0].text).to eq(issue_project_b_a.title)
...@@ -311,7 +311,7 @@ describe 'Related issues', :js do ...@@ -311,7 +311,7 @@ describe 'Related issues', :js do
end end
it 'shows related issues' do it 'shows related issues' do
items = all('.js-related-issues-token-list-item .issue-token-title-text') items = all('.item-title a')
expect(items.count).to eq(2) expect(items.count).to eq(2)
expect(items[0].text).to eq(issue_b.title) expect(items[0].text).to eq(issue_b.title)
...@@ -319,7 +319,7 @@ describe 'Related issues', :js do ...@@ -319,7 +319,7 @@ describe 'Related issues', :js do
end end
it 'allows us to remove a related issues' do it 'allows us to remove a related issues' do
items_before = all('.js-related-issues-token-list-item .issue-token-title-text') items_before = all('.item-title a')
expect(items_before.count).to eq(2) expect(items_before.count).to eq(2)
...@@ -327,7 +327,7 @@ describe 'Related issues', :js do ...@@ -327,7 +327,7 @@ describe 'Related issues', :js do
wait_for_requests wait_for_requests
items_after = all('.js-related-issues-token-list-item .issue-token-title-text') items_after = all('.item-title a')
expect(items_after.count).to eq(1) expect(items_after.count).to eq(1)
end end
...@@ -339,7 +339,7 @@ describe 'Related issues', :js do ...@@ -339,7 +339,7 @@ describe 'Related issues', :js do
wait_for_requests wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text') items = all('.item-title a')
expect(items.count).to eq(3) expect(items.count).to eq(3)
expect(items[0].text).to eq(issue_b.title) expect(items[0].text).to eq(issue_b.title)
...@@ -355,7 +355,7 @@ describe 'Related issues', :js do ...@@ -355,7 +355,7 @@ describe 'Related issues', :js do
wait_for_requests wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text') items = all('.item-title a')
expect(items.count).to eq(2) expect(items.count).to eq(2)
expect(items[0].text).to eq(issue_b.title) expect(items[0].text).to eq(issue_b.title)
...@@ -370,7 +370,7 @@ describe 'Related issues', :js do ...@@ -370,7 +370,7 @@ describe 'Related issues', :js do
wait_for_requests wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text') items = all('.item-title a')
expect(items.count).to eq(2) expect(items.count).to eq(2)
expect(items[0].text).to eq(issue_b.title) expect(items[0].text).to eq(issue_b.title)
......
{
"type": "object",
"additionalProperties": false,
"required": [
"id",
"confidential",
"title",
"assignees",
"milestone",
"due_date",
"state",
"reference",
"path",
"relation_path",
"weight"
],
"properties": {
"id": { "type": "integer" },
"confidential": { "type": "boolean" },
"title": { "type": "string" },
"assignees": { "type": "array" },
"milestone": { "type": ["object", "null"] },
"due_date": { "type": ["string", "null"] },
"state": { "type": "string" },
"weight": { "type": ["integer", "null"] },
"reference": { "type": "string" },
"path": { "type": "string" },
"relation_path": { "type": "string" },
"epic_issue_id": { "type": ["integer", "null"] },
"created_at": { "type": "string" },
"closed_at": { "type": ["string", "null"] }
}
}
{
"type": "array",
"items": { "$ref": "related_issue.json" }
}
...@@ -48,6 +48,6 @@ describe('Issue card component', () => { ...@@ -48,6 +48,6 @@ describe('Issue card component', () => {
const el = vm.$el.querySelector('.board-card-weight'); const el = vm.$el.querySelector('.board-card-weight');
expect(el).not.toBeNull(); expect(el).not.toBeNull();
expect(el.textContent.trim()).toBe('2'); expect(el.textContent.trim()).toContain('2');
}); });
}); });
...@@ -55,7 +55,7 @@ describe('EpicBodyComponent', () => { ...@@ -55,7 +55,7 @@ describe('EpicBodyComponent', () => {
expect(vm.$el.querySelector('.related-issues-block')).not.toBeNull(); expect(vm.$el.querySelector('.related-issues-block')).not.toBeNull();
expect(vm.$el.querySelector('.js-related-issues-header-issue-count')).not.toBeNull(); expect(vm.$el.querySelector('.js-related-issues-header-issue-count')).not.toBeNull();
expect(vm.$el.querySelector('.related-issues-token-body')).not.toBeNull(); expect(vm.$el.querySelector('.related-issues-token-body')).not.toBeNull();
expect(vm.$el.querySelector('.issuable-list')).not.toBeNull(); expect(vm.$el.querySelector('.related-items-list')).not.toBeNull();
}); });
}); });
}); });
...@@ -21,6 +21,8 @@ const issuable2 = { ...@@ -21,6 +21,8 @@ const issuable2 = {
state: 'opened', state: 'opened',
}; };
const pathIdSeparator = '#';
describe('AddIssuableForm', () => { describe('AddIssuableForm', () => {
let AddIssuableForm; let AddIssuableForm;
let vm; let vm;
...@@ -47,6 +49,7 @@ describe('AddIssuableForm', () => { ...@@ -47,6 +49,7 @@ describe('AddIssuableForm', () => {
propsData: { propsData: {
inputValue: '', inputValue: '',
pendingReferences: [], pendingReferences: [],
pathIdSeparator,
}, },
}).$mount(); }).$mount();
}); });
...@@ -63,6 +66,7 @@ describe('AddIssuableForm', () => { ...@@ -63,6 +66,7 @@ describe('AddIssuableForm', () => {
propsData: { propsData: {
inputValue: 'foo', inputValue: 'foo',
pendingReferences: [], pendingReferences: [],
pathIdSeparator,
}, },
}).$mount(); }).$mount();
}); });
...@@ -81,6 +85,7 @@ describe('AddIssuableForm', () => { ...@@ -81,6 +85,7 @@ describe('AddIssuableForm', () => {
propsData: { propsData: {
inputValue, inputValue,
pendingReferences: [issuable1.reference, issuable2.reference], pendingReferences: [issuable1.reference, issuable2.reference],
pathIdSeparator,
}, },
}).$mount(); }).$mount();
}); });
...@@ -105,6 +110,7 @@ describe('AddIssuableForm', () => { ...@@ -105,6 +110,7 @@ describe('AddIssuableForm', () => {
inputValue: '', inputValue: '',
pendingReferences: [issuable1.reference, issuable2.reference], pendingReferences: [issuable1.reference, issuable2.reference],
isSubmitting: true, isSubmitting: true,
pathIdSeparator,
}, },
}).$mount(); }).$mount();
}); });
...@@ -125,6 +131,7 @@ describe('AddIssuableForm', () => { ...@@ -125,6 +131,7 @@ describe('AddIssuableForm', () => {
autoCompleteSources: { autoCompleteSources: {
issues: '/fake/issues/path', issues: '/fake/issues/path',
}, },
pathIdSeparator,
}, },
}).$mount(); }).$mount();
}); });
...@@ -144,6 +151,7 @@ describe('AddIssuableForm', () => { ...@@ -144,6 +151,7 @@ describe('AddIssuableForm', () => {
propsData: { propsData: {
inputValue: '', inputValue: '',
autoCompleteSources: {}, autoCompleteSources: {},
pathIdSeparator,
}, },
}).$mount(); }).$mount();
}); });
...@@ -185,6 +193,7 @@ describe('AddIssuableForm', () => { ...@@ -185,6 +193,7 @@ describe('AddIssuableForm', () => {
autoCompleteSources: { autoCompleteSources: {
issues: '/fake/issues/path', issues: '/fake/issues/path',
}, },
pathIdSeparator,
}, },
}).$mount(el); }).$mount(el);
}); });
......
...@@ -2,14 +2,22 @@ import Vue from 'vue'; ...@@ -2,14 +2,22 @@ import Vue from 'vue';
import issueItem from 'ee/related_issues/components/issue_item.vue'; import issueItem from 'ee/related_issues/components/issue_item.vue';
import eventHub from 'ee/related_issues/event_hub'; import eventHub from 'ee/related_issues/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { defaultMilestone, defaultAssignees } from '../mock_data';
describe('issueItem', () => { describe('issueItem', () => {
let vm; let vm;
const props = { const props = {
idKey: 1, idKey: 1,
displayReference: '#1', displayReference: 'gitlab-org/gitlab-test#1',
pathIdSeparator: '#',
path: `${gl.TEST_HOST}/path`, path: `${gl.TEST_HOST}/path`,
title: 'title', title: 'title',
confidential: true,
dueDate: '2018-12-31',
weight: 10,
createdAt: '2018-12-01T00:00:00.00Z',
milestone: defaultMilestone,
assignees: defaultAssignees,
}; };
beforeEach(() => { beforeEach(() => {
...@@ -22,12 +30,6 @@ describe('issueItem', () => { ...@@ -22,12 +30,6 @@ describe('issueItem', () => {
expect(vm.$el.querySelector('.issuable-info-container')).toBeNull(); expect(vm.$el.querySelector('.issuable-info-container')).toBeNull();
}); });
it('renders displayReference', () => {
expect(vm.$el.querySelector('.text-secondary').innerText.trim()).toEqual(
props.displayReference,
);
});
it('does not render token state', () => { it('does not render token state', () => {
expect(vm.$el.querySelector('.text-secondary svg')).toBeNull(); expect(vm.$el.querySelector('.text-secondary svg')).toBeNull();
}); });
...@@ -38,11 +40,17 @@ describe('issueItem', () => { ...@@ -38,11 +40,17 @@ describe('issueItem', () => {
describe('token title', () => { describe('token title', () => {
it('links to computedPath', () => { it('links to computedPath', () => {
expect(vm.$el.querySelector('a').href).toEqual(props.path); expect(vm.$el.querySelector('.item-title a').href).toEqual(props.path);
});
it('renders confidential icon', () => {
expect(
vm.$el.querySelector('.item-title svg.confidential-icon use').getAttribute('xlink:href'),
).toContain('eye-slash');
}); });
it('renders title', () => { it('renders title', () => {
expect(vm.$el.querySelector('a').innerText.trim()).toEqual(props.title); expect(vm.$el.querySelector('.item-title a').innerText.trim()).toEqual(props.title);
}); });
}); });
...@@ -52,7 +60,7 @@ describe('issueItem', () => { ...@@ -52,7 +60,7 @@ describe('issueItem', () => {
beforeEach(done => { beforeEach(done => {
vm.state = 'opened'; vm.state = 'opened';
Vue.nextTick(() => { Vue.nextTick(() => {
tokenState = vm.$el.querySelector('.text-secondary svg'); tokenState = vm.$el.querySelector('.item-meta svg');
done(); done();
}); });
}); });
...@@ -62,7 +70,12 @@ describe('issueItem', () => { ...@@ -62,7 +70,12 @@ describe('issueItem', () => {
}); });
it('renders state title', () => { it('renders state title', () => {
expect(tokenState.getAttribute('data-original-title')).toEqual('Open'); const stateTitle = tokenState.getAttribute('data-original-title').trim();
expect(stateTitle).toContain('<span class="bold">Opened</span>');
expect(stateTitle).toContain(
'<span class="text-tertiary">Dec 1, 2018 12:00am GMT+0000</span>',
);
}); });
it('renders aria label', () => { it('renders aria label', () => {
...@@ -75,6 +88,7 @@ describe('issueItem', () => { ...@@ -75,6 +88,7 @@ describe('issueItem', () => {
it('renders close icon when close state', done => { it('renders close icon when close state', done => {
vm.state = 'closed'; vm.state = 'closed';
vm.closedAt = '2018-12-01T00:00:00.00Z';
Vue.nextTick(() => { Vue.nextTick(() => {
expect(tokenState.classList.contains('issue-token-state-icon-closed')).toEqual(true); expect(tokenState.classList.contains('issue-token-state-icon-closed')).toEqual(true);
...@@ -83,6 +97,57 @@ describe('issueItem', () => { ...@@ -83,6 +97,57 @@ describe('issueItem', () => {
}); });
}); });
describe('token metadata', () => {
let tokenMetadata;
beforeEach(done => {
Vue.nextTick(() => {
tokenMetadata = vm.$el.querySelector('.item-meta');
done();
});
});
it('renders item path and ID', () => {
const pathAndID = tokenMetadata.querySelector('.item-path-id').innerText.trim();
expect(pathAndID).toContain('gitlab-org/gitlab-test');
expect(pathAndID).toContain('#1');
});
it('renders milestone icon and name', () => {
const milestoneIconEl = tokenMetadata.querySelector('.item-milestone svg use');
const milestoneTitle = tokenMetadata.querySelector('.item-milestone .milestone-title');
expect(milestoneIconEl.getAttribute('xlink:href')).toContain('clock');
expect(milestoneTitle.innerText.trim()).toContain('Milestone title');
});
it('renders date icon and due date', () => {
const dueDateIconEl = tokenMetadata.querySelector('.item-due-date svg use');
const dueDateEl = tokenMetadata.querySelector('.item-due-date time');
expect(dueDateIconEl.getAttribute('xlink:href')).toContain('calendar');
expect(dueDateEl.innerText.trim()).toContain('Dec 31');
});
it('renders weight icon and value', () => {
const dueDateIconEl = tokenMetadata.querySelector('.item-weight svg use');
const dueDateEl = tokenMetadata.querySelector('.item-weight span');
expect(dueDateIconEl.getAttribute('xlink:href')).toContain('weight');
expect(dueDateEl.innerText.trim()).toContain('10');
});
});
describe('token assignees', () => {
it('renders assignees avatars', () => {
const assigneesEl = vm.$el.querySelector('.item-assignees');
expect(assigneesEl.querySelectorAll('.user-avatar-link').length).toBe(2);
expect(assigneesEl.querySelector('.avatar-counter').innerText.trim()).toContain('+2');
});
});
describe('remove button', () => { describe('remove button', () => {
let removeBtn; let removeBtn;
......
...@@ -6,6 +6,7 @@ describe('IssueToken', () => { ...@@ -6,6 +6,7 @@ describe('IssueToken', () => {
const idKey = 200; const idKey = 200;
const displayReference = 'foo/bar#123'; const displayReference = 'foo/bar#123';
const title = 'some title'; const title = 'some title';
const pathIdSeparator = '#';
let IssueToken; let IssueToken;
let vm; let vm;
...@@ -25,6 +26,7 @@ describe('IssueToken', () => { ...@@ -25,6 +26,7 @@ describe('IssueToken', () => {
propsData: { propsData: {
idKey, idKey,
displayReference, displayReference,
pathIdSeparator,
}, },
}).$mount(); }).$mount();
}); });
...@@ -45,6 +47,7 @@ describe('IssueToken', () => { ...@@ -45,6 +47,7 @@ describe('IssueToken', () => {
propsData: { propsData: {
idKey, idKey,
displayReference, displayReference,
pathIdSeparator,
title, title,
}, },
}).$mount(); }).$mount();
...@@ -63,6 +66,7 @@ describe('IssueToken', () => { ...@@ -63,6 +66,7 @@ describe('IssueToken', () => {
propsData: { propsData: {
idKey, idKey,
displayReference, displayReference,
pathIdSeparator,
title, title,
path, path,
}, },
...@@ -81,6 +85,7 @@ describe('IssueToken', () => { ...@@ -81,6 +85,7 @@ describe('IssueToken', () => {
propsData: { propsData: {
idKey, idKey,
displayReference, displayReference,
pathIdSeparator,
state: 'opened', state: 'opened',
}, },
}).$mount(); }).$mount();
...@@ -97,6 +102,7 @@ describe('IssueToken', () => { ...@@ -97,6 +102,7 @@ describe('IssueToken', () => {
propsData: { propsData: {
idKey, idKey,
displayReference, displayReference,
pathIdSeparator,
state: 'reopened', state: 'reopened',
}, },
}).$mount(); }).$mount();
...@@ -113,6 +119,7 @@ describe('IssueToken', () => { ...@@ -113,6 +119,7 @@ describe('IssueToken', () => {
propsData: { propsData: {
idKey, idKey,
displayReference, displayReference,
pathIdSeparator,
state: 'closed', state: 'closed',
}, },
}).$mount(); }).$mount();
...@@ -131,6 +138,7 @@ describe('IssueToken', () => { ...@@ -131,6 +138,7 @@ describe('IssueToken', () => {
propsData: { propsData: {
idKey, idKey,
displayReference, displayReference,
pathIdSeparator,
title, title,
state, state,
}, },
...@@ -153,6 +161,7 @@ describe('IssueToken', () => { ...@@ -153,6 +161,7 @@ describe('IssueToken', () => {
propsData: { propsData: {
idKey, idKey,
displayReference, displayReference,
pathIdSeparator,
}, },
}).$mount(); }).$mount();
}); });
...@@ -168,6 +177,7 @@ describe('IssueToken', () => { ...@@ -168,6 +177,7 @@ describe('IssueToken', () => {
propsData: { propsData: {
idKey, idKey,
displayReference, displayReference,
pathIdSeparator,
canRemove: true, canRemove: true,
}, },
}).$mount(); }).$mount();
...@@ -187,6 +197,7 @@ describe('IssueToken', () => { ...@@ -187,6 +197,7 @@ describe('IssueToken', () => {
propsData: { propsData: {
idKey, idKey,
displayReference, displayReference,
pathIdSeparator,
}, },
}).$mount(); }).$mount();
removeRequestSpy = jasmine.createSpy('spy'); removeRequestSpy = jasmine.createSpy('spy');
......
...@@ -7,6 +7,7 @@ export const defaultProps = { ...@@ -7,6 +7,7 @@ export const defaultProps = {
export const issuable1 = { export const issuable1 = {
id: 200, id: 200,
epic_issue_id: 1, epic_issue_id: 1,
confidential: false,
reference: 'foo/bar#123', reference: 'foo/bar#123',
displayReference: '#123', displayReference: '#123',
title: 'some title', title: 'some title',
...@@ -17,6 +18,7 @@ export const issuable1 = { ...@@ -17,6 +18,7 @@ export const issuable1 = {
export const issuable2 = { export const issuable2 = {
id: 201, id: 201,
epic_issue_id: 2, epic_issue_id: 2,
confidential: false,
reference: 'foo/bar#124', reference: 'foo/bar#124',
displayReference: '#124', displayReference: '#124',
title: 'some other thing', title: 'some other thing',
...@@ -27,6 +29,7 @@ export const issuable2 = { ...@@ -27,6 +29,7 @@ export const issuable2 = {
export const issuable3 = { export const issuable3 = {
id: 202, id: 202,
epic_issue_id: 3, epic_issue_id: 3,
confidential: false,
reference: 'foo/bar#125', reference: 'foo/bar#125',
displayReference: '#125', displayReference: '#125',
title: 'some other other thing', title: 'some other other thing',
...@@ -37,6 +40,7 @@ export const issuable3 = { ...@@ -37,6 +40,7 @@ export const issuable3 = {
export const issuable4 = { export const issuable4 = {
id: 203, id: 203,
epic_issue_id: 4, epic_issue_id: 4,
confidential: false,
reference: 'foo/bar#126', reference: 'foo/bar#126',
displayReference: '#126', displayReference: '#126',
title: 'some other other other thing', title: 'some other other other thing',
...@@ -47,9 +51,61 @@ export const issuable4 = { ...@@ -47,9 +51,61 @@ export const issuable4 = {
export const issuable5 = { export const issuable5 = {
id: 204, id: 204,
epic_issue_id: 5, epic_issue_id: 5,
confidential: false,
reference: 'foo/bar#127', reference: 'foo/bar#127',
displayReference: '#127', displayReference: '#127',
title: 'some other other other thing', title: 'some other other other thing',
path: '/foo/bar/issues/127', path: '/foo/bar/issues/127',
state: 'opened', state: 'opened',
}; };
export const defaultMilestone = {
id: 1,
state: 'active',
title: 'Milestone title',
start_date: '2018-01-01',
due_date: '2019-12-31',
};
export const defaultAssignees = [
{
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url: `${gl.TEST_HOST}`,
web_url: `${gl.TEST_HOST}/root`,
status_tooltip_html: null,
path: '/root',
},
{
id: 13,
name: 'Brooks Beatty',
username: 'brynn_champlin',
state: 'active',
avatar_url: `${gl.TEST_HOST}`,
web_url: `${gl.TEST_HOST}/brynn_champlin`,
status_tooltip_html: null,
path: '/brynn_champlin',
},
{
id: 6,
name: 'Bryce Turcotte',
username: 'melynda',
state: 'active',
avatar_url: `${gl.TEST_HOST}`,
web_url: `${gl.TEST_HOST}/melynda`,
status_tooltip_html: null,
path: '/melynda',
},
{
id: 20,
name: 'Conchita Eichmann',
username: 'juliana_gulgowski',
state: 'active',
avatar_url: `${gl.TEST_HOST}`,
web_url: `${gl.TEST_HOST}/juliana_gulgowski`,
status_tooltip_html: null,
path: '/juliana_gulgowski',
},
];
...@@ -7,7 +7,7 @@ describe EpicIssues::ListService do ...@@ -7,7 +7,7 @@ describe EpicIssues::ListService do
let(:other_project) { create(:project_empty_repo, group: group) } let(:other_project) { create(:project_empty_repo, group: group) }
let(:epic) { create(:epic, group: group) } let(:epic) { create(:epic, group: group) }
let(:issue1) { create :issue, project: project } let(:issue1) { create :issue, project: project, weight: 1 }
let(:issue2) { create :issue, project: project } let(:issue2) { create :issue, project: project }
let(:issue3) { create :issue, project: other_project } let(:issue3) { create :issue, project: other_project }
...@@ -31,6 +31,36 @@ describe EpicIssues::ListService do ...@@ -31,6 +31,36 @@ describe EpicIssues::ListService do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
end end
it 'does not have N+1 queries', :use_clean_rails_memory_store_caching, :request_store do
# The control query is made with the worst case scenario:
# * Two different issues from two different projects that belong to two different groups
# Then a new group with a new project is created and we do the call again to check if there will be no
# additional queries.
group.add_developer(user)
list_service = described_class.new(epic, user)
new_group = create(:group, :private)
new_group.add_developer(user)
new_project = create(:project, namespace: new_group)
milestone = create(:milestone, project: project)
milestone2 = create(:milestone, project: new_project)
new_issue1 = create(:issue, project: project, milestone: milestone, assignees: [user])
new_issue3 = create(:issue, project: new_project, milestone: milestone2)
create(:epic_issue, issue: new_issue1, epic: epic, relative_position: 3)
create(:epic_issue, issue: new_issue3, epic: epic, relative_position: 5)
control_count = ActiveRecord::QueryRecorder.new { list_service.execute }.count
new_group2 = create(:group, :private)
new_project2 = create(:project, namespace: new_group2)
new_group2.add_developer(user)
milestone3 = create(:milestone, project: new_project2)
new_issue4 = create(:issue, project: new_project, milestone: milestone3)
create(:epic_issue, issue: new_issue4, epic: epic, relative_position: 6)
expect { list_service.execute }.not_to exceed_query_limit(control_count)
end
context 'owner can see all issues and destroy their associations' do context 'owner can see all issues and destroy their associations' do
before do before do
group.add_developer(user) group.add_developer(user)
...@@ -41,31 +71,53 @@ describe EpicIssues::ListService do ...@@ -41,31 +71,53 @@ describe EpicIssues::ListService do
{ {
id: issue2.id, id: issue2.id,
title: issue2.title, title: issue2.title,
assignees: [],
state: issue2.state, state: issue2.state,
milestone: nil,
weight: nil,
confidential: false,
reference: issue2.to_reference(full: true), reference: issue2.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue2.iid}", path: "/#{project.full_path}/issues/#{issue2.iid}",
relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue2.id}", relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue2.id}",
epic_issue_id: epic_issue2.id epic_issue_id: epic_issue2.id,
due_date: nil,
created_at: issue2.created_at.to_s,
closed_at: issue2.closed_at
}, },
{ {
id: issue1.id, id: issue1.id,
title: issue1.title, title: issue1.title,
assignees: [],
state: issue1.state, state: issue1.state,
milestone: nil,
weight: 1,
confidential: false,
reference: issue1.to_reference(full: true), reference: issue1.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue1.iid}", path: "/#{project.full_path}/issues/#{issue1.iid}",
relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue1.id}", relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue1.id}",
epic_issue_id: epic_issue1.id epic_issue_id: epic_issue1.id,
due_date: nil,
created_at: issue1.created_at.to_s,
closed_at: issue1.closed_at
}, },
{ {
id: issue3.id, id: issue3.id,
title: issue3.title, title: issue3.title,
assignees: [],
state: issue3.state, state: issue3.state,
milestone: nil,
weight: nil,
confidential: false,
reference: issue3.to_reference(full: true), reference: issue3.to_reference(full: true),
path: "/#{other_project.full_path}/issues/#{issue3.iid}", path: "/#{other_project.full_path}/issues/#{issue3.iid}",
relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue3.id}", relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue3.id}",
epic_issue_id: epic_issue3.id epic_issue_id: epic_issue3.id,
due_date: nil,
created_at: issue3.created_at.to_s,
closed_at: issue3.closed_at
} }
] ]
expect(subject).to eq(expected_result) expect(subject).to eq(expected_result)
end end
end end
...@@ -80,20 +132,34 @@ describe EpicIssues::ListService do ...@@ -80,20 +132,34 @@ describe EpicIssues::ListService do
{ {
id: issue2.id, id: issue2.id,
title: issue2.title, title: issue2.title,
assignees: [],
state: issue2.state, state: issue2.state,
milestone: nil,
weight: nil,
confidential: false,
reference: issue2.to_reference(full: true), reference: issue2.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue2.iid}", path: "/#{project.full_path}/issues/#{issue2.iid}",
relation_path: nil, relation_path: nil,
epic_issue_id: epic_issue2.id epic_issue_id: epic_issue2.id,
due_date: nil,
created_at: issue2.created_at.to_s,
closed_at: issue2.closed_at
}, },
{ {
id: issue1.id, id: issue1.id,
title: issue1.title, title: issue1.title,
assignees: [],
state: issue1.state, state: issue1.state,
milestone: nil,
weight: 1,
confidential: false,
reference: issue1.to_reference(full: true), reference: issue1.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue1.iid}", path: "/#{project.full_path}/issues/#{issue1.iid}",
relation_path: nil, relation_path: nil,
epic_issue_id: epic_issue1.id epic_issue_id: epic_issue1.id,
due_date: nil,
created_at: issue1.created_at.to_s,
closed_at: issue1.closed_at
} }
] ]
......
...@@ -39,8 +39,9 @@ describe IssueLinks::ListService do ...@@ -39,8 +39,9 @@ describe IssueLinks::ListService do
control_count = ActiveRecord::QueryRecorder.new { subject }.count control_count = ActiveRecord::QueryRecorder.new { subject }.count
project = create :project, :public project = create :project, :public
issue_x = create :issue, project: project milestone = create :milestone, project: project
issue_y = create :issue, project: project issue_x = create :issue, project: project, milestone: milestone
issue_y = create :issue, project: project, assignees: [user]
issue_z = create :issue, project: project issue_z = create :issue, project: project
create :issue_link, source: issue_x, target: issue_y create :issue_link, source: issue_x, target: issue_y
create :issue_link, source: issue_x, target: issue_z create :issue_link, source: issue_x, target: issue_z
......
...@@ -1061,6 +1061,9 @@ msgstr "" ...@@ -1061,6 +1061,9 @@ msgstr ""
msgid "Available specific runners" msgid "Available specific runners"
msgstr "" msgstr ""
msgid "Avatar for %{assigneeName}"
msgstr ""
msgid "Avatar will be removed. Are you sure?" msgid "Avatar will be removed. Are you sure?"
msgstr "" msgstr ""
...@@ -3456,6 +3459,9 @@ msgstr "" ...@@ -3456,6 +3459,9 @@ msgstr ""
msgid "Expiration date" msgid "Expiration date"
msgstr "" msgstr ""
msgid "Expired %{expiredOn}"
msgstr ""
msgid "Expires in %{expires_at}" msgid "Expires in %{expires_at}"
msgstr "" msgstr ""
...@@ -8196,9 +8202,15 @@ msgstr "" ...@@ -8196,9 +8202,15 @@ msgstr ""
msgid "Started" msgid "Started"
msgstr "" msgstr ""
msgid "Started %{startsIn}"
msgstr ""
msgid "Starting..." msgid "Starting..."
msgstr "" msgstr ""
msgid "Starts %{startsIn}"
msgstr ""
msgid "Starts at (UTC)" msgid "Starts at (UTC)"
msgstr "" msgstr ""
......
...@@ -130,3 +130,12 @@ export const mockAssigneesList = [ ...@@ -130,3 +130,12 @@ export const mockAssigneesList = [
path: '/root', path: '/root',
}, },
]; ];
export const mockMilestone = {
id: 1,
state: 'active',
title: 'Milestone title',
description: 'Harum corporis aut consequatur quae dolorem error sequi quia.',
start_date: '2018-01-01',
due_date: '2019-12-31',
};
import Vue from 'vue';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from 'spec/boards/mock_data';
const createComponent = (assignees = mockAssigneesList, cssClass = '') => {
const Component = Vue.extend(IssueAssignees);
return mountComponent(Component, {
assignees,
cssClass,
});
};
describe('IssueAssigneesComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.maxVisibleAssignees).toBe(2);
expect(vm.maxAssigneeAvatars).toBe(3);
expect(vm.maxAssignees).toBe(99);
});
});
describe('computed', () => {
describe('countOverLimit', () => {
it('should return difference between assignees count and maxVisibleAssignees', () => {
expect(vm.countOverLimit).toBe(mockAssigneesList.length - vm.maxVisibleAssignees);
});
});
describe('assigneesToShow', () => {
it('should return assignees containing only 2 items when count more than maxAssigneeAvatars', () => {
expect(vm.assigneesToShow.length).toBe(2);
});
it('should return all assignees as it is when count less than maxAssigneeAvatars', () => {
vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees
expect(vm.assigneesToShow.length).toBe(3);
});
});
describe('assigneesCounterTooltip', () => {
it('should return string containing count of remaining assignees when count more than maxAssigneeAvatars', () => {
expect(vm.assigneesCounterTooltip).toBe('3 more assignees');
});
});
describe('shouldRenderAssigneesCounter', () => {
it('should return `false` when assignees count less than maxAssigneeAvatars', () => {
vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees
expect(vm.shouldRenderAssigneesCounter).toBe(false);
});
it('should return `true` when assignees count more than maxAssigneeAvatars', () => {
expect(vm.shouldRenderAssigneesCounter).toBe(true);
});
});
describe('assigneeCounterLabel', () => {
it('should return count of additional assignees total assignees count more than maxAssigneeAvatars', () => {
expect(vm.assigneeCounterLabel).toBe('+3');
});
});
});
describe('methods', () => {
describe('avatarUrlTitle', () => {
it('returns string containing alt text for assignee avatar', () => {
expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham');
});
});
});
describe('template', () => {
it('renders component root element with class `issue-assignees`', () => {
expect(vm.$el.classList.contains('issue-assignees')).toBe(true);
});
it('renders assignee avatars', () => {
expect(vm.$el.querySelectorAll('.user-avatar-link').length).toBe(2);
});
it('renders assignee tooltips', () => {
const tooltipText = vm.$el
.querySelectorAll('.user-avatar-link')[0]
.querySelector('.js-assignee-tooltip').innerText;
expect(tooltipText).toContain('Assignee');
expect(tooltipText).toContain('Terrell Graham');
expect(tooltipText).toContain('@monserrate.gleichner');
});
it('renders additional assignees count', () => {
const avatarCounterEl = vm.$el.querySelector('.avatar-counter');
expect(avatarCounterEl.innerText.trim()).toBe('+3');
expect(avatarCounterEl.getAttribute('data-original-title')).toBe('3 more assignees');
});
});
});
import Vue from 'vue';
import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockMilestone } from 'spec/boards/mock_data';
const createComponent = (milestone = mockMilestone) => {
const Component = Vue.extend(IssueMilestone);
return mountComponent(Component, {
milestone,
});
};
describe('IssueMilestoneComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('isMilestoneStarted', () => {
it('should return `false` when milestoneStart prop is not defined', done => {
const vmStartUndefined = createComponent(
Object.assign({}, mockMilestone, {
start_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmStartUndefined.isMilestoneStarted).toBe(false);
})
.then(done)
.catch(done.fail);
vmStartUndefined.$destroy();
});
it('should return `true` when milestone start date is past current date', done => {
const vmStarted = createComponent(
Object.assign({}, mockMilestone, {
start_date: '1990-07-22',
}),
);
Vue.nextTick()
.then(() => {
expect(vmStarted.isMilestoneStarted).toBe(true);
})
.then(done)
.catch(done.fail);
vmStarted.$destroy();
});
});
describe('isMilestonePastDue', () => {
it('should return `false` when milestoneDue prop is not defined', done => {
const vmDueUndefined = createComponent(
Object.assign({}, mockMilestone, {
due_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmDueUndefined.isMilestonePastDue).toBe(false);
})
.then(done)
.catch(done.fail);
vmDueUndefined.$destroy();
});
it('should return `true` when milestone due is past current date', done => {
const vmPastDue = createComponent(
Object.assign({}, mockMilestone, {
due_date: '1990-07-22',
}),
);
Vue.nextTick()
.then(() => {
expect(vmPastDue.isMilestonePastDue).toBe(true);
})
.then(done)
.catch(done.fail);
vmPastDue.$destroy();
});
});
describe('milestoneDatesAbsolute', () => {
it('returns string containing absolute milestone due date', () => {
expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)');
});
it('returns string containing absolute milestone start date when due date is not present', done => {
const vmDueUndefined = createComponent(
Object.assign({}, mockMilestone, {
due_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmDueUndefined.milestoneDatesAbsolute).toBe('(January 1, 2018)');
})
.then(done)
.catch(done.fail);
vmDueUndefined.$destroy();
});
it('returns empty string when both milestone start and due dates are not present', done => {
const vmDatesUndefined = createComponent(
Object.assign({}, mockMilestone, {
start_date: '',
due_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmDatesUndefined.milestoneDatesAbsolute).toBe('');
})
.then(done)
.catch(done.fail);
vmDatesUndefined.$destroy();
});
});
describe('milestoneDatesHuman', () => {
it('returns string containing milestone due date when date is yet to be due', done => {
const vmFuture = createComponent(
Object.assign({}, mockMilestone, {
due_date: `${new Date().getFullYear() + 10}-01-01`,
}),
);
Vue.nextTick()
.then(() => {
expect(vmFuture.milestoneDatesHuman).toContain('years remaining');
})
.then(done)
.catch(done.fail);
vmFuture.$destroy();
});
it('returns string containing milestone start date when date has already started and due date is not present', done => {
const vmStarted = createComponent(
Object.assign({}, mockMilestone, {
start_date: '1990-07-22',
due_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmStarted.milestoneDatesHuman).toContain('Started');
})
.then(done)
.catch(done.fail);
vmStarted.$destroy();
});
it('returns string containing milestone start date when date is yet to start and due date is not present', done => {
const vmStarts = createComponent(
Object.assign({}, mockMilestone, {
start_date: `${new Date().getFullYear() + 10}-01-01`,
due_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmStarts.milestoneDatesHuman).toContain('Starts');
})
.then(done)
.catch(done.fail);
vmStarts.$destroy();
});
it('returns empty string when milestone start and due dates are not present', done => {
const vmDatesUndefined = createComponent(
Object.assign({}, mockMilestone, {
start_date: '',
due_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmDatesUndefined.milestoneDatesHuman).toBe('');
})
.then(done)
.catch(done.fail);
vmDatesUndefined.$destroy();
});
});
});
describe('template', () => {
it('renders component root element with class `issue-milestone-details`', () => {
expect(vm.$el.classList.contains('issue-milestone-details')).toBe(true);
});
it('renders milestone icon', () => {
expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('clock');
});
it('renders milestone title', () => {
expect(vm.$el.querySelector('.milestone-title').innerText.trim()).toBe(mockMilestone.title);
});
it('renders milestone tooltip', () => {
expect(vm.$el.querySelector('.js-item-milestone').innerText.trim()).toContain(
mockMilestone.title,
);
});
});
});
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