Commit a34082ff authored by Coung Ngo's avatar Coung Ngo

Add weight and progress information in roadmap epic bars

This commit adds weight and progress information in roadmap epic bars
parent e7935f19
......@@ -413,6 +413,7 @@ img.emoji {
.prepend-left-20 { margin-left: 20px; }
.prepend-left-32 { margin-left: 32px; }
.prepend-left-64 { margin-left: 64px; }
.append-right-2 { margin-right: 2px; }
.append-right-4 { margin-right: 4px; }
.append-right-5 { margin-right: 5px; }
.append-right-8 { margin-right: 8px; }
......@@ -424,6 +425,7 @@ img.emoji {
.append-right-48 { margin-right: 48px; }
.prepend-right-32 { margin-right: 32px; }
.append-bottom-0 { margin-bottom: 0; }
.append-bottom-2 { margin-bottom: 2px; }
.append-bottom-4 { margin-bottom: $gl-padding-4; }
.append-bottom-5 { margin-bottom: 5px; }
.append-bottom-8 { margin-bottom: $grid-size; }
......
......@@ -182,7 +182,7 @@ If your epic contains one or more [child epics](#multi-level-child-epics-ultimat
have a [start or due date](#start-date-and-due-date), a
[roadmap](../roadmap/index.md) view of the child epics is listed under the parent epic.
![Child epics roadmap](img/epic_view_roadmap_v12.3.png)
![Child epics roadmap](img/epic_view_roadmap_v12_9.png)
## Reordering issues and child epics
......
......@@ -10,7 +10,12 @@ An Epic within a group containing **Start date** and/or **Due date**
can be visualized in a form of a timeline (e.g. a Gantt chart). The Epics Roadmap page
shows such a visualization for all the epics which are under a group and/or its subgroups.
![roadmap view](img/roadmap_view.png)
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/5164) in GitLab 12.9.
On the epic bars, you can see their title, progress, and completed weight percentage.
When you hover over an epic bar, a popover appears with its description, start and due dates, and weight completed.
![roadmap view](img/roadmap_view_v12_9.png)
A dropdown allows you to show only open or closed epics. By default, all epics are shown.
......@@ -68,11 +73,7 @@ the timeline header represent the days of the week.
## Timeline bar for an epic
The timeline bar indicates the approximate position of an epic based on its start
and due date. If an epic doesn't have a due date, the timeline bar fades
away towards the future. Similarly, if an epic doesn't have a start date, the
timeline bar becomes more visible as it approaches the epic's due date on the
timeline.
The timeline bar indicates the approximate position of an epic based on its start and due date.
<!-- ## Troubleshooting
......
<script>
import _ from 'underscore';
import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility';
import epicItemDetails from './epic_item_details.vue';
import epicItemTimeline from './epic_item_timeline.vue';
......@@ -28,6 +30,63 @@ export default {
type: Number,
required: true,
},
clientWidth: {
type: Number,
required: false,
default: 0,
},
},
computed: {
/**
* In case Epic start date is out of range
* we need to use original date instead of proxy date
*/
startDate() {
if (this.epic.startDateOutOfRange) {
return this.epic.originalStartDate;
}
return this.epic.startDate;
},
/**
* In case Epic end date is out of range
* we need to use original date instead of proxy date
*/
endDate() {
if (this.epic.endDateOutOfRange) {
return this.epic.originalEndDate;
}
return this.epic.endDate;
},
/**
* Compose timeframe string to show on UI
* based on start and end date availability
*/
timeframeString() {
if (this.epic.startDateUndefined) {
return sprintf(s__('GroupRoadmap|No start date – %{dateWord}'), {
dateWord: dateInWords(this.endDate, true),
});
} else if (this.epic.endDateUndefined) {
return sprintf(s__('GroupRoadmap|%{dateWord} – No end date'), {
dateWord: dateInWords(this.startDate, true),
});
}
// In case both start and end date fall in same year
// We should hide year from start date
const startDateInWords = dateInWords(
this.startDate,
true,
this.startDate.getFullYear() === this.endDate.getFullYear(),
);
const endDateInWords = dateInWords(this.endDate, true);
return sprintf(s__('GroupRoadmap|%{startDateInWords} – %{endDateInWords}'), {
startDateInWords,
endDateInWords,
});
},
},
updated() {
this.removeHighlight();
......@@ -59,7 +118,11 @@ export default {
<template>
<div :class="{ 'newly-added-epic': epic.newEpic }" class="epics-list-item clearfix">
<epic-item-details :epic="epic" :current-group-id="currentGroupId" />
<epic-item-details
:epic="epic"
:current-group-id="currentGroupId"
:timeframe-string="timeframeString"
/>
<epic-item-timeline
v-for="(timeframeItem, index) in timeframe"
:key="index"
......@@ -67,6 +130,8 @@ export default {
:timeframe="timeframe"
:timeframe-item="timeframeItem"
:epic="epic"
:timeframe-string="timeframeString"
:client-width="clientWidth"
/>
</div>
</template>
<script>
import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility';
export default {
props: {
epic: {
......@@ -12,61 +9,15 @@ export default {
type: Number,
required: true,
},
timeframeString: {
type: String,
required: true,
},
},
computed: {
isEpicGroupDifferent() {
return this.currentGroupId !== this.epic.groupId;
},
/**
* In case Epic start date is out of range
* we need to use original date instead of proxy date
*/
startDate() {
if (this.epic.startDateOutOfRange) {
return this.epic.originalStartDate;
}
return this.epic.startDate;
},
/**
* In case Epic end date is out of range
* we need to use original date instead of proxy date
*/
endDate() {
if (this.epic.endDateOutOfRange) {
return this.epic.originalEndDate;
}
return this.epic.endDate;
},
/**
* Compose timeframe string to show on UI
* based on start and end date availability
*/
timeframeString() {
if (this.epic.startDateUndefined) {
return sprintf(s__('GroupRoadmap|Until %{dateWord}'), {
dateWord: dateInWords(this.endDate, true),
});
} else if (this.epic.endDateUndefined) {
return sprintf(s__('GroupRoadmap|From %{dateWord}'), {
dateWord: dateInWords(this.startDate, true),
});
}
// In case both start and end date fall in same year
// We should hide year from start date
const startDateInWords = dateInWords(
this.startDate,
true,
this.startDate.getFullYear() === this.endDate.getFullYear(),
);
const endDateInWords = dateInWords(this.endDate, true);
return sprintf(s__('GroupRoadmap|%{startDateInWords} &ndash; %{endDateInWords}'), {
startDateInWords,
endDateInWords,
});
},
},
};
</script>
......@@ -80,7 +31,7 @@ export default {
<span v-if="isEpicGroupDifferent" :title="epic.groupFullName" class="epic-group"
>{{ epic.groupName }} &middot;</span
>
<span class="epic-timeframe" v-html="timeframeString"></span>
<span class="epic-timeframe">{{ timeframeString }}</span>
</div>
</span>
</template>
<script>
import tooltip from '~/vue_shared/directives/tooltip';
import { GlPopover, GlProgressBar } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import CommonMixin from '../mixins/common_mixin';
import QuartersPresetMixin from '../mixins/quarters_preset_mixin';
......@@ -8,15 +10,22 @@ import WeeksPresetMixin from '../mixins/weeks_preset_mixin';
import CurrentDayIndicator from './current_day_indicator.vue';
import { TIMELINE_CELL_MIN_WIDTH } from '../constants';
import {
EPIC_DETAILS_CELL_WIDTH,
PERCENTAGE,
PRESET_TYPES,
SMALL_TIMELINE_BAR,
TIMELINE_CELL_MIN_WIDTH,
VERY_SMALL_TIMELINE_BAR,
} from '../constants';
export default {
cellWidth: TIMELINE_CELL_MIN_WIDTH,
directives: {
tooltip,
},
components: {
CurrentDayIndicator,
Icon,
GlPopover,
GlProgressBar,
},
mixins: [CommonMixin, QuartersPresetMixin, MonthsPresetMixin, WeeksPresetMixin],
props: {
......@@ -36,6 +45,15 @@ export default {
type: Object,
required: true,
},
timeframeString: {
type: String,
required: true,
},
clientWidth: {
type: Number,
required: false,
default: 0,
},
},
computed: {
startDateValues() {
......@@ -94,6 +112,57 @@ export default {
}
return barStyles;
},
epicBarInnerStyle() {
return {
maxWidth: `${this.clientWidth - EPIC_DETAILS_CELL_WIDTH}px`,
};
},
timelineBarWidth() {
if (this.hasStartDate) {
if (this.presetType === PRESET_TYPES.QUARTERS) {
return this.getTimelineBarWidthForQuarters(this.epic);
} else if (this.presetType === PRESET_TYPES.MONTHS) {
return this.getTimelineBarWidthForMonths();
} else if (this.presetType === PRESET_TYPES.WEEKS) {
return this.getTimelineBarWidthForWeeks();
}
}
return Infinity;
},
showTimelineBarEllipsis() {
return this.timelineBarWidth < SMALL_TIMELINE_BAR;
},
timelineBarEllipsis() {
if (this.timelineBarWidth < VERY_SMALL_TIMELINE_BAR) {
return '.';
} else if (this.timelineBarWidth < SMALL_TIMELINE_BAR) {
return '...';
}
return '';
},
epicTotalWeight() {
if (this.epic.descendantWeightSum) {
const { openedIssues, closedIssues } = this.epic.descendantWeightSum;
return openedIssues + closedIssues;
}
return undefined;
},
epicWeightPercentage() {
return this.epicTotalWeight
? Math.round(
(this.epic.descendantWeightSum.closedIssues / this.epicTotalWeight) * PERCENTAGE,
)
: 0;
},
popoverWeightText() {
if (this.epic.descendantWeightSum) {
return sprintf(__('%{completedWeight} of %{totalWeight} weight completed'), {
completedWeight: this.epic.descendantWeightSum.closedIssues,
totalWeight: this.epicTotalWeight,
});
}
return __('- of - weight completed');
},
},
};
</script>
......@@ -101,17 +170,41 @@ export default {
<template>
<span class="epic-timeline-cell" data-qa-selector="epic_timeline_cell">
<current-day-indicator :preset-type="presetType" :timeframe-item="timeframeItem" />
<div class="timeline-bar-wrapper">
<div class="epic-bar-wrapper">
<a
v-if="hasStartDate"
:id="`epic-bar-${epic.id}`"
:href="epic.webUrl"
:class="{
'start-date-undefined': epic.startDateUndefined,
'end-date-undefined': epic.endDateUndefined,
}"
:style="timelineBarStyles"
class="timeline-bar"
></a>
class="epic-bar"
>
<div class="epic-bar-inner" :style="epicBarInnerStyle">
<gl-progress-bar
class="epic-bar-progress append-bottom-2"
:value="epicWeightPercentage"
/>
<div v-if="showTimelineBarEllipsis" class="m-0">{{ timelineBarEllipsis }}</div>
<div v-else class="d-flex">
<span class="flex-grow-1 text-nowrap text-truncate mr-3">
{{ epic.title }}
</span>
<span class="d-flex align-items-center text-nowrap">
<icon class="append-right-2" :size="16" name="weight" />
{{ epicWeightPercentage }}%
</span>
</div>
</div>
</a>
<gl-popover
:target="`epic-bar-${epic.id}`"
:title="epic.description"
triggers="hover focus"
placement="right"
>
<p class="text-secondary m-0">{{ timeframeString }}</p>
<p class="m-0">{{ popoverWeightText }}</p>
</gl-popover>
</div>
</span>
</template>
......@@ -40,6 +40,7 @@ export default {
},
data() {
return {
clientWidth: 0,
offsetLeft: 0,
emptyRowContainerStyles: {},
showBottomShadow: false,
......@@ -64,10 +65,12 @@ export default {
},
mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
window.addEventListener('resize', this.syncClientWidth);
this.initMounted();
},
beforeDestroy() {
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
window.removeEventListener('resize', this.syncClientWidth);
},
methods: {
...mapActions(['setBufferSize']),
......@@ -91,6 +94,11 @@ export default {
this.emptyRowContainerStyles = this.getEmptyRowContainerStyles();
}
});
this.syncClientWidth();
},
syncClientWidth() {
this.clientWidth = this.$root.$el?.clientWidth || 0;
},
getEmptyRowContainerStyles() {
if (this.$refs.epicItems && this.$refs.epicItems.length) {
......@@ -150,6 +158,7 @@ export default {
:epic="epic"
:timeframe="timeframe"
:current-group-id="currentGroupId"
:client-width="clientWidth"
/>
</template>
<div
......@@ -162,6 +171,10 @@ export default {
<current-day-indicator :preset-type="presetType" :timeframe-item="timeframeItem" />
</span>
</div>
<div v-show="showBottomShadow" :style="shadowCellStyles" class="scroll-bottom-shadow"></div>
<div
v-show="showBottomShadow"
:style="shadowCellStyles"
class="epic-scroll-bottom-shadow"
></div>
</div>
</template>
......@@ -12,6 +12,12 @@ export const EPIC_HIGHLIGHT_REMOVE_AFTER = 3000;
export const DAYS_IN_WEEK = 7;
export const PERCENTAGE = 100;
export const SMALL_TIMELINE_BAR = 100;
export const VERY_SMALL_TIMELINE_BAR = 27;
export const BUFFER_OVERLAP_SIZE = 20;
export const PRESET_TYPES = {
......
......@@ -17,6 +17,7 @@ query epicChildEpics(
node {
id
title
description
state
webUrl
startDate
......
query epicChildEpics(
$fullPath: ID!
$iid: ID!
$state: EpicState
$sort: EpicSort
$startDate: Time
$dueDate: Time
) {
group(fullPath: $fullPath) {
id
name
epic(iid: $iid) {
id
title
children(state: $state, sort: $sort, startDate: $startDate, endDate: $dueDate) {
edges {
node {
id
title
description
state
webUrl
startDate
dueDate
descendantWeightSum {
closedIssues
openedIssues
}
group {
name
fullName
}
}
}
}
}
}
}
......@@ -24,6 +24,7 @@ query groupEpics(
node {
id
title
description
state
webUrl
startDate
......
query groupEpics(
$fullPath: ID!
$state: EpicState
$sort: EpicSort
$startDate: Time
$dueDate: Time
$labelName: [String!] = []
$authorUsername: String = ""
$search: String = ""
) {
group(fullPath: $fullPath) {
id
name
epics(
state: $state
sort: $sort
startDate: $startDate
endDate: $dueDate
labelName: $labelName
authorUsername: $authorUsername
search: $search
) {
edges {
node {
id
title
description
state
webUrl
startDate
dueDate
descendantWeightSum {
closedIssues
openedIssues
}
group {
name
fullName
}
}
}
}
}
}
......@@ -14,6 +14,8 @@ import { EXTEND_AS } from '../constants';
import groupEpics from '../queries/groupEpics.query.graphql';
import epicChildEpics from '../queries/epicChildEpics.query.graphql';
import groupEpicsForUnfilteredEpicAggregatesFeatureFlag from '../queries/groupEpicsForUnfilteredEpicAggregatesFeatureFlag.query.graphql';
import epicChildEpicsForUnfilteredEpicAggregatesFeatureFlag from '../queries/epicChildEpicsForUnfilteredEpicAggregatesFeatureFlag.query.graphql';
import * as types from './mutation_types';
......@@ -42,9 +44,15 @@ export const fetchGroupEpics = (
// and then we don't need to pass `filterParams`.
if (epicIid) {
query = epicChildEpics;
if (gon.features && gon.features.unfilteredEpicAggregates) {
query = epicChildEpicsForUnfilteredEpicAggregatesFeatureFlag;
}
variables.iid = epicIid;
} else {
query = groupEpics;
if (gon.features && gon.features.unfilteredEpicAggregates) {
query = groupEpicsForUnfilteredEpicAggregatesFeatureFlag;
}
variables = {
...variables,
...filterParams,
......
......@@ -249,145 +249,153 @@ html.group-epics-roadmap-html {
.epics-list-section {
height: calc(100% - 60px);
}
.epics-list-item {
&:hover {
.epic-details-cell,
.epic-timeline-cell {
background-color: $gray-100;
}
.epics-list-item {
&:hover {
.epic-details-cell,
.epic-timeline-cell {
background-color: $gray-100;
}
}
&.epics-list-item-empty {
height: 100%;
&:hover {
.epic-details-cell,
.epic-timeline-cell {
background-color: $white-light;
}
}
&.epics-list-item-empty {
height: 100%;
&:hover {
.epic-details-cell,
.epic-details-cell::after,
.epic-timeline-cell {
height: 100%;
}
}
&.newly-added-epic {
.epic-details-cell {
animation: colorTransitionDetailsCell 3s;
background-color: $white-light;
}
}
.epic-details-cell,
.epic-details-cell::after,
.epic-timeline-cell {
box-sizing: border-box;
float: left;
height: $item-height;
border-bottom: $border-style;
height: 100%;
}
}
&.newly-added-epic {
.epic-details-cell {
position: sticky;
position: -webkit-sticky;
left: 0;
width: $details-cell-width;
padding: $gl-padding-8 $gl-padding;
font-size: $code-font-size;
background-color: $white-light;
z-index: 10;
animation: colorTransitionDetailsCell 3s;
}
}
}
&::after {
height: $item-height;
}
.epic-details-cell,
.epic-timeline-cell {
box-sizing: border-box;
float: left;
height: $item-height;
border-bottom: $border-style;
}
.epic-title,
.epic-group-timeframe {
will-change: contents;
}
.epic-details-cell {
position: sticky;
position: -webkit-sticky;
left: 0;
width: $details-cell-width;
padding: $gl-padding-8 $gl-padding;
font-size: $code-font-size;
background-color: $white-light;
z-index: 10;
.epic-title {
display: table;
table-layout: fixed;
width: 100%;
.epic-url {
display: table-cell;
color: $gray-900;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
&::after {
height: $item-height;
}
.epic-group-timeframe {
color: $gray-700;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.epic-title,
.epic-group-timeframe {
will-change: contents;
}
.epic-group:hover {
cursor: pointer;
}
.epic-title {
display: table;
table-layout: fixed;
width: 100%;
.epic-url {
display: table-cell;
color: $gray-900;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.epic-timeline-cell {
position: relative;
width: $timeline-cell-width;
background-color: transparent;
border-right: $border-style;
.epic-group-timeframe {
color: $gray-700;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-day-indicator {
top: -1px;
width: 2px;
height: calc(100% + 1px);
background-color: $red-500;
pointer-events: none;
}
.epic-group:hover {
cursor: pointer;
}
}
.timeline-bar-wrapper {
position: relative;
}
.epic-timeline-cell {
position: relative;
width: $timeline-cell-width;
background-color: transparent;
border-right: $border-style;
.current-day-indicator {
top: -1px;
width: 2px;
height: calc(100% + 1px);
background-color: $red-500;
pointer-events: none;
}
.timeline-bar {
position: absolute;
top: 12px;
height: 24px;
background-color: $blue-500;
border-radius: $border-radius-default;
opacity: 0.75;
will-change: width, left;
z-index: 5;
&:hover {
opacity: 1;
}
&.start-date-undefined {
background: linear-gradient(to right, $roadmap-gradient-gray 0%, $blue-200 50%, $blue-500 100%);
}
&.end-date-undefined {
background: linear-gradient(to right, $blue-500 0%, $blue-200 50%, $roadmap-gradient-gray 100%);
}
}
&:last-child {
border-right: 0;
}
}
&:last-child {
border-right: 0;
}
}
.epic-bar-wrapper {
position: relative;
}
.epic-bar {
position: absolute;
top: 5px;
height: 40px;
background-color: $blue-600;
border-radius: $border-radius-default;
will-change: width, left;
z-index: 5;
&:hover {
background-color: $blue-700;
}
}
.scroll-bottom-shadow {
@include roadmap-scroll-mixin;
position: fixed;
bottom: 0;
background: $scroll-bottom-gradient;
z-index: 2;
.epic-bar-inner {
position: sticky;
position: -webkit-sticky;
left: $details-cell-width;
padding: $gl-padding-8;
color: $white-light;
}
.epic-bar-progress {
background-color: $blue-300;
.progress-bar {
background-color: $white-light;
}
}
.epic-scroll-bottom-shadow {
@include roadmap-scroll-mixin;
position: fixed;
bottom: 0;
background: $scroll-bottom-gradient;
z-index: 2;
}
......@@ -18,6 +18,7 @@ class Groups::EpicsController < Groups::ApplicationController
before_action do
push_frontend_feature_flag(:roadmap_graphql, @group)
push_frontend_feature_flag(:unfiltered_epic_aggregates, @group)
push_frontend_feature_flag(:vue_issuable_epic_sidebar, @group)
end
......
......@@ -11,6 +11,7 @@ module Groups
before_action :persist_roadmap_layout, only: [:show]
before_action do
push_frontend_feature_flag(:roadmap_graphql, @group)
push_frontend_feature_flag(:unfiltered_epic_aggregates, @group)
push_frontend_feature_flag(:roadmap_buffered_rendering, @group)
end
......
......@@ -37,9 +37,9 @@ module EpicsHelper
"#{epic.start_date.strftime(start_date_format)}#{epic.end_date.strftime(long_format)}"
elsif epic.start_date.present?
s_('GroupRoadmap|From %{dateWord}') % { dateWord: epic.start_date.strftime(long_format) }
s_('GroupRoadmap|%{dateWord} – No end date') % { dateWord: epic.start_date.strftime(long_format) }
elsif epic.end_date.present?
s_("GroupRoadmap|Until %{dateWord}") % { dateWord: epic.end_date.strftime(long_format) }
s_("GroupRoadmap|No start date – %{dateWord}") % { dateWord: epic.end_date.strftime(long_format) }
end
end
end
---
title: Add weight and progress information in Roadmap Epic bars
merge_request: 18957
author:
type: added
......@@ -50,8 +50,8 @@ describe 'epics list', :js do
it 'shows epic start and/or end dates when present' do
page.within('.issuable-list') do
expect(find("li[data-id='#{epic1.id}'] .issuable-info .issuable-dates")).to have_content("Until #{epic1.end_date.strftime('%b %d, %Y')}")
expect(find("li[data-id='#{epic2.id}'] .issuable-info .issuable-dates")).to have_content("From #{epic2.start_date.strftime('%b %d, %Y')}")
expect(find("li[data-id='#{epic1.id}'] .issuable-info .issuable-dates")).to have_content("No start date – #{epic1.end_date.strftime('%b %d, %Y')}")
expect(find("li[data-id='#{epic2.id}'] .issuable-info .issuable-dates")).to have_content("#{epic2.start_date.strftime('%b %d, %Y')} – No end date")
end
end
......
......@@ -77,7 +77,7 @@ describe EpicsHelper, type: :helper do
let(:end_date) { nil }
it 'returns start date with year' do
is_expected.to eq('From Jul 22, 2018')
is_expected.to eq('Jul 22, 2018 – No end date')
end
end
......@@ -86,7 +86,7 @@ describe EpicsHelper, type: :helper do
let(:end_date) { Date.new(2018, 7, 22) }
it 'returns end date with year' do
is_expected.to eq('Until Jul 22, 2018')
is_expected.to eq('No start date – Jul 22, 2018')
end
end
end
......
......@@ -11,6 +11,7 @@ const createComponent = (epic = mockEpic, currentGroupId = mockGroupId) => {
return mountComponent(Component, {
epic,
currentGroupId,
timeframeString: 'Jul 10, 2017 – Jun 2, 2018',
});
};
......@@ -37,80 +38,6 @@ describe('EpicItemDetailsComponent', () => {
expect(vm.isEpicGroupDifferent).toBe(false);
});
});
describe('startDate', () => {
it('returns Epic.startDate when start date is within range', () => {
vm = createComponent(mockEpic);
expect(vm.startDate).toBe(mockEpic.startDate);
});
it('returns Epic.originalStartDate when start date is out of range', () => {
const mockStartDate = new Date(2018, 0, 1);
const mockEpicItem = Object.assign({}, mockEpic, {
startDateOutOfRange: true,
originalStartDate: mockStartDate,
});
vm = createComponent(mockEpicItem);
expect(vm.startDate).toBe(mockStartDate);
});
});
describe('endDate', () => {
it('returns Epic.endDate when end date is within range', () => {
vm = createComponent(mockEpic);
expect(vm.endDate).toBe(mockEpic.endDate);
});
it('returns Epic.originalEndDate when end date is out of range', () => {
const mockEndDate = new Date(2018, 0, 1);
const mockEpicItem = Object.assign({}, mockEpic, {
endDateOutOfRange: true,
originalEndDate: mockEndDate,
});
vm = createComponent(mockEpicItem);
expect(vm.endDate).toBe(mockEndDate);
});
});
describe('timeframeString', () => {
it('returns timeframe string correctly when both start and end dates are defined', () => {
vm = createComponent(mockEpic);
expect(vm.timeframeString).toBe('Jul 10, 2017 &ndash; Jun 2, 2018');
});
it('returns timeframe string correctly when only start date is defined', () => {
const mockEpicItem = Object.assign({}, mockEpic, {
endDateUndefined: true,
});
vm = createComponent(mockEpicItem);
expect(vm.timeframeString).toBe('From Jul 10, 2017');
});
it('returns timeframe string correctly when only end date is defined', () => {
const mockEpicItem = Object.assign({}, mockEpic, {
startDateUndefined: true,
});
vm = createComponent(mockEpicItem);
expect(vm.timeframeString).toBe('Until Jun 2, 2018');
});
it('returns timeframe string with hidden year for start date when both start and end dates are from same year', () => {
const mockEpicItem = Object.assign({}, mockEpic, {
startDate: new Date(2018, 0, 1),
endDate: new Date(2018, 3, 1),
});
vm = createComponent(mockEpicItem);
expect(vm.timeframeString).toBe('Jan 1 &ndash; Apr 1, 2018');
});
});
});
describe('template', () => {
......
......@@ -40,6 +40,74 @@ describe('EpicItemComponent', () => {
vm.$destroy();
});
describe('startDate', () => {
it('returns Epic.startDate when start date is within range', () => {
expect(vm.startDate).toBe(mockEpic.startDate);
});
it('returns Epic.originalStartDate when start date is out of range', () => {
const mockStartDate = new Date(2018, 0, 1);
const epic = Object.assign({}, mockEpic, {
startDateOutOfRange: true,
originalStartDate: mockStartDate,
});
vm = createComponent({ epic });
expect(vm.startDate).toBe(mockStartDate);
});
});
describe('endDate', () => {
it('returns Epic.endDate when end date is within range', () => {
expect(vm.endDate).toBe(mockEpic.endDate);
});
it('returns Epic.originalEndDate when end date is out of range', () => {
const mockEndDate = new Date(2018, 0, 1);
const epic = Object.assign({}, mockEpic, {
endDateOutOfRange: true,
originalEndDate: mockEndDate,
});
vm = createComponent({ epic });
expect(vm.endDate).toBe(mockEndDate);
});
});
describe('timeframeString', () => {
it('returns timeframe string correctly when both start and end dates are defined', () => {
expect(vm.timeframeString).toBe('Jul 10, 2017 – Jun 2, 2018');
});
it('returns timeframe string correctly when only start date is defined', () => {
const epic = Object.assign({}, mockEpic, {
endDateUndefined: true,
});
vm = createComponent({ epic });
expect(vm.timeframeString).toBe('Jul 10, 2017 – No end date');
});
it('returns timeframe string correctly when only end date is defined', () => {
const epic = Object.assign({}, mockEpic, {
startDateUndefined: true,
});
vm = createComponent({ epic });
expect(vm.timeframeString).toBe('No start date – Jun 2, 2018');
});
it('returns timeframe string with hidden year for start date when both start and end dates are from same year', () => {
const epic = Object.assign({}, mockEpic, {
startDate: new Date(2018, 0, 1),
endDate: new Date(2018, 3, 1),
});
vm = createComponent({ epic });
expect(vm.timeframeString).toBe('Jan 1 – Apr 1, 2018');
});
});
describe('methods', () => {
describe('removeHighlight', () => {
it('should call _.delay after 3 seconds with a callback function which would set `epic.newEpic` to false when it is true already', done => {
......
......@@ -15,6 +15,7 @@ const createComponent = ({
timeframe = mockTimeframeMonths,
timeframeItem = mockTimeframeMonths[0],
epic = mockEpic,
timeframeString = '',
}) => {
const Component = Vue.extend(epicItemTimelineComponent);
......@@ -23,6 +24,7 @@ const createComponent = ({
timeframe,
timeframeItem,
epic,
timeframeString,
});
};
......@@ -65,6 +67,60 @@ describe('EpicItemTimelineComponent', () => {
);
});
});
describe('epicTotalWeight', () => {
it('returns the correct percentage of completed to total weights', () => {
vm = createComponent({});
expect(vm.epicTotalWeight).toBe(5);
});
it('returns undefined if weights information is not present', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
descendantWeightSum: undefined,
}),
});
expect(vm.epicTotalWeight).toBe(undefined);
});
});
describe('epicWeightPercentage', () => {
it('returns the correct percentage of completed to total weights', () => {
vm = createComponent({});
expect(vm.epicWeightPercentage).toBe(60);
});
it('returns 0 when there is no total weight', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
descendantWeightSum: undefined,
}),
});
expect(vm.epicWeightPercentage).toBe(0);
});
});
describe('popoverWeightText', () => {
it('returns a description of the weight completed', () => {
vm = createComponent({});
expect(vm.popoverWeightText).toBe('3 of 5 weight completed');
});
it('returns a description with no numbers for weight completed when there is no weights information', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
descendantWeightSum: undefined,
}),
});
expect(vm.popoverWeightText).toBe('- of - weight completed');
});
});
});
describe('template', () => {
......@@ -83,13 +139,13 @@ describe('EpicItemTimelineComponent', () => {
expect(vm.$el.querySelector('span.current-day-indicator')).not.toBeNull();
});
it('renders timeline bar element with class `timeline-bar` and class `timeline-bar-wrapper` as container element', () => {
it('renders timeline bar element with class `epic-bar` and class `epic-bar-wrapper` as container element', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframeMonths[1] }),
timeframeItem: mockTimeframeMonths[1],
});
expect(vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar')).not.toBeNull();
expect(vm.$el.querySelector('.epic-bar-wrapper .epic-bar')).not.toBeNull();
});
it('renders timeline bar with calculated `width` and `left` properties applied via style attribute', () => {
......@@ -99,41 +155,19 @@ describe('EpicItemTimelineComponent', () => {
endDate: new Date(2018, 1, 15),
}),
});
const timelineBarEl = vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar');
const timelineBarEl = vm.$el.querySelector('.epic-bar-wrapper .epic-bar');
expect(timelineBarEl.getAttribute('style')).toContain('width');
expect(timelineBarEl.getAttribute('style')).toContain('left: 0px;');
});
it('renders timeline bar with `start-date-undefined` class when Epic startDate is undefined', done => {
it('renders component with the title in the epic bar', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDateUndefined: true,
startDate: mockTimeframeMonths[0],
}),
});
const timelineBarEl = vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar');
vm.$nextTick(() => {
expect(timelineBarEl.classList.contains('start-date-undefined')).toBe(true);
done();
});
});
it('renders timeline bar with `end-date-undefined` class when Epic endDate is undefined', done => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframeMonths[0],
endDateUndefined: true,
endDate: mockTimeframeMonths[mockTimeframeMonths.length - 1],
}),
epic: Object.assign({}, mockEpic, { startDate: mockTimeframeMonths[1] }),
timeframeItem: mockTimeframeMonths[1],
});
const timelineBarEl = vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar');
vm.$nextTick(() => {
expect(timelineBarEl.classList.contains('end-date-undefined')).toBe(true);
done();
});
expect(vm.$el.querySelector('.epic-bar').textContent).toContain(mockEpic.title);
});
});
});
......@@ -251,7 +251,7 @@ describe('EpicsListSectionComponent', () => {
showBottomShadow: true,
});
expect(wrapper.find('.scroll-bottom-shadow').exists()).toBe(true);
expect(wrapper.find('.epic-scroll-bottom-shadow').exists()).toBe(true);
});
});
});
......@@ -23,6 +23,7 @@ const createComponent = ({
timeframe,
timeframeItem,
epic,
timeframeString: '',
});
};
......
......@@ -15,6 +15,7 @@ const createComponent = ({
timeframe = mockTimeframeQuarters,
timeframeItem = mockTimeframeQuarters[0],
epic = mockEpic,
timeframeString = '',
}) => {
const Component = Vue.extend(EpicItemTimelineComponent);
......@@ -23,6 +24,7 @@ const createComponent = ({
timeframe,
timeframeItem,
epic,
timeframeString,
});
};
......
......@@ -23,6 +23,7 @@ const createComponent = ({
timeframe,
timeframeItem,
epic,
timeframeString: '',
});
};
......
......@@ -101,6 +101,10 @@ export const mockEpic = {
groupFullName: 'Gitlab Org',
startDate: new Date('2017-07-10'),
endDate: new Date('2018-06-02'),
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
webUrl: '/groups/gitlab-org/-/epics/1',
};
......
......@@ -220,6 +220,9 @@ msgstr ""
msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
msgid "%{completedWeight} of %{totalWeight} weight completed"
msgstr ""
msgid "%{cores} cores"
msgstr ""
......@@ -600,6 +603,9 @@ msgid_plural "- Users"
msgstr[0] ""
msgstr[1] ""
msgid "- of - weight completed"
msgstr ""
msgid "- show less"
msgstr ""
......@@ -9925,10 +9931,13 @@ msgstr ""
msgid "Group: %{name}"
msgstr ""
msgid "GroupRoadmap|%{startDateInWords} &ndash; %{endDateInWords}"
msgid "GroupRoadmap|%{dateWord} – No end date"
msgstr ""
msgid "GroupRoadmap|From %{dateWord}"
msgid "GroupRoadmap|%{startDateInWords} – %{endDateInWords}"
msgstr ""
msgid "GroupRoadmap|No start date – %{dateWord}"
msgstr ""
msgid "GroupRoadmap|Something went wrong while fetching epics"
......@@ -9949,9 +9958,6 @@ msgstr ""
msgid "GroupRoadmap|To widen your search, change or remove filters; from %{startDate} to %{endDate}."
msgstr ""
msgid "GroupRoadmap|Until %{dateWord}"
msgstr ""
msgid "GroupSAML|Certificate fingerprint"
msgstr ""
......
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