Commit 7cb0dd98 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'kp-issue_6086' into 'master'

CE Backport: Epic issue list and related issue list re-design

See merge request gitlab-org/gitlab-ce!23658
parents 6018259f 295e8b9b
......@@ -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>
......@@ -849,6 +849,9 @@ msgstr ""
msgid "Available specific runners"
msgstr ""
msgid "Avatar for %{assigneeName}"
msgstr ""
msgid "Avatar will be removed. Are you sure?"
msgstr ""
......@@ -2920,6 +2923,9 @@ msgstr ""
msgid "Expiration date"
msgstr ""
msgid "Expired %{expiredOn}"
msgstr ""
msgid "Expires in %{expires_at}"
msgstr ""
......@@ -6275,6 +6281,12 @@ msgstr ""
msgid "Started"
msgstr ""
msgid "Started %{startsIn}"
msgstr ""
msgid "Starts %{startsIn}"
msgstr ""
msgid "Starts at (UTC)"
msgstr ""
......
import BoardService from '~/boards/services/board_service';
export const boardObj = {
id: 1,
name: 'test',
milestone_id: null,
};
export const listObj = {
id: 300,
position: 0,
......@@ -40,6 +46,12 @@ export const BoardsMockData = {
},
],
},
'/test/issue-boards/milestones.json': [
{
id: 1,
title: 'test',
},
],
},
POST: {
'/test/-/boards/1/lists': listObj,
......@@ -70,3 +82,60 @@ export const mockBoardService = (opts = {}) => {
boardId,
});
};
export const mockAssigneesList = [
{
id: 2,
name: 'Terrell Graham',
username: 'monserrate.gleichner',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/598fd02741ac58b88854a99d16704309?s=80&d=identicon',
web_url: 'http://127.0.0.1:3001/monserrate.gleichner',
path: '/monserrate.gleichner',
},
{
id: 12,
name: 'Susy Johnson',
username: 'tana_harvey',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/e021a7b0f3e4ae53b5068d487e68c031?s=80&d=identicon',
web_url: 'http://127.0.0.1:3001/tana_harvey',
path: '/tana_harvey',
},
{
id: 20,
name: 'Conchita Eichmann',
username: 'juliana_gulgowski',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/c43c506cb6fd7b37017d3b54b94aa937?s=80&d=identicon',
web_url: 'http://127.0.0.1:3001/juliana_gulgowski',
path: '/juliana_gulgowski',
},
{
id: 6,
name: 'Bryce Turcotte',
username: 'melynda',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/cc2518f2c6f19f8fac49e1a5ee092a9b?s=80&d=identicon',
web_url: 'http://127.0.0.1:3001/melynda',
path: '/melynda',
},
{
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
web_url: 'http://127.0.0.1:3001/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', () => {