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 {
type: String,
required: true,
},
cssClass: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'bottom',
},
},
computed: {
title() {
......@@ -66,15 +76,13 @@ export default {
<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">{{
<span ref="issueDueDate" :class="cssClass" 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">
<gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement">
<span class="bold">{{ __('Due date') }}</span> <br />
<span :class="{ 'text-danger-muted': isPastDue }">{{ title }}</span>
</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>
import { GlTooltipDirective } from '@gitlab/ui';
import { GlTooltip } from '@gitlab/ui';
import icon from '~/vue_shared/components/icon.vue';
export default {
name: 'IssueCardWeight',
components: {
icon,
},
directives: {
GlTooltip: GlTooltipDirective,
GlTooltip,
},
props: {
weight: {
......@@ -21,16 +19,19 @@ export default {
<template>
<a
v-gl-tooltip
:title="__('Weight')"
ref="itemWeight"
class="board-card-info card-number board-card-weight"
data-container="body"
data-placement="bottom"
tabindex="1"
v-on="$listeners"
>
<icon name="weight" css-classes="board-card-info-icon" /><span class="board-card-info-text">{{
weight
}}</span>
<icon name="weight" css-classes="board-card-info-icon" />
<span class="board-card-info-text"> {{ weight }} </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>
</template>
......@@ -31,6 +31,10 @@ export default {
required: false,
default: false,
},
pathIdSeparator: {
type: String,
required: true,
},
},
data() {
......@@ -135,6 +139,7 @@ export default {
:display-reference="reference"
:can-remove="true"
:is-condensed="true"
:path-id-separator="pathIdSeparator"
event-namespace="pendingIssuable"
/>
</li>
......
<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';
export default {
name: 'IssueItem',
components: {
IssueMilestone,
IssueDueDate,
IssueAssignees,
IssueWeight,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [relatedIssueMixin],
props: {
canReorder: {
......@@ -14,7 +28,14 @@ export default {
},
computed: {
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 {
'issuable-info-container': !canReorder,
'card-body': canReorder,
}"
class="flex"
class="item-body"
>
<div class="block-truncated append-right-8 d-inline-flex">
<div class="block text-secondary append-right-default">
<div class="item-contents">
<div class="item-title d-flex align-items-center">
<icon
v-if="hasState"
v-tooltip
:css-classes="iconClass"
:name="iconName"
:size="12"
: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
v-if="hasState"
v-tooltip
:css-classes="iconClass"
:name="iconName"
:size="16"
:title="stateTitle"
: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>
<a :href="computedPath" class="issue-token-title-text sortable-link"> {{ title }} </a>
</div>
<button
v-if="canRemove"
......@@ -49,13 +118,12 @@ export default {
v-tooltip
:disabled="removeDisabled"
type="button"
class="btn btn-default js-issue-item-remove-button issue-item-remove-button flex-align-self-center flex-right
qa-remove-issue-button"
class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button"
title="Remove"
aria-label="Remove"
@click="onRemoveRequest"
>
<i class="fa fa-times" aria-hidden="true"> </i>
<icon :size="16" class="btn-item-remove-icon" name="close" />
</button>
</div>
</template>
......@@ -60,6 +60,11 @@ export default {
required: false,
default: '',
},
pathIdSeparator: {
type: String,
required: false,
default: '#',
},
helpPath: {
type: String,
required: false,
......@@ -148,16 +153,13 @@ export default {
{{ title }}
<a v-if="hasHelpPath" :href="helpPath">
<i
class="related-issues-header-help-icon
fa fa-question-circle"
class="related-issues-header-help-icon fa fa-question-circle"
aria-label="Read more about related issues"
>
</i>
></i>
</a>
<div class="d-inline-flex lh-100 align-middle">
<div
class="js-related-issues-header-issue-count
related-issues-header-issue-count issue-count-badge mx-1"
class="js-related-issues-header-issue-count related-issues-header-issue-count issue-count-badge mx-1"
>
<span class="issue-count-badge-count">
<icon name="issues" class="mr-1 text-secondary" /> {{ badgeLabel }}
......@@ -167,13 +169,12 @@ fa fa-question-circle"
v-if="canAdmin"
ref="issueCountBadgeAddButton"
type="button"
class="js-issue-count-badge-add-button
issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
class="js-issue-count-badge-add-button issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
aria-label="Add an issue"
data-placement="top"
@click="toggleAddRelatedIssuesForm"
>
<i class="fa fa-plus" aria-hidden="true"> </i>
<i class="fa fa-plus" aria-hidden="true"></i>
</button>
</div>
</h3>
......@@ -190,6 +191,7 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
:input-value="inputValue"
:pending-references="pendingReferences"
:auto-complete-sources="autoCompleteSources"
:path-id-separator="pathIdSeparator"
/>
</div>
<div
......@@ -206,7 +208,7 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
class="prepend-top-5"
/>
</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
v-for="issue in relatedIssues"
:key="issue.id"
......@@ -217,16 +219,24 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
}"
:data-key="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
:id-key="issue.id"
:display-reference="issue.reference"
:confidential="issue.confidential"
:title="issue.title"
:path="issue.path"
: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-reorder="canReorder"
:path-id-separator="pathIdSeparator"
event-namespace="relatedIssue"
/>
</li>
......
......@@ -246,6 +246,7 @@ export default {
:input-value="inputValue"
:auto-complete-sources="autoCompleteSources"
:title="title"
path-id-separator="#"
@saveReorder="saveIssueOrder"
/>
</template>
import { formatDate } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import eventHub from '../event_hub';
const mixins = {
......@@ -17,11 +19,20 @@ const mixins = {
type: String,
required: true,
},
pathIdSeparator: {
type: String,
required: true,
},
eventNamespace: {
type: String,
required: false,
default: '',
},
confidential: {
type: Boolean,
required: false,
default: false,
},
title: {
type: String,
required: false,
......@@ -37,6 +48,36 @@ const mixins = {
required: false,
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: {
type: Boolean,
required: false,
......@@ -49,6 +90,7 @@ const mixins = {
directives: {
tooltip,
},
mixins: [timeagoMixin],
computed: {
hasState() {
return this.state && this.state.length > 0;
......@@ -63,7 +105,7 @@ const mixins = {
return this.title.length > 0;
},
iconName() {
return this.isOpen ? 'issue-open' : 'issue-close';
return this.isOpen ? 'issue-open-m' : 'issue-close';
},
iconClass() {
return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
......@@ -74,6 +116,24 @@ const mixins = {
computedPath() {
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: {
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;
li .issuable-info-container {
padding-left: $gl-padding;
padding-right: $gl-padding-4;
@include media-breakpoint-down(sm) {
padding-left: $gl-padding-8;
......
......@@ -219,9 +219,10 @@ module EE
def update_project_counter_caches
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')
.joins(:epic_issue)
.preload(preload)
.where("epic_issues.epic_id = #{id}")
.order('epic_issues.relative_position, epic_issues.id')
......
......@@ -7,7 +7,7 @@ module EpicIssues
def child_issuables
return [] unless issuable&.group&.feature_available?(:epics)
issuable.issues_readable_by(current_user)
issuable.issues_readable_by(current_user, preload: preload_for_collection)
end
def relation_path(issue)
......
......@@ -18,6 +18,10 @@ module IssuableLinks
private
def preload_for_collection
[{ project: :namespace }, :assignees]
end
def relation_path(object)
raise NotImplementedError
end
......@@ -30,15 +34,24 @@ module IssuableLinks
project_issue_path(object.project, object.iid)
end
# rubocop: disable CodeReuse/Serializer
def to_hash(object)
{
id: object.id,
confidential: object.confidential,
title: object.title,
assignees: UserSerializer.new.represent(object.assignees),
state: object.state,
milestone: MilestoneSerializer.new.represent(object.milestone),
weight: object.weight,
reference: reference(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
# rubocop: enable CodeReuse/Serializer
end
end
......@@ -7,7 +7,7 @@ module IssueLinks
private
def child_issuables
issuable.related_issues(current_user, preload: { project: :namespace })
issuable.related_issues(current_user, preload: preload_for_collection)
end
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'
describe Groups::EpicIssuesController do
let(:group) { create(:group, :public) }
let(:project) { create(:project, :public, group: group) }
let(:milestone) { create(:milestone, project: project) }
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
stub_licensed_features(epics: true)
......@@ -45,18 +46,7 @@ describe Groups::EpicIssuesController do
end
it 'returns the correct json' do
expected_result = [
{
'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)
expect(JSON.parse(response.body)).to match_schema('related_issues', dir: 'ee')
end
end
end
......
......@@ -32,7 +32,7 @@ describe 'Epic Issues', :js do
end
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_content(public_issue.title)
expect(page).not_to have_selector('button.js-issue-item-remove-button')
......@@ -70,7 +70,7 @@ describe 'Epic Issues', :js do
end
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_content(public_issue.title)
expect(page).to have_content(private_issue.title)
......@@ -80,7 +80,7 @@ describe 'Epic Issues', :js do
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)
end
end
......@@ -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_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_content(issue_to_add.title)
end
......@@ -110,7 +110,7 @@ describe 'Epic Issues', :js do
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)
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(page.all('.js-related-issues-token-list-item').last).to have_content(public_issue.title)
......
......@@ -258,7 +258,7 @@ describe 'Related issues', :js do
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
expect(page).not_to have_selector('.js-add-related-issues-form-area')
......@@ -275,7 +275,7 @@ describe 'Related issues', :js do
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[0].text).to eq(issue_project_b_a.title)
......@@ -289,7 +289,7 @@ describe 'Related issues', :js do
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[0].text).to eq(issue_project_b_a.title)
......@@ -311,7 +311,7 @@ describe 'Related issues', :js do
end
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[0].text).to eq(issue_b.title)
......@@ -319,7 +319,7 @@ describe 'Related issues', :js do
end
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)
......@@ -327,7 +327,7 @@ describe 'Related issues', :js do
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)
end
......@@ -339,7 +339,7 @@ describe 'Related issues', :js do
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[0].text).to eq(issue_b.title)
......@@ -355,7 +355,7 @@ describe 'Related issues', :js do
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[0].text).to eq(issue_b.title)
......@@ -370,7 +370,7 @@ describe 'Related issues', :js do
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[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', () => {
const el = vm.$el.querySelector('.board-card-weight');
expect(el).not.toBeNull();
expect(el.textContent.trim()).toBe('2');
expect(el.textContent.trim()).toContain('2');
});
});
......@@ -55,7 +55,7 @@ describe('EpicBodyComponent', () => {
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('.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 = {
state: 'opened',
};
const pathIdSeparator = '#';
describe('AddIssuableForm', () => {
let AddIssuableForm;
let vm;
......@@ -47,6 +49,7 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue: '',
pendingReferences: [],
pathIdSeparator,
},
}).$mount();
});
......@@ -63,6 +66,7 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue: 'foo',
pendingReferences: [],
pathIdSeparator,
},
}).$mount();
});
......@@ -81,6 +85,7 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue,
pendingReferences: [issuable1.reference, issuable2.reference],
pathIdSeparator,
},
}).$mount();
});
......@@ -105,6 +110,7 @@ describe('AddIssuableForm', () => {
inputValue: '',
pendingReferences: [issuable1.reference, issuable2.reference],
isSubmitting: true,
pathIdSeparator,
},
}).$mount();
});
......@@ -125,6 +131,7 @@ describe('AddIssuableForm', () => {
autoCompleteSources: {
issues: '/fake/issues/path',
},
pathIdSeparator,
},
}).$mount();
});
......@@ -144,6 +151,7 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue: '',
autoCompleteSources: {},
pathIdSeparator,
},
}).$mount();
});
......@@ -185,6 +193,7 @@ describe('AddIssuableForm', () => {
autoCompleteSources: {
issues: '/fake/issues/path',
},
pathIdSeparator,
},
}).$mount(el);
});
......
......@@ -2,14 +2,22 @@ import Vue from 'vue';
import issueItem from 'ee/related_issues/components/issue_item.vue';
import eventHub from 'ee/related_issues/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { defaultMilestone, defaultAssignees } from '../mock_data';
describe('issueItem', () => {
let vm;
const props = {
idKey: 1,
displayReference: '#1',
displayReference: 'gitlab-org/gitlab-test#1',
pathIdSeparator: '#',
path: `${gl.TEST_HOST}/path`,
title: 'title',
confidential: true,
dueDate: '2018-12-31',
weight: 10,
createdAt: '2018-12-01T00:00:00.00Z',
milestone: defaultMilestone,
assignees: defaultAssignees,
};
beforeEach(() => {
......@@ -22,12 +30,6 @@ describe('issueItem', () => {
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', () => {
expect(vm.$el.querySelector('.text-secondary svg')).toBeNull();
});
......@@ -38,11 +40,17 @@ describe('issueItem', () => {
describe('token title', () => {
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', () => {
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', () => {
beforeEach(done => {
vm.state = 'opened';
Vue.nextTick(() => {
tokenState = vm.$el.querySelector('.text-secondary svg');
tokenState = vm.$el.querySelector('.item-meta svg');
done();
});
});
......@@ -62,7 +70,12 @@ describe('issueItem', () => {
});
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', () => {
......@@ -75,6 +88,7 @@ describe('issueItem', () => {
it('renders close icon when close state', done => {
vm.state = 'closed';
vm.closedAt = '2018-12-01T00:00:00.00Z';
Vue.nextTick(() => {
expect(tokenState.classList.contains('issue-token-state-icon-closed')).toEqual(true);
......@@ -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', () => {
let removeBtn;
......
......@@ -6,6 +6,7 @@ describe('IssueToken', () => {
const idKey = 200;
const displayReference = 'foo/bar#123';
const title = 'some title';
const pathIdSeparator = '#';
let IssueToken;
let vm;
......@@ -25,6 +26,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
},
}).$mount();
});
......@@ -45,6 +47,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
title,
},
}).$mount();
......@@ -63,6 +66,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
title,
path,
},
......@@ -81,6 +85,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
state: 'opened',
},
}).$mount();
......@@ -97,6 +102,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
state: 'reopened',
},
}).$mount();
......@@ -113,6 +119,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
state: 'closed',
},
}).$mount();
......@@ -131,6 +138,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
title,
state,
},
......@@ -153,6 +161,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
},
}).$mount();
});
......@@ -168,6 +177,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
canRemove: true,
},
}).$mount();
......@@ -187,6 +197,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
},
}).$mount();
removeRequestSpy = jasmine.createSpy('spy');
......
......@@ -7,6 +7,7 @@ export const defaultProps = {
export const issuable1 = {
id: 200,
epic_issue_id: 1,
confidential: false,
reference: 'foo/bar#123',
displayReference: '#123',
title: 'some title',
......@@ -17,6 +18,7 @@ export const issuable1 = {
export const issuable2 = {
id: 201,
epic_issue_id: 2,
confidential: false,
reference: 'foo/bar#124',
displayReference: '#124',
title: 'some other thing',
......@@ -27,6 +29,7 @@ export const issuable2 = {
export const issuable3 = {
id: 202,
epic_issue_id: 3,
confidential: false,
reference: 'foo/bar#125',
displayReference: '#125',
title: 'some other other thing',
......@@ -37,6 +40,7 @@ export const issuable3 = {
export const issuable4 = {
id: 203,
epic_issue_id: 4,
confidential: false,
reference: 'foo/bar#126',
displayReference: '#126',
title: 'some other other other thing',
......@@ -47,9 +51,61 @@ export const issuable4 = {
export const issuable5 = {
id: 204,
epic_issue_id: 5,
confidential: false,
reference: 'foo/bar#127',
displayReference: '#127',
title: 'some other other other thing',
path: '/foo/bar/issues/127',
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
let(:other_project) { create(:project_empty_repo, 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(:issue3) { create :issue, project: other_project }
......@@ -31,6 +31,36 @@ describe EpicIssues::ListService do
stub_licensed_features(epics: true)
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
before do
group.add_developer(user)
......@@ -41,31 +71,53 @@ describe EpicIssues::ListService do
{
id: issue2.id,
title: issue2.title,
assignees: [],
state: issue2.state,
milestone: nil,
weight: nil,
confidential: false,
reference: issue2.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue2.iid}",
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,
title: issue1.title,
assignees: [],
state: issue1.state,
milestone: nil,
weight: 1,
confidential: false,
reference: issue1.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue1.iid}",
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,
title: issue3.title,
assignees: [],
state: issue3.state,
milestone: nil,
weight: nil,
confidential: false,
reference: issue3.to_reference(full: true),
path: "/#{other_project.full_path}/issues/#{issue3.iid}",
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)
end
end
......@@ -80,20 +132,34 @@ describe EpicIssues::ListService do
{
id: issue2.id,
title: issue2.title,
assignees: [],
state: issue2.state,
milestone: nil,
weight: nil,
confidential: false,
reference: issue2.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue2.iid}",
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,
title: issue1.title,
assignees: [],
state: issue1.state,
milestone: nil,
weight: 1,
confidential: false,
reference: issue1.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue1.iid}",
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
control_count = ActiveRecord::QueryRecorder.new { subject }.count
project = create :project, :public
issue_x = create :issue, project: project
issue_y = create :issue, project: project
milestone = create :milestone, project: project
issue_x = create :issue, project: project, milestone: milestone
issue_y = create :issue, project: project, assignees: [user]
issue_z = create :issue, project: project
create :issue_link, source: issue_x, target: issue_y
create :issue_link, source: issue_x, target: issue_z
......
......@@ -1061,6 +1061,9 @@ msgstr ""
msgid "Available specific runners"
msgstr ""
msgid "Avatar for %{assigneeName}"
msgstr ""
msgid "Avatar will be removed. Are you sure?"
msgstr ""
......@@ -3456,6 +3459,9 @@ msgstr ""
msgid "Expiration date"
msgstr ""
msgid "Expired %{expiredOn}"
msgstr ""
msgid "Expires in %{expires_at}"
msgstr ""
......@@ -8196,9 +8202,15 @@ msgstr ""
msgid "Started"
msgstr ""
msgid "Started %{startsIn}"
msgstr ""
msgid "Starting..."
msgstr ""
msgid "Starts %{startsIn}"
msgstr ""
msgid "Starts at (UTC)"
msgstr ""
......
......@@ -130,3 +130,12 @@ export const mockAssigneesList = [
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