diff options
7 files changed, 627 insertions, 6 deletions
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index 15937b1091a..e038198e6f0 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -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> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue new file mode 100644 index 00000000000..7e79e63aa1e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue @@ -0,0 +1,94 @@ +<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> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue new file mode 100644 index 00000000000..d5d967e25bf --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue @@ -0,0 +1,90 @@ +<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> diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 27f1a7a7165..d24a4f5dc21 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -855,6 +855,9 @@ msgstr "" msgid "Available specific runners" msgstr "" +msgid "Avatar for %{assigneeName}" +msgstr "" + msgid "Avatar will be removed. Are you sure?" msgstr "" @@ -2926,6 +2929,9 @@ msgstr "" msgid "Expiration date" msgstr "" +msgid "Expired %{expiredOn}" +msgstr "" + msgid "Expires in %{expires_at}" msgstr "" @@ -6290,6 +6296,12 @@ msgstr "" msgid "Started" msgstr "" +msgid "Started %{startsIn}" +msgstr "" + +msgid "Starts %{startsIn}" +msgstr "" + msgid "Starts at (UTC)" msgstr "" diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js index c28e41ec175..14fff9223f4 100644 --- a/spec/javascripts/boards/mock_data.js +++ b/spec/javascripts/boards/mock_data.js @@ -1,5 +1,11 @@ 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', +}; diff --git a/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js b/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js new file mode 100644 index 00000000000..9eac75fac96 --- /dev/null +++ b/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js @@ -0,0 +1,114 @@ +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'); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js b/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js new file mode 100644 index 00000000000..8fca2637326 --- /dev/null +++ b/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js @@ -0,0 +1,234 @@ +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, + ); + }); + }); +}); |