Commit 541f2f42 authored by Phil Hughes's avatar Phil Hughes

Merge branch '4936-roadmap-navigation-presets' into 'master'

Add presets for navigating Epic Roadmap

Closes #4936

See merge request gitlab-org/gitlab-ee!5798
parents ab92643a 22309d76
......@@ -269,6 +269,17 @@ export const totalDaysInMonth = date => {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
};
/**
* Returns number of days in a quarter from provided
* months array.
*
* @param {Array} quarter
*/
export const totalDaysInQuarter = quarter => quarter.reduce(
(acc, month) => acc + totalDaysInMonth(month),
0,
);
/**
* Returns list of Dates referring to Sundays of the month
* based on provided date
......@@ -309,42 +320,27 @@ export const getSundays = date => {
};
/**
* Returns list of Dates representing a timeframe of Months from month of provided date (inclusive)
* up to provided length
*
* For eg;
* If current month is January 2018 and `length` provided is `6`
* Then this method will return list of Date objects as follows;
*
* [ October 2017, November 2017, December 2017, January 2018, February 2018, March 2018 ]
*
* If current month is March 2018 and `length` provided is `3`
* Then this method will return list of Date objects as follows;
*
* [ February 2018, March 2018, April 2018 ]
* Returns list of Dates representing a timeframe of months from startDate and length
*
* @param {Date} startDate
* @param {Number} length
* @param {Date} date
*/
export const getTimeframeWindow = (length, date) => {
if (!length) {
export const getTimeframeWindowFrom = (startDate, length) => {
if (!(startDate instanceof Date) || !length) {
return [];
}
const currentDate = date instanceof Date ? date : new Date();
const currentMonthIndex = Math.floor(length / 2);
const timeframe = [];
// Move date object backward to the first month of timeframe
currentDate.setDate(1);
currentDate.setMonth(currentDate.getMonth() - currentMonthIndex);
// Iterate and update date for the size of length
// Iterate and set date for the size of length
// and push date reference to timeframe list
for (let i = 0; i < length; i += 1) {
timeframe.push(new Date(currentDate.getTime()));
currentDate.setMonth(currentDate.getMonth() + 1);
}
const timeframe = new Array(length)
.fill()
.map(
(val, i) => new Date(
startDate.getFullYear(),
startDate.getMonth() + i,
1,
),
);
// Change date of last timeframe item to last date of the month
timeframe[length - 1].setDate(totalDaysInMonth(timeframe[length - 1]));
......@@ -352,6 +348,29 @@ export const getTimeframeWindow = (length, date) => {
return timeframe;
};
/**
* Returns count of day within current quarter from provided date
* and array of months for the quarter
*
* Eg;
* If date is 15 Feb 2018
* and quarter is [Jan, Feb, Mar]
*
* Then 15th Feb is 46th day of the quarter
* Where 31 (days in Jan) + 15 (date of Feb).
*
* @param {Date} date
* @param {Array} quarter
*/
export const dayInQuarter = (date, quarter) => quarter.reduce((acc, month) => {
if (date.getMonth() > month.getMonth()) {
return acc + totalDaysInMonth(month);
} else if (date.getMonth() === month.getMonth()) {
return acc + date.getDate();
}
return acc + 0;
}, 0);
window.gl = window.gl || {};
window.gl.utils = {
...(window.gl.utils || {}),
......
......@@ -22,6 +22,10 @@
type: Object,
required: true,
},
presetType: {
type: String,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
......@@ -129,12 +133,14 @@
/>
<roadmap-shell
v-if="showRoadmap"
:preset-type="presetType"
:epics="epics"
:timeframe="timeframe"
:current-group-id="currentGroupId"
/>
<epics-list-empty
v-if="isEpicsListEmpty"
:preset-type="presetType"
:timeframe-start="timeframeStart"
:timeframe-end="timeframeEnd"
:has-filters-applied="hasFiltersApplied"
......
......@@ -8,6 +8,10 @@
epicItemTimeline,
},
props: {
presetType: {
type: String,
required: true,
},
epic: {
type: Object,
required: true,
......@@ -41,6 +45,7 @@
<epic-item-timeline
v-for="(timeframeItem, index) in timeframe"
:key="index"
:preset-type="presetType"
:timeframe="timeframe"
:timeframe-item="timeframeItem"
:epic="epic"
......
<script>
import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility';
import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility';
import NewEpic from '../../epics/new_epic/components/new_epic.vue';
import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants';
export default {
components: {
NewEpic,
import NewEpic from '../../epics/new_epic/components/new_epic.vue';
export default {
components: {
NewEpic,
},
props: {
presetType: {
type: String,
required: true,
},
timeframeStart: {
type: [Date, Object],
required: true,
},
timeframeEnd: {
type: [Date, Object],
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
},
props: {
timeframeStart: {
type: Date,
required: true,
},
timeframeEnd: {
type: Date,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
},
newEpicEndpoint: {
type: String,
required: true,
},
emptyStateIllustrationPath: {
type: String,
required: true,
},
newEpicEndpoint: {
type: String,
required: true,
},
computed: {
timeframeRange() {
const startDate = dateInWords(
emptyStateIllustrationPath: {
type: String,
required: true,
},
},
computed: {
timeframeRange() {
let startDate;
let endDate;
if (this.presetType === PRESET_TYPES.QUARTERS) {
const quarterStart = this.timeframeStart.range[0];
const quarterEnd = this.timeframeEnd.range[2];
startDate = dateInWords(
quarterStart,
true,
quarterStart.getFullYear() === quarterEnd.getFullYear(),
);
endDate = dateInWords(quarterEnd, true);
} else if (this.presetType === PRESET_TYPES.MONTHS) {
startDate = dateInWords(
this.timeframeStart,
true,
this.timeframeStart.getFullYear() === this.timeframeEnd.getFullYear(),
);
const endDate = dateInWords(this.timeframeEnd, true);
endDate = dateInWords(this.timeframeEnd, true);
} else if (this.presetType === PRESET_TYPES.WEEKS) {
const end = new Date(this.timeframeEnd.getTime());
end.setDate(end.getDate() + 6);
return {
startDate,
endDate,
};
},
message() {
if (this.hasFiltersApplied) {
return s__('GroupRoadmap|Sorry, no epics matched your search');
}
return s__('GroupRoadmap|The roadmap shows the progress of your epics along a timeline');
},
subMessage() {
if (this.hasFiltersApplied) {
return sprintf(s__('GroupRoadmap|To widen your search, change or remove filters. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}.'), {
startDate: this.timeframeRange.startDate,
endDate: this.timeframeRange.endDate,
});
}
return sprintf(s__('GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}.'), {
startDate = dateInWords(
this.timeframeStart,
true,
this.timeframeStart.getFullYear() === end.getFullYear(),
);
endDate = dateInWords(end, true);
}
return {
startDate,
endDate,
};
},
message() {
if (this.hasFiltersApplied) {
return s__('GroupRoadmap|Sorry, no epics matched your search');
}
return s__('GroupRoadmap|The roadmap shows the progress of your epics along a timeline');
},
subMessage() {
if (this.hasFiltersApplied) {
return sprintf(PRESET_DEFAULTS[this.presetType].emptyStateWithFilters, {
startDate: this.timeframeRange.startDate,
endDate: this.timeframeRange.endDate,
});
},
}
return sprintf(PRESET_DEFAULTS[this.presetType].emptyStateDefault, {
startDate: this.timeframeRange.startDate,
endDate: this.timeframeRange.endDate,
});
},
};
},
};
</script>
<template>
......
......@@ -13,6 +13,10 @@
SectionMixin,
],
props: {
presetType: {
type: String,
required: true,
},
epics: {
type: Array,
required: true,
......@@ -148,6 +152,7 @@
<epic-item
v-for="(epic, index) in epics"
:key="index"
:preset-type="presetType"
:epic="epic"
:timeframe="timeframe"
:current-group-id="currentGroupId"
......
<script>
import { monthInWords } from '~/lib/utils/datetime_utility';
import timelineHeaderSubItem from './timeline_header_sub_item.vue';
import MonthsHeaderSubItem from './months_header_sub_item.vue';
export default {
components: {
timelineHeaderSubItem,
MonthsHeaderSubItem,
},
props: {
timeframeIndex: {
......@@ -27,6 +27,8 @@
},
data() {
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
return {
currentDate,
currentYear: currentDate.getFullYear(),
......@@ -93,7 +95,7 @@
>
{{ timelineHeaderLabel }}
</div>
<timeline-header-sub-item
<months-header-sub-item
:timeframe-item="timeframeItem"
:current-date="currentDate"
/>
......
<script>
import { getSundays } from '~/lib/utils/datetime_utility';
import timelineTodayIndicator from './timeline_today_indicator.vue';
import { PRESET_TYPES } from '../../constants';
import timelineTodayIndicator from '../timeline_today_indicator.vue';
export default {
presetType: PRESET_TYPES.MONTHS,
components: {
timelineTodayIndicator,
},
......@@ -40,10 +43,16 @@
},
methods: {
getSubItemValueClass(subItem) {
// Show light color text for dates which are
// older than today
if (subItem < this.currentDate) {
return 'value-light';
const daysToClosestWeek = this.currentDate.getDate() - subItem.getDate();
// Show dark color text only for upcoming dates
// and current week date
if (daysToClosestWeek <= 6 &&
this.currentDate.getDate() >= subItem.getDate() &&
this.currentDate.getFullYear() === subItem.getFullYear() &&
this.currentDate.getMonth() === subItem.getMonth()) {
return 'label-dark label-bold';
} else if (subItem >= this.currentDate) {
return 'label-dark';
}
return '';
},
......@@ -66,8 +75,9 @@
</span>
<timeline-today-indicator
v-if="hasToday"
:timeframe-item="timeframeItem"
:preset-type="$options.presetType"
:current-date="currentDate"
:timeframe-item="timeframeItem"
/>
</div>
</template>
<script>
import QuartersHeaderSubItem from './quarters_header_sub_item.vue';
export default {
components: {
QuartersHeaderSubItem,
},
props: {
timeframeIndex: {
type: Number,
required: true,
},
timeframeItem: {
type: Object,
required: true,
},
timeframe: {
type: Array,
required: true,
},
itemWidth: {
type: Number,
required: true,
},
},
data() {
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
return {
currentDate,
quarterBeginDate: this.timeframeItem.range[0],
quarterEndDate: this.timeframeItem.range[2],
};
},
computed: {
itemStyles() {
return {
width: `${this.itemWidth}px`,
};
},
timelineHeaderLabel() {
const { quarterSequence } = this.timeframeItem;
if (quarterSequence === 1 ||
this.timeframeIndex === 0 && quarterSequence !== 1) {
return `${this.timeframeItem.year} Q${quarterSequence}`;
}
return `Q${quarterSequence}`;
},
timelineHeaderClass() {
let headerClass = '';
if (this.currentDate >= this.quarterBeginDate &&
this.currentDate <= this.quarterEndDate) {
headerClass = 'label-dark label-bold';
} else if (this.currentDate < this.quarterBeginDate) {
headerClass = 'label-dark';
}
return headerClass;
},
},
};
</script>
<template>
<span
class="timeline-header-item"
:style="itemStyles"
>
<div
class="item-label"
:class="timelineHeaderClass"
>
{{ timelineHeaderLabel }}
</div>
<quarters-header-sub-item
:timeframe-item="timeframeItem"
:current-date="currentDate"
/>
</span>
</template>
<script>
import { monthInWords } from '~/lib/utils/datetime_utility';
import { PRESET_TYPES } from '../../constants';
import timelineTodayIndicator from '../timeline_today_indicator.vue';
export default {
presetType: PRESET_TYPES.QUARTERS,
components: {
timelineTodayIndicator,
},
props: {
currentDate: {
type: Date,
required: true,
},
timeframeItem: {
type: Object,
required: true,
},
},
data() {
return {
quarterBeginDate: this.timeframeItem.range[0],
quarterEndDate: this.timeframeItem.range[2],
};
},
computed: {
headerSubItems() {
return this.timeframeItem.range;
},
hasToday() {
return this.currentDate >= this.quarterBeginDate &&
this.currentDate <= this.quarterEndDate;
},
},
methods: {
getSubItemValueClass(subItem) {
let itemValueClass = '';
if (this.currentDate.getFullYear() === subItem.getFullYear() &&
this.currentDate.getMonth() === subItem.getMonth()) {
itemValueClass = 'label-dark label-bold';
} else if (this.currentDate < subItem) {
itemValueClass = 'label-dark';
}
return itemValueClass;
},
getSubItemValue(subItem) {
return monthInWords(subItem, true);
},
},
};
</script>
<template>
<div class="item-sublabel">
<span
v-for="(subItem, index) in headerSubItems"
:key="index"
class="sublabel-value"
:class="getSubItemValueClass(subItem)"
>
{{ getSubItemValue(subItem) }}
</span>
<timeline-today-indicator
v-if="hasToday"
:preset-type="$options.presetType"
:current-date="currentDate"
:timeframe-item="timeframeItem"
/>
</div>
</template>
<script>
import { monthInWords } from '~/lib/utils/datetime_utility';
import WeeksHeaderSubItem from './weeks_header_sub_item.vue';
export default {
components: {
WeeksHeaderSubItem,
},
props: {
timeframeIndex: {
type: Number,
required: true,
},
timeframeItem: {
type: Date,
required: true,
},
timeframe: {
type: Array,
required: true,
},
itemWidth: {
type: Number,
required: true,
},
},
data() {
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
const lastDayOfCurrentWeek = new Date(this.timeframeItem.getTime());
lastDayOfCurrentWeek.setDate(lastDayOfCurrentWeek.getDate() + 7);
return {
currentDate,
lastDayOfCurrentWeek,
};
},
computed: {
itemStyles() {
return {
width: `${this.itemWidth}px`,
};
},
timelineHeaderLabel() {
if (this.timeframeIndex === 0) {
return `${this.timeframeItem.getFullYear()} ${monthInWords(this.timeframeItem, true)} ${this.timeframeItem.getDate()}`;
}
return `${monthInWords(this.timeframeItem, true)} ${this.timeframeItem.getDate()}`;
},
timelineHeaderClass() {
if (this.currentDate >= this.timeframeItem &&
this.currentDate <= this.lastDayOfCurrentWeek) {
return 'label-dark label-bold';
} else if (this.currentDate < this.lastDayOfCurrentWeek) {
return 'label-dark';
}
return '';
},
},
};
</script>
<template>
<span
class="timeline-header-item"
:style="itemStyles"
>
<div
class="item-label"
:class="timelineHeaderClass"
>
{{ timelineHeaderLabel }}
</div>
<weeks-header-sub-item
:timeframe-item="timeframeItem"
:current-date="currentDate"
/>
</span>
</template>
<script>
import { PRESET_TYPES } from '../../constants';
import timelineTodayIndicator from '../timeline_today_indicator.vue';
export default {
presetType: PRESET_TYPES.WEEKS,
components: {
timelineTodayIndicator,
},
props: {
currentDate: {
type: Date,
required: true,
},
timeframeItem: {
type: Date,
required: true,
},
},
data() {
const timeframeItem = new Date(this.timeframeItem.getTime());
const headerSubItems = new Array(7)
.fill()
.map(
(val, i) => new Date(
timeframeItem.getFullYear(),
timeframeItem.getMonth(),
timeframeItem.getDate() + i,
),
);
return {
headerSubItems,
};
},
computed: {
hasToday() {
return (
this.currentDate >= this.headerSubItems[0] &&
this.currentDate <= this.headerSubItems[this.headerSubItems.length - 1]
);
},
},
methods: {
getSubItemValueClass(subItem) {
// Show dark color text only for current & upcoming dates
if (subItem.getTime() === this.currentDate.getTime()) {
return 'label-dark label-bold';
} else if (subItem > this.currentDate) {
return 'label-dark';
}
return '';
},
},
};
</script>
<template>
<div class="item-sublabel">
<span
v-for="(subItem, index) in headerSubItems"
:key="index"
class="sublabel-value"
:class="getSubItemValueClass(subItem)"
>
{{ subItem.getDate() }}
</span>
<timeline-today-indicator
v-if="hasToday"
:preset-type="$options.presetType"
:current-date="currentDate"
:timeframe-item="timeframeItem"
/>
</div>
</template>
......@@ -12,6 +12,10 @@
roadmapTimelineSection,
},
props: {
presetType: {
type: String,
required: true,
},
epics: {
type: Array,
required: true,
......@@ -65,9 +69,7 @@
},
handleScroll() {
const { scrollTop, scrollLeft, clientHeight, scrollHeight } = this.$el;
if (!this.noScroll) {
eventHub.$emit('epicsListScrolled', { scrollTop, scrollLeft, clientHeight, scrollHeight });
}
eventHub.$emit('epicsListScrolled', { scrollTop, scrollLeft, clientHeight, scrollHeight });
},
},
};
......@@ -81,12 +83,14 @@
@scroll="handleScroll"
>
<roadmap-timeline-section
:preset-type="presetType"
:epics="epics"
:timeframe="timeframe"
:shell-width="shellWidth"
:list-scrollable="!noScroll"
/>
<epics-list-section
:preset-type="presetType"
:epics="epics"
:timeframe="timeframe"
:shell-width="shellWidth"
......
<script>
import eventHub from '../event_hub';
import { PRESET_TYPES } from '../constants';
import SectionMixin from '../mixins/section_mixin';
import timelineHeaderItem from './timeline_header_item.vue';
import QuartersHeaderItem from './preset_quarters/quarters_header_item.vue';
import MonthsHeaderItem from './preset_months/months_header_item.vue';
import WeeksHeaderItem from './preset_weeks/weeks_header_item.vue';
export default {
components: {
timelineHeaderItem,
QuartersHeaderItem,
MonthsHeaderItem,
WeeksHeaderItem,
},
mixins: [
SectionMixin,
],
props: {
presetType: {
type: String,
required: true,
},
epics: {
type: Array,
required: true,
......@@ -35,6 +45,18 @@
scrolledHeaderClass: '',
};
},
computed: {
headerItemComponentForPreset() {
if (this.presetType === PRESET_TYPES.QUARTERS) {
return 'quarters-header-item';
} else if (this.presetType === PRESET_TYPES.MONTHS) {
return 'months-header-item';
} else if (this.presetType === PRESET_TYPES.WEEKS) {
return 'weeks-header-item';
}
return '';
},
},
mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
},
......@@ -57,7 +79,8 @@
:style="sectionContainerStyles"
>
<span class="timeline-header-blank"></span>
<timeline-header-item
<component
:is="headerItemComponentForPreset"
v-for="(timeframeItem, index) in timeframe"
:key="index"
:timeframe-index="index"
......
<script>
import { totalDaysInMonth } from '~/lib/utils/datetime_utility';
import { totalDaysInMonth, dayInQuarter, totalDaysInQuarter } from '~/lib/utils/datetime_utility';
import eventHub from '../event_hub';
import { EPIC_DETAILS_CELL_WIDTH, PRESET_TYPES } from '../constants';
export default {
props: {
currentDate: {
type: Date,
required: true,
},
timeframeItem: {
type: Date,
required: true,
},
import eventHub from '../event_hub';
export default {
props: {
presetType: {
type: String,
required: true,
},
data() {
return {
todayBarStyles: '',
todayBarReady: false,
};
currentDate: {
type: Date,
required: true,
},
mounted() {
eventHub.$on('epicsListRendered', this.handleEpicsListRender);
timeframeItem: {
type: [Date, Object],
required: true,
},
beforeDestroy() {
eventHub.$off('epicsListRendered', this.handleEpicsListRender);
},
data() {
return {
todayBarStyles: '',
todayBarReady: false,
};
},
mounted() {
eventHub.$on('epicsListRendered', this.handleEpicsListRender);
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
},
beforeDestroy() {
eventHub.$off('epicsListRendered', this.handleEpicsListRender);
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
},
methods: {
/**
* This method takes height of current shell
* and renders vertical line over the area where
* today falls in current timeline
*/
handleEpicsListRender({ height }) {
let left = 0;
// Get total days of current timeframe Item and then
// get size in % from current date and days in range
// based on the current presetType
if (this.presetType === PRESET_TYPES.QUARTERS) {
left = Math.floor(
dayInQuarter(this.currentDate, this.timeframeItem.range) /
totalDaysInQuarter(this.timeframeItem.range) *
100,
);
} else if (this.presetType === PRESET_TYPES.MONTHS) {
left = Math.floor(this.currentDate.getDate() / totalDaysInMonth(this.timeframeItem) * 100);
} else if (this.presetType === PRESET_TYPES.WEEKS) {
left = Math.floor(((this.currentDate.getDay() + 1) / 7 * 100) - 7);
}
// We add 20 to height to ensure that
// today indicator goes past the bottom
// edge of the browser even when
// scrollbar is present
this.todayBarStyles = {
height: `${height + 20}px`,
left: `${left}%`,
};
this.todayBarReady = true;
},
methods: {
/**
* This method takes height of current shell
* and renders vertical line over the area where
* today falls in current timeline
*/
handleEpicsListRender({ height }) {
// Get total days of current timeframe Item
const daysInMonth = totalDaysInMonth(this.timeframeItem);
// Get size in % from current date and days in month.
const left = Math.floor((this.currentDate.getDate() / daysInMonth) * 100);
handleEpicsListScroll() {
const indicatorX = this.$el.getBoundingClientRect().x;
const rootOffsetLeft = this.$root.$el.offsetLeft;
// We add 20 to height to ensure that
// today indicator goes past the bottom
// edge of the browser even when
// scrollbar is present
this.todayBarStyles = {
height: `${height + 20}px`,
left: `${left}%`,
};
this.todayBarReady = true;
},
// 3px to compensate size of bubble on top of Indicator
this.todayBarReady = (indicatorX - rootOffsetLeft) >= (EPIC_DETAILS_CELL_WIDTH + 3);
},
};
},
};
</script>
<template>
<span
v-if="todayBarReady"
class="today-bar"
:class="{ 'invisible': !todayBarReady }"
:style="todayBarStyles"
>
</span>
......
import { s__ } from '~/locale';
export const TIMEFRAME_LENGTH = 6;
export const EPIC_DETAILS_CELL_WIDTH = 320;
......@@ -13,3 +15,27 @@ export const SCROLL_BAR_SIZE = 15;
export const TIMELINE_END_OFFSET_HALF = 8;
export const TIMELINE_END_OFFSET_FULL = 16;
export const PRESET_TYPES = {
QUARTERS: 'QUARTERS',
MONTHS: 'MONTHS',
WEEKS: 'WEEKS',
};
export const PRESET_DEFAULTS = {
QUARTERS: {
TIMEFRAME_LENGTH: 18,
emptyStateDefault: s__('GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. In the quarters view, only epics in the past quarter, current quarter, and next 4 quarters are shown &ndash; from %{startDate} to %{endDate}.'),
emptyStateWithFilters: s__('GroupRoadmap|To widen your search, change or remove filters. In the quarters view, only epics in the past quarter, current quarter, and next 4 quarters are shown &ndash; from %{startDate} to %{endDate}.'),
},
MONTHS: {
TIMEFRAME_LENGTH: 7,
emptyStateDefault: s__('GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. In the months view, only epics in the past month, current month, and next 5 months are shown &ndash; from %{startDate} to %{endDate}.'),
emptyStateWithFilters: s__('GroupRoadmap|To widen your search, change or remove filters. In the months view, only epics in the past month, current month, and next 5 months are shown &ndash; from %{startDate} to %{endDate}.'),
},
WEEKS: {
TIMEFRAME_LENGTH: 42,
emptyStateDefault: s__('GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. In the weeks view, only epics in the past week, current week, and next 4 weeks are shown &ndash; from %{startDate} to %{endDate}.'),
emptyStateWithFilters: s__('GroupRoadmap|To widen your search, change or remove filters. In the weeks view, only epics in the past week, current week, and next 4 weeks are shown &ndash; from %{startDate} to %{endDate}.'),
},
};
......@@ -3,9 +3,11 @@ import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import { convertPermissionToBoolean } from '~/lib/utils/common_utils';
import { getTimeframeWindow } from '~/lib/utils/datetime_utility';
import { visitUrl, mergeUrlParams } from '~/lib/utils/url_utility';
import { TIMEFRAME_LENGTH } from './constants';
import { PRESET_TYPES } from './constants';
import { getTimeframeForPreset, getEpicsPathForPreset } from './utils/roadmap_utils';
import RoadmapStore from './store/roadmap_store';
import RoadmapService from './service/roadmap_service';
......@@ -16,41 +18,51 @@ Vue.use(Translate);
export default () => {
const el = document.getElementById('js-roadmap');
const presetButtonsContainer = document.querySelector('.js-btn-roadmap-presets');
if (!el) {
return false;
}
// This event handler is to be removed in 11.1 once
// we allow user to save selected preset in db
if (presetButtonsContainer) {
presetButtonsContainer.addEventListener('click', e => {
const presetType = e.target.querySelector('input[name="presetType"]').value;
visitUrl(mergeUrlParams({ layout: presetType }, location.href));
});
}
return new Vue({
el,
components: {
roadmapApp,
},
data() {
const supportedPresetTypes = Object.keys(PRESET_TYPES);
const dataset = this.$options.el.dataset;
const hasFiltersApplied = convertPermissionToBoolean(dataset.hasFiltersApplied);
const presetType =
supportedPresetTypes.indexOf(dataset.presetType) > -1
? dataset.presetType
: PRESET_TYPES.MONTHS;
const filterQueryString = window.location.search.substring(1);
const timeframe = getTimeframeForPreset(presetType);
const epicsPath = getEpicsPathForPreset({
basePath: dataset.epicsPath,
filterQueryString,
presetType,
timeframe,
});
// Construct Epic API path to include
// `start_date` & `end_date` query params to get list of
// epics only for current timeframe.
const timeframe = getTimeframeWindow(TIMEFRAME_LENGTH);
const start = timeframe[0];
const end = timeframe[TIMEFRAME_LENGTH - 1];
const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`;
const endDate = `${end.getFullYear()}-${end.getMonth() + 1}-${end.getDate()}`;
let epicsPath = `${dataset.epicsPath}?start_date=${startDate}&end_date=${endDate}`;
if (filterQueryString) {
epicsPath += `&${filterQueryString}`;
}
const store = new RoadmapStore(parseInt(dataset.groupId, 0), timeframe);
const store = new RoadmapStore(parseInt(dataset.groupId, 0), timeframe, presetType);
const service = new RoadmapService(epicsPath);
return {
store,
service,
presetType,
hasFiltersApplied,
newEpicEndpoint: dataset.newEpicEndpoint,
emptyStateIllustrationPath: dataset.emptyStateIllustration,
......@@ -61,6 +73,7 @@ export default () => {
props: {
store: this.store,
service: this.service,
presetType: this.presetType,
hasFiltersApplied: this.hasFiltersApplied,
newEpicEndpoint: this.newEpicEndpoint,
emptyStateIllustrationPath: this.emptyStateIllustrationPath,
......
import { totalDaysInMonth } from '~/lib/utils/datetime_utility';
import { TIMELINE_END_OFFSET_HALF } from '../constants';
export default {
methods: {
/**
* Check if current epic starts within current month (timeline cell)
*/
hasStartDateForMonth() {
return (
this.epic.startDate.getMonth() === this.timeframeItem.getMonth() &&
this.epic.startDate.getFullYear() === this.timeframeItem.getFullYear()
);
},
/**
* Check if current epic ends within current month (timeline cell)
*/
isTimeframeUnderEndDateForMonth(timeframeItem, epicEndDate) {
return (
timeframeItem.getYear() <= epicEndDate.getYear() &&
timeframeItem.getMonth() === epicEndDate.getMonth()
);
},
/**
* Return timeline bar width for current month (timeline cell) based on
* cellWidth, days in month and date of the month
*/
getBarWidthForSingleMonth(cellWidth, daysInMonth, date) {
const dayWidth = cellWidth / daysInMonth;
const barWidth = date === daysInMonth ? cellWidth : dayWidth * date;
return Math.min(cellWidth, barWidth);
},
/**
* In case startDate for any epic is undefined or is out of range
* for current timeframe, we have to provide specific offset while
* positioning it to ensure that;
*
* 1. Timeline bar starts at correct position based on start date.
* 2. Bar starts exactly at the start of cell in case start date is `1`.
* 3. A "triangle" shape is shown at the beginning of timeline bar
* when startDate is out of range.
*/
getTimelineBarStartOffsetForMonths() {
const daysInMonth = totalDaysInMonth(this.timeframeItem);
const startDate = this.epic.startDate.getDate();
if (
this.epic.startDateOutOfRange ||
(this.epic.startDateUndefined && this.epic.endDateOutOfRange)
) {
// If Epic startDate is out of timeframe range
// OR
// Epic startDate is undefined AND Epic endDate is out of timeframe range
// no offset is needed.
return '';
} else if (startDate === 1) {
// If Epic startDate is first day of the month
// Set offset to 0.
return 'left: 0;';
}
// If Epic end date is out of range
const lastTimeframeItem = this.timeframe[this.timeframe.length - 1];
// Check if Epic start date falls within last month of the timeframe
if (
this.epic.startDate.getMonth() === lastTimeframeItem.getMonth() &&
this.epic.startDate.getFullYear() === lastTimeframeItem.getFullYear()
) {
// Compensate for triangle size
return `right: ${TIMELINE_END_OFFSET_HALF}px;`;
}
// Calculate proportional offset based on startDate and total days in
// current month.
return `left: ${startDate / daysInMonth * 100}%;`;
},
/**
* This method is externally only called when current timeframe cell has timeline
* bar to show. So when this method is called, we iterate over entire timeframe
* array starting from current timeframeItem.
*
* For eg;
* If timeframe range for 7 months is;
* 2017 Oct, 2017 Nov, 2017 Dec, 2018 Jan, 2018 Feb, 2018 Mar, 2018 Apr
*
* And if Epic starts in 2017 Dec and ends in 2018 Feb.
*
* Then this method will iterate over timeframe as;
* 2017 Dec => 2018 Feb
* And will add up width(see 1.) for timeline bar for each month in iteration
* based on provided start and end dates.
*
* 1. Width from date is calculated by totalWidthCell / totalDaysInMonth = widthOfSingleDay
* and then dateOfMonth x widthOfSingleDay = totalBarWidth
*/
getTimelineBarWidthForMonths() {
let timelineBarWidth = 0;
const indexOfCurrentMonth = this.timeframe.indexOf(this.timeframeItem);
const cellWidth = this.getCellWidth();
const offsetEnd = this.getTimelineBarEndOffset();
const epicStartDate = this.epic.startDate;
const epicEndDate = this.epic.endDate;
// Start iteration from current month
for (let i = indexOfCurrentMonth; i < this.timeframe.length; i += 1) {
// Get total days for current month
const daysInMonth = totalDaysInMonth(this.timeframe[i]);
if (i === indexOfCurrentMonth) {
// If this is current month
if (this.isTimeframeUnderEndDateForMonth(this.timeframe[i], epicEndDate)) {
// If Epic endDate falls under the range of current timeframe month
// then get width for number of days between start and end dates (inclusive)
timelineBarWidth += this.getBarWidthForSingleMonth(
cellWidth,
daysInMonth,
epicEndDate.getDate() - epicStartDate.getDate() + 1,
);
// Break as Epic start and end date fall within current timeframe month itself!
break;
} else {
// Epic end date does NOT fall in current month.
// If start date is first day of the month,
// we need width of full cell (i.e. total days of month)
// otherwise, we need width only for date from total days of month.
const date =
epicStartDate.getDate() === 1 ? daysInMonth : daysInMonth - epicStartDate.getDate();
timelineBarWidth += this.getBarWidthForSingleMonth(cellWidth, daysInMonth, date);
}
} else if (this.isTimeframeUnderEndDateForMonth(this.timeframe[i], epicEndDate)) {
// If this is NOT current month but epicEndDate falls under
// current timeframe month then calculate width
// based on date of the month
timelineBarWidth += this.getBarWidthForSingleMonth(
cellWidth,
daysInMonth,
epicEndDate.getDate(),
);
// Break as Epic end date falls within current timeframe month!
break;
} else {
// This is neither current month,
// nor does the Epic end date fall under current timeframe month
// add width for entire cell of current timeframe.
timelineBarWidth += this.getBarWidthForSingleMonth(cellWidth, daysInMonth, daysInMonth);
}
}
// Reduce any offset from total width and round it off.
return timelineBarWidth - offsetEnd;
},
},
};
import { totalDaysInQuarter, dayInQuarter } from '~/lib/utils/datetime_utility';
import { TIMELINE_END_OFFSET_HALF } from '../constants';
export default {
methods: {
/**
* Check if current epic starts within current quarter (timeline cell)
*/
hasStartDateForQuarter() {
const quarterStart = this.timeframeItem.range[0];
const quarterEnd = this.timeframeItem.range[2];
return this.epic.startDate >= quarterStart && this.epic.startDate <= quarterEnd;
},
/**
* Check if current epic ends within current quarter (timeline cell)
*/
isTimeframeUnderEndDateForQuarter(timeframeItem, epicEndDate) {
const quarterEnd = timeframeItem.range[2];
return epicEndDate <= quarterEnd;
},
/**
* Return timeline bar width for current quarter (timeline cell) based on
* cellWidth, days in quarter and day of the quarter
*/
getBarWidthForSingleQuarter(cellWidth, daysInQuarter, day) {
const dayWidth = cellWidth / daysInQuarter;
const barWidth = day === daysInQuarter ? cellWidth : dayWidth * day;
return Math.min(cellWidth, barWidth);
},
/**
* In case startDate for any epic is undefined or is out of range
* for current timeframe, we have to provide specific offset while
* positioning it to ensure that;
*
* 1. Timeline bar starts at correct position based on start date.
* 2. Bar starts exactly at the start of cell in case start date is `1`.
* 3. A "triangle" shape is shown at the beginning of timeline bar
* when startDate is out of range.
*
* Implementation of this method is identical to
* MonthsPresetMixin#getTimelineBarStartOffsetForMonths
*/
getTimelineBarStartOffsetForQuarters() {
const daysInQuarter = totalDaysInQuarter(this.timeframeItem.range);
const startDay = dayInQuarter(this.epic.startDate, this.timeframeItem.range);
if (
this.epic.startDateOutOfRange ||
(this.epic.startDateUndefined && this.epic.endDateOutOfRange)
) {
return '';
} else if (startDay === 1) {
return 'left: 0;';
}
const lastTimeframeItem = this.timeframe[this.timeframe.length - 1].range[2];
if (
this.epic.startDate >= this.timeframe[this.timeframe.length - 1].range[0] &&
this.epic.startDate <= lastTimeframeItem
) {
return `right: ${TIMELINE_END_OFFSET_HALF}px;`;
}
return `left: ${startDay / daysInQuarter * 100}%;`;
},
/**
* This method is externally only called when current timeframe cell has timeline
* bar to show. So when this method is called, we iterate over entire timeframe
* array starting from current timeframeItem.
*
* For eg;
* If timeframe range for 6 quarters is;
* [2017 Oct Nov Dec], [2018 Jan Feb Mar], [Apr May Jun],
* [Jul Aug Sep], [Oct Nov Dec], [2019 Jan Feb Mar]
*
* And if Epic starts in 2017 Dec and ends in 2018 May.
*
* Then this method will iterate over timeframe as;
* [2017 Oct Nov Dec] => [2018 Apr May Jun]
* And will add up width(see 1.) for timeline bar for each quarter in iteration
* based on provided start and end dates.
*
* 1. Width from date is calculated by totalWidthCell / totalDaysInQuarter = widthOfSingleDay
* and then dayOfQuarter x widthOfSingleDay = totalBarWidth
*
* Implementation of this method is identical to
* MonthsPresetMixin#getTimelineBarWidthForMonths
*/
getTimelineBarWidthForQuarters() {
let timelineBarWidth = 0;
const indexOfCurrentQuarter = this.timeframe.indexOf(this.timeframeItem);
const cellWidth = this.getCellWidth();
const offsetEnd = this.getTimelineBarEndOffset();
const epicStartDate = this.epic.startDate;
const epicEndDate = this.epic.endDate;
for (let i = indexOfCurrentQuarter; i < this.timeframe.length; i += 1) {
const currentQuarter = this.timeframe[i].range;
if (i === indexOfCurrentQuarter) {
if (this.isTimeframeUnderEndDateForQuarter(this.timeframe[i], epicEndDate)) {
timelineBarWidth += this.getBarWidthForSingleQuarter(
cellWidth,
totalDaysInQuarter(currentQuarter),
dayInQuarter(epicEndDate, currentQuarter) -
dayInQuarter(epicStartDate, currentQuarter) +
1,
);
break;
} else {
const daysInQuarter = totalDaysInQuarter(currentQuarter);
const day = dayInQuarter(epicStartDate, currentQuarter);
const date = day === 1 ? daysInQuarter : daysInQuarter - day;
timelineBarWidth += this.getBarWidthForSingleQuarter(
cellWidth,
totalDaysInQuarter(currentQuarter),
date,
);
}
} else if (this.isTimeframeUnderEndDateForQuarter(this.timeframe[i], epicEndDate)) {
timelineBarWidth += this.getBarWidthForSingleQuarter(
cellWidth,
totalDaysInQuarter(currentQuarter),
dayInQuarter(epicEndDate, currentQuarter),
);
break;
} else {
const daysInQuarter = totalDaysInQuarter(currentQuarter);
timelineBarWidth += this.getBarWidthForSingleQuarter(
cellWidth,
daysInQuarter,
daysInQuarter,
);
}
}
return timelineBarWidth - offsetEnd;
},
},
};
import { TIMELINE_END_OFFSET_HALF } from '../constants';
export default {
methods: {
/**
* Check if current epic starts within current week (timeline cell)
*/
hasStartDateForWeek() {
const firstDayOfWeek = this.timeframeItem;
const lastDayOfWeek = new Date(this.timeframeItem.getTime());
lastDayOfWeek.setDate(lastDayOfWeek.getDate() + 6);
return this.epic.startDate >= firstDayOfWeek && this.epic.startDate <= lastDayOfWeek;
},
/**
* Return last date of the week from provided timeframeItem
*/
getLastDayOfWeek(timeframeItem) {
const lastDayOfWeek = new Date(timeframeItem.getTime());
lastDayOfWeek.setDate(lastDayOfWeek.getDate() + 6);
return lastDayOfWeek;
},
/**
* Check if current epic ends within current week (timeline cell)
*/
isTimeframeUnderEndDateForWeek(timeframeItem, epicEndDate) {
const lastDayOfWeek = this.getLastDayOfWeek(timeframeItem);
return epicEndDate <= lastDayOfWeek;
},
/**
* Return timeline bar width for current week (timeline cell) based on
* cellWidth, days in week (7) and day of the week (non-zero based index)
*/
getBarWidthForSingleWeek(cellWidth, day) {
const dayWidth = cellWidth / 7;
const barWidth = day === 7 ? cellWidth : dayWidth * day;
return Math.min(cellWidth, barWidth);
},
/**
* Gets timelinebar end offset based width of single day
* and TIMELINE_END_OFFSET_HALF
*/
getTimelineBarEndOffsetHalfForWeek() {
const dayWidth = this.getCellWidth() / 7;
return TIMELINE_END_OFFSET_HALF + (dayWidth * 0.5);
},
/**
* In case startDate for any epic is undefined or is out of range
* for current timeframe, we have to provide specific offset while
* positioning it to ensure that;
*
* 1. Timeline bar starts at correct position based on start date.
* 2. Bar starts exactly at the start of cell in case start date is `1`.
* 3. A "triangle" shape is shown at the beginning of timeline bar
* when startDate is out of range.
*
* Implementation of this method is identical to
* MonthsPresetMixin#getTimelineBarStartOffsetForMonths
*/
getTimelineBarStartOffsetForWeeks() {
const daysInWeek = 7;
const dayWidth = this.getCellWidth() / daysInWeek;
const startDate = this.epic.startDate.getDay() + 1;
const firstDayOfWeek = this.timeframeItem.getDay() + 1;
if (
this.epic.startDateOutOfRange ||
(this.epic.startDateUndefined && this.epic.endDateOutOfRange)
) {
return '';
} else if (startDate === firstDayOfWeek) {
return 'left: 0;';
}
const lastTimeframeItem = new Date(this.timeframe[this.timeframe.length - 1].getTime());
lastTimeframeItem.setDate(lastTimeframeItem.getDate() + 6);
if (
this.epic.startDate >= this.timeframe[this.timeframe.length - 1] &&
this.epic.startDate <= lastTimeframeItem
) {
return `right: ${TIMELINE_END_OFFSET_HALF}px;`;
}
return `left: ${(startDate * dayWidth) - (dayWidth / 2)}px;`;
},
/**
* This method is externally only called when current timeframe cell has timeline
* bar to show. So when this method is called, we iterate over entire timeframe
* array starting from current timeframeItem.
*
* For eg;
* If timeframe range for 6 weeks is;
* May 27, Jun 3, Jun 10, Jun 17, Jun 24, Jul 1
*
* And if Epic starts in May 30 and ends on June 20.
*
* Then this method will iterate over timeframe as;
* May 27 => Jun 17
* And will add up width(see 1.) for timeline bar for each week in iteration
* based on provided start and end dates.
*
* 1. Width from date is calculated by totalWidthCell / totalDaysInWeek = widthOfSingleDay
* and then dayOfWeek x widthOfSingleDay = totalBarWidth
*
* Implementation of this method is identical to
* MonthsPresetMixin#getTimelineBarWidthForMonths
*/
getTimelineBarWidthForWeeks() {
let timelineBarWidth = 0;
const indexOfCurrentWeek = this.timeframe.indexOf(this.timeframeItem);
const cellWidth = this.getCellWidth();
const offsetEnd = this.getTimelineBarEndOffset();
const epicStartDate = this.epic.startDate;
const epicEndDate = this.epic.endDate;
for (let i = indexOfCurrentWeek; i < this.timeframe.length; i += 1) {
if (i === indexOfCurrentWeek) {
if (this.isTimeframeUnderEndDateForWeek(this.timeframe[i], epicEndDate)) {
timelineBarWidth += this.getBarWidthForSingleWeek(
cellWidth,
epicEndDate.getDay() - epicStartDate.getDay() + 1,
);
break;
} else {
const date = epicStartDate.getDay() === 0 ? 7 : 7 - epicStartDate.getDay();
timelineBarWidth += this.getBarWidthForSingleWeek(cellWidth, date);
}
} else if (this.isTimeframeUnderEndDateForWeek(this.timeframe[i], epicEndDate)) {
timelineBarWidth += this.getBarWidthForSingleWeek(cellWidth, epicEndDate.getDay() + 1);
break;
} else {
timelineBarWidth += this.getBarWidthForSingleWeek(cellWidth, 7);
}
}
return timelineBarWidth - offsetEnd;
},
},
};
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { parsePikadayDate } from '~/lib/utils/datefix';
import { PRESET_TYPES } from '../constants';
export default class RoadmapStore {
constructor(groupId, timeframe) {
constructor(groupId, timeframe, presetType) {
this.state = {};
this.state.epics = [];
this.state.currentGroupId = groupId;
this.state.timeframe = timeframe;
this.firstTimeframeItem = this.state.timeframe[0];
this.presetType = presetType;
this.initTimeframeThreshold();
}
this.lastTimeframeItem = this.state.timeframe[this.state.timeframe.length - 1];
initTimeframeThreshold() {
const lastTimeframeIndex = this.state.timeframe.length - 1;
if (this.presetType === PRESET_TYPES.QUARTERS) {
this.timeframeStartDate = this.state.timeframe[0].range[0];
this.timeframeEndDate = this.state.timeframe[lastTimeframeIndex].range[2];
} else if (this.presetType === PRESET_TYPES.MONTHS) {
this.timeframeStartDate = this.state.timeframe[0];
this.timeframeEndDate = this.state.timeframe[lastTimeframeIndex];
} else if (this.presetType === PRESET_TYPES.WEEKS) {
this.timeframeStartDate = this.state.timeframe[0];
this.timeframeEndDate = new Date(this.state.timeframe[lastTimeframeIndex].getTime());
this.timeframeEndDate.setDate(this.timeframeEndDate.getDate() + 7);
}
}
setEpics(epics) {
this.state.epics = epics.map(
epic => RoadmapStore.formatEpicDetails(epic, this.firstTimeframeItem, this.lastTimeframeItem),
epic => RoadmapStore.formatEpicDetails(epic, this.timeframeStartDate, this.timeframeEndDate),
);
}
......@@ -36,17 +52,17 @@ export default class RoadmapStore {
* in case start or end dates are unavailable.
*
* @param {Object} rawEpic
* @param {Date} firstTimeframeItem
* @param {Date} lastTimeframeItem
* @param {Date} timeframeStartDate
* @param {Date} timeframeEndDate
*/
static formatEpicDetails(rawEpic, firstTimeframeItem, lastTimeframeItem) {
static formatEpicDetails(rawEpic, timeframeStartDate, timeframeEndDate) {
const epicItem = convertObjectPropsToCamelCase(rawEpic);
if (rawEpic.start_date) {
// If startDate is present
const startDate = parsePikadayDate(rawEpic.start_date);
if (startDate <= firstTimeframeItem) {
if (startDate <= timeframeStartDate) {
// If startDate is less than first timeframe item
// startDate is out of range;
epicItem.startDateOutOfRange = true;
......@@ -54,7 +70,7 @@ export default class RoadmapStore {
epicItem.originalStartDate = startDate;
// Use startDate object to set a proxy date so
// that timeline bar can render it.
epicItem.startDate = new Date(firstTimeframeItem.getTime());
epicItem.startDate = new Date(timeframeStartDate.getTime());
} else {
// startDate is within timeframe range
epicItem.startDate = startDate;
......@@ -63,7 +79,7 @@ export default class RoadmapStore {
// Start date is not available
epicItem.startDateUndefined = true;
// Set proxy date so that timeline bar can render it.
epicItem.startDate = new Date(firstTimeframeItem.getTime());
epicItem.startDate = new Date(timeframeStartDate.getTime());
}
// Same as above but for endDate
......@@ -71,16 +87,16 @@ export default class RoadmapStore {
// but we're keeping it here for the sake of simplicity.
if (rawEpic.end_date) {
const endDate = parsePikadayDate(rawEpic.end_date);
if (endDate >= lastTimeframeItem) {
if (endDate >= timeframeEndDate) {
epicItem.endDateOutOfRange = true;
epicItem.originalEndDate = endDate;
epicItem.endDate = new Date(lastTimeframeItem.getTime());
epicItem.endDate = new Date(timeframeEndDate.getTime());
} else {
epicItem.endDate = endDate;
}
} else {
epicItem.endDateUndefined = true;
epicItem.endDate = new Date(lastTimeframeItem.getTime());
epicItem.endDate = new Date(timeframeEndDate.getTime());
}
return epicItem;
......
import { getTimeframeWindowFrom, totalDaysInMonth } from '~/lib/utils/datetime_utility';
import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants';
/**
* This method returns array of Objects representing Quarters based on provided initialDate
*
* For eg; If initialDate is 15th Jan 2018
* Then as per Roadmap specs, we need to show
* 1 quarter before current quarter AND
* 4 quarters after current quarter
* thus, total of 6 quarters.
*
* So returned array from this method will be;
* [
* {
* quarterSequence: 4,
* year: 2017,
* range: [
* 1 Oct 2017,
* 1 Nov 2017,
* 31 Dec 2017,
* ],
* },
* {
* quarterSequence: 1,
* year: 2018,
* range: [
* 1 Jan 2018,
* 1 Feb 2018,
* 31 Mar 2018,
* ],
* },
* ....
* ....
* ....
* {
* quarterSequence: 1,
* year: 2019,
* range: [
* 1 Jan 2019,
* 1 Feb 2019,
* 31 Mar 2019,
* ],
* },
* ]
*
* @param {Date} initialDate
*/
export const getTimeframeForQuartersView = (initialDate = new Date()) => {
const startDate = initialDate;
startDate.setHours(0, 0, 0, 0);
const monthsForQuarters = {
1: [0, 1, 2],
2: [3, 4, 5],
3: [6, 7, 8],
4: [9, 10, 11],
};
// Get current quarter for current month
const currentQuarter = Math.floor((startDate.getMonth() + 3) / 3);
// Get index of current month in current quarter
// It could be 0, 1, 2 (i.e. first, second or third)
const currentMonthInCurrentQuarter = monthsForQuarters[currentQuarter].indexOf(
startDate.getMonth(),
);
// To move start back to first month of previous quarter
// Adding quarter size (3) to month order will give us
// exact number of months we need to go back in time
const startMonth = currentMonthInCurrentQuarter + 3;
const quartersTimeframe = [];
// Move startDate to first month of previous quarter
startDate.setMonth(startDate.getMonth() - startMonth);
// Get timeframe for the length we determined for this preset
// start from the startDate
const timeframe = getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH);
// Iterate over the timeframe and break it down
// in chunks of quarters
for (let i = 0; i < timeframe.length; i += 3) {
const range = timeframe.slice(i, i + 3);
const lastMonthOfQuarter = range[range.length - 1];
const quarterSequence = Math.floor((range[0].getMonth() + 3) / 3);
const year = range[0].getFullYear();
// Ensure that `range` spans across duration of
// entire quarter
lastMonthOfQuarter.setDate(totalDaysInMonth(lastMonthOfQuarter));
quartersTimeframe.push({
quarterSequence,
range,
year,
});
}
return quartersTimeframe;
};
/**
* This method returns array of Dates respresenting Months based on provided initialDate
*
* For eg; If initialDate is 15th Jan 2018
* Then as per Roadmap specs, we need to show
* 1 month before current month AND
* 5 months after current month
* thus, total of 7 months.
*
* So returned array from this method will be;
* [
* 1 Dec 2017, 1 Jan 2018, 1 Feb 2018, 1 Mar 2018,
* 1 Apr 2018, 1 May 2018, 30 Jun 2018
* ]
*
* @param {Date} initialDate
*/
export const getTimeframeForMonthsView = (initialDate = new Date()) => {
const startDate = initialDate;
startDate.setHours(0, 0, 0, 0);
// Move startDate to a month prior to current month
startDate.setMonth(startDate.getMonth() - 1);
return getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.MONTHS.TIMEFRAME_LENGTH);
};
/**
* This method returns array of Dates respresenting Months based on provided initialDate
*
* For eg; If initialDate is 15th Jan 2018
* Then as per Roadmap specs, we need to show
* 1 week before current week AND
* 4 weeks after current week
* thus, total of 6 weeks.
* Note that week starts on Sunday
*
* So returned array from this method will be;
* [
* 7 Jan 2018, 14 Jan 2018, 21 Jan 2018,
* 28 Jan 2018, 4 Mar 2018, 11 Mar 2018
* ]
*
* @param {Date} initialDate
*/
export const getTimeframeForWeeksView = (initialDate = new Date()) => {
const startDate = initialDate;
startDate.setHours(0, 0, 0, 0);
const dayOfWeek = startDate.getDay();
const daysToFirstDayOfPrevWeek = dayOfWeek + 7;
const timeframe = [];
// Move startDate to first day (Sunday) of previous week
startDate.setDate(startDate.getDate() - daysToFirstDayOfPrevWeek);
// Iterate for the length of this preset
for (let i = 0; i < PRESET_DEFAULTS.WEEKS.TIMEFRAME_LENGTH; i += 1) {
// Push date to timeframe only when day is
// first day (Sunday) of the week1
if (startDate.getDay() === 0) {
timeframe.push(new Date(startDate.getTime()));
}
// Move date one day further
startDate.setDate(startDate.getDate() + 1);
}
return timeframe;
};
export const getTimeframeForPreset = (presetType = PRESET_TYPES.MONTHS) => {
if (presetType === PRESET_TYPES.QUARTERS) {
return getTimeframeForQuartersView();
} else if (presetType === PRESET_TYPES.MONTHS) {
return getTimeframeForMonthsView();
}
return getTimeframeForWeeksView();
};
export const getEpicsPathForPreset = ({
basePath = '',
filterQueryString = '',
presetType = '',
timeframe = [],
}) => {
let start;
let end;
let epicsPath = basePath;
if (!basePath || !timeframe.length) {
return null;
}
// Construct Epic API path to include
// `start_date` & `end_date` query params to get list of
// epics only for timeframe.
if (presetType === PRESET_TYPES.QUARTERS) {
const firstTimeframe = timeframe[0];
const lastTimeframe = timeframe[timeframe.length - 1];
start = firstTimeframe.range[0];
end = lastTimeframe.range[lastTimeframe.range.length - 1];
} else if (presetType === PRESET_TYPES.MONTHS) {
start = timeframe[0];
end = timeframe[timeframe.length - 1];
} else if (presetType === PRESET_TYPES.WEEKS) {
start = timeframe[0];
end = new Date(timeframe[timeframe.length - 1].getTime());
end.setDate(end.getDate() + 6);
}
const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`;
const endDate = `${end.getFullYear()}-${end.getMonth() + 1}-${end.getDate()}`;
epicsPath += `?start_date=${startDate}&end_date=${endDate}`;
if (filterQueryString) {
epicsPath += `&${filterQueryString}`;
}
return epicsPath;
};
......@@ -6,6 +6,14 @@ $scroll-top-gradient: linear-gradient(to bottom, rgba(0, 0, 0, 0.15) 0%, rgba(25
$scroll-bottom-gradient: linear-gradient(to bottom, rgba(255, 255, 255, 0.001) 0%, rgba(0, 0, 0, 0.15) 100%);
$column-right-gradient: linear-gradient(to right, rgba(0, 0, 0, 0.15) 0%, rgba(255, 255, 255, 0.001) 100%);
.epics-roadmap-filters {
.epics-details-filters {
.btn-roadmap-preset {
padding: 7px $gl-btn-padding;
}
}
}
.group-epics-roadmap-wrapper {
padding-bottom: 0;
}
......@@ -34,6 +42,22 @@ $column-right-gradient: linear-gradient(to right, rgba(0, 0, 0, 0.15) 0%, rgba(2
}
}
.roadmap-timeline-section .timeline-header-item {
.item-label,
.item-sublabel .sublabel-value {
color: $theme-gray-600;
font-weight: 400;
&.label-dark {
color: $theme-gray-900;
}
&.label-bold {
font-weight: 600;
}
}
}
.roadmap-timeline-section .timeline-header-blank:after,
.epics-list-section .epic-details-cell:after {
content: '';
......@@ -78,28 +102,10 @@ $column-right-gradient: linear-gradient(to right, rgba(0, 0, 0, 0.15) 0%, rgba(2
border-right: none;
}
.item-label,
.item-sublabel {
color: $theme-gray-600;
font-weight: 400;
&.label-dark {
color: $theme-gray-900;
.value-light {
color: $theme-gray-600;
}
}
}
.item-label {
padding: $gl-padding-8 $gl-padding;
border-right: $border-style;
border-bottom: $border-style;
&.label-bold {
font-weight: 600;
}
}
.item-sublabel {
......@@ -108,6 +114,7 @@ $column-right-gradient: linear-gradient(to right, rgba(0, 0, 0, 0.15) 0%, rgba(2
.sublabel-value {
flex-grow: 1;
flex-basis: 0;
text-align: center;
font-size: $code_font_size;
line-height: 1.5;
......
......@@ -2,13 +2,14 @@
- @no_container = true
- @content_wrapper_class = "group-epics-roadmap-wrapper"
- @content_class = "group-epics-roadmap"
- preset_layout = params[:layout].present? ? params[:layout] : "MONTHS"
- breadcrumb_title _("Epics Roadmap")
- has_filters_applied = params[:label_name].present? || params[:author_username].present? || params[:search].present?
- if @epics_count != 0
= render 'shared/epic/search_bar', type: :epics, hide_sort_dropdown: true
= render 'shared/epic/search_bar', type: :epics, hide_sort_dropdown: true, show_roadmap_presets: true
#js-roadmap{ data: { epics_path: group_epics_path(@group, format: :json), group_id: @group.id, empty_state_illustration: image_path('illustrations/epics/roadmap.svg'), has_filters_applied: "#{has_filters_applied}", new_epic_endpoint: group_epics_path(@group) } }
#js-roadmap{ data: { epics_path: group_epics_path(@group, format: :json), group_id: @group.id, empty_state_illustration: image_path('illustrations/epics/roadmap.svg'), has_filters_applied: "#{has_filters_applied}", new_epic_endpoint: group_epics_path(@group), preset_type: preset_layout } }
- else
= render 'shared/empty_states/roadmap'
- type = local_assigns.fetch(:type)
- hide_sort_dropdown = local_assigns.fetch(:hide_sort_dropdown, false)
- show_roadmap_presets = local_assigns.fetch(:show_roadmap_presets, false)
- full_path = @project.present? ? @project.full_path : @group.full_path
- preset_layout = params[:layout].present? ? params[:layout] : "MONTHS"
- is_quarters = preset_layout == "QUARTERS"
- is_months = preset_layout == "MONTHS"
- is_weeks = preset_layout == "WEEKS"
.epics-filters
.epics-details-filters.filtered-search-block.row-content-block.second-block
= form_tag page_filter_path(without: [:author_id, :search]), method: :get, class: 'filter-form js-filter-form' do
.epics-filters{ class: "#{'epics-roadmap-filters' if show_roadmap_presets}" }
.d-flex.epics-details-filters.filtered-search-block.row-content-block.second-block
- if show_roadmap_presets
.btn-group.btn-group-toggle.append-right-default.js-btn-roadmap-presets{ data: { toggle: 'buttons' } }
%label.btn.btn-default.btn-roadmap-preset{ class: ("active" if is_quarters) }
%input{ type: 'radio', name: 'presetType', autocomplete: 'off', checked: ("checked" if is_quarters), value: 'QUARTERS' }
= _('Quarters')
%label.btn.btn-default.btn-roadmap-preset{ class: ("active" if is_months) }
%input{ type: 'radio', name: 'presetType', autocomplete: 'off', checked: ("checked" if is_months), value: 'MONTHS' }
= _('Months')
%label.btn.btn-default.btn-roadmap-preset{ class: ("active" if is_weeks) }
%input{ type: 'radio', name: 'presetType', autocomplete: 'off', checked: ("checked" if is_weeks), value: 'WEEKS' }
= _('Weeks')
= form_tag page_filter_path(without: [:author_id, :search]), method: :get, class: 'flex-fill filter-form js-filter-form' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
.epics-other-filters.filtered-search-wrapper
......
---
title: Add presets for navigating Epic Roadmap
merge_request: 5798
author:
type: added
......@@ -34,6 +34,14 @@ describe 'group epic roadmap', :js do
end
describe 'roadmap page' do
it 'renders roadmap preset buttons correctly' do
page.within('.js-btn-roadmap-presets') do
expect(page).to have_css('.btn-roadmap-preset input[value="QUARTERS"]')
expect(page).to have_css('.btn-roadmap-preset input[value="MONTHS"]')
expect(page).to have_css('.btn-roadmap-preset input[value="WEEKS"]')
end
end
it 'renders the filtered search bar correctly' do
page.within('.content-wrapper .content') do
expect(page).to have_css('.epics-filters')
......
......@@ -149,23 +149,22 @@ describe('getSundays', () => {
});
});
describe('getTimeframeWindow', () => {
it('returns array of dates representing a timeframe based on provided length and date', () => {
const date = new Date(2018, 0, 1);
describe('getTimeframeWindowFrom', () => {
it('returns array of date objects upto provided length start with provided startDate', () => {
const startDate = new Date(2018, 0, 1);
const mockTimeframe = [
new Date(2017, 9, 1),
new Date(2017, 10, 1),
new Date(2017, 11, 1),
new Date(2018, 0, 1),
new Date(2018, 1, 1),
new Date(2018, 2, 31),
new Date(2018, 2, 1),
new Date(2018, 3, 1),
new Date(2018, 4, 31),
];
const timeframe = datetimeUtility.getTimeframeWindow(6, date);
expect(timeframe.length).toBe(6);
const timeframe = datetimeUtility.getTimeframeWindowFrom(startDate, 5);
expect(timeframe.length).toBe(5);
timeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.getFullYear() === mockTimeframe[index].getFullYear()).toBeTruthy();
expect(timeframeItem.getMonth() === mockTimeframe[index].getMonth()).toBeTruthy();
console.log(timeframeItem);
expect(timeframeItem.getFullYear() === mockTimeframe[index].getFullYear()).toBe(true);
expect(timeframeItem.getMonth() === mockTimeframe[index].getMonth()).toBe(true);
expect(timeframeItem.getDate() === mockTimeframe[index].getDate()).toBeTruthy();
});
});
......
......@@ -7,12 +7,14 @@ import appComponent from 'ee/roadmap/components/app.vue';
import RoadmapStore from 'ee/roadmap/store/roadmap_store';
import RoadmapService from 'ee/roadmap/service/roadmap_service';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframe, mockGroupId, epicsPath, mockNewEpicEndpoint, rawEpics, mockSvgPath } from '../mock_data';
import { mockTimeframeMonths, mockGroupId, epicsPath, mockNewEpicEndpoint, rawEpics, mockSvgPath } from '../mock_data';
const createComponent = () => {
const Component = Vue.extend(appComponent);
const timeframe = mockTimeframe;
const timeframe = mockTimeframeMonths;
const store = new RoadmapStore(mockGroupId, timeframe);
const service = new RoadmapService(epicsPath);
......@@ -20,6 +22,7 @@ const createComponent = () => {
return mountComponent(Component, {
store,
service,
presetType: PRESET_TYPES.MONTHS,
hasFiltersApplied: true,
newEpicEndpoint: mockNewEpicEndpoint,
emptyStateIllustrationPath: mockSvgPath,
......
......@@ -2,12 +2,15 @@ import Vue from 'vue';
import epicItemComponent from 'ee/roadmap/components/epic_item.vue';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframe, mockEpic, mockGroupId, mockShellWidth, mockItemWidth } from '../mock_data';
import { mockTimeframeMonths, mockEpic, mockGroupId, mockShellWidth, mockItemWidth } from '../mock_data';
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
epic = mockEpic,
timeframe = mockTimeframe,
timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId,
shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
......@@ -15,6 +18,7 @@ const createComponent = ({
const Component = Vue.extend(epicItemComponent);
return mountComponent(Component, {
presetType,
epic,
timeframe,
currentGroupId,
......
......@@ -2,15 +2,29 @@ import Vue from 'vue';
import epicsListEmptyComponent from 'ee/roadmap/components/epics_list_empty.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframe, mockSvgPath, mockNewEpicEndpoint } from '../mock_data';
import { PRESET_TYPES } from 'ee/roadmap/constants';
const createComponent = (hasFiltersApplied = false) => {
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import {
mockTimeframeQuarters,
mockTimeframeMonths,
mockTimeframeWeeks,
mockSvgPath,
mockNewEpicEndpoint,
} from '../mock_data';
const createComponent = ({
hasFiltersApplied = false,
presetType = PRESET_TYPES.MONTHS,
timeframeStart = mockTimeframeMonths[0],
timeframeEnd = mockTimeframeMonths[mockTimeframeMonths.length - 1],
}) => {
const Component = Vue.extend(epicsListEmptyComponent);
return mountComponent(Component, {
timeframeStart: mockTimeframe[0],
timeframeEnd: mockTimeframe[mockTimeframe.length - 1],
presetType,
timeframeStart,
timeframeEnd,
emptyStateIllustrationPath: mockSvgPath,
newEpicEndpoint: mockNewEpicEndpoint,
hasFiltersApplied,
......@@ -21,7 +35,7 @@ describe('EpicsListEmptyComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
vm = createComponent({});
});
afterEach(() => {
......@@ -46,25 +60,93 @@ describe('EpicsListEmptyComponent', () => {
});
describe('subMessage', () => {
it('returns default empty state sub-message', () => {
expect(vm.subMessage).toBe('To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from Nov 1, 2017 to Apr 30, 2018.');
describe('with presetType `QUARTERS`', () => {
beforeEach(() => {
vm.presetType = PRESET_TYPES.QUARTERS;
vm.timeframeStart = mockTimeframeQuarters[0];
vm.timeframeEnd = mockTimeframeQuarters[mockTimeframeQuarters.length - 1];
});
it('returns default empty state sub-message when `hasFiltersApplied` props is false', done => {
Vue.nextTick()
.then(() => {
expect(vm.subMessage).toBe('To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. In the quarters view, only epics in the past quarter, current quarter, and next 4 quarters are shown &ndash; from Oct 1, 2017 to Mar 31, 2019.');
})
.then(done)
.catch(done.fail);
});
it('returns empty state sub-message when `hasFiltersApplied` prop is true', done => {
vm.hasFiltersApplied = true;
Vue.nextTick()
.then(() => {
expect(vm.subMessage).toBe('To widen your search, change or remove filters. In the quarters view, only epics in the past quarter, current quarter, and next 4 quarters are shown &ndash; from Oct 1, 2017 to Mar 31, 2019.');
})
.then(done)
.catch(done.fail);
});
});
it('returns empty state sub-message when `hasFiltersApplied` prop is true', done => {
vm.hasFiltersApplied = true;
Vue.nextTick()
.then(() => {
expect(vm.subMessage).toBe('To widen your search, change or remove filters. Only epics in the past 3 months and the next 3 months are shown &ndash; from Nov 1, 2017 to Apr 30, 2018.');
})
.then(done)
.catch(done.fail);
describe('with presetType `MONTHS`', () => {
beforeEach(() => {
vm.presetType = PRESET_TYPES.MONTHS;
});
it('returns default empty state sub-message when `hasFiltersApplied` props is false', done => {
Vue.nextTick()
.then(() => {
expect(vm.subMessage).toBe('To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. In the months view, only epics in the past month, current month, and next 5 months are shown &ndash; from Dec 1, 2017 to Jun 30, 2018.');
})
.then(done)
.catch(done.fail);
});
it('returns empty state sub-message when `hasFiltersApplied` prop is true', done => {
vm.hasFiltersApplied = true;
Vue.nextTick()
.then(() => {
expect(vm.subMessage).toBe('To widen your search, change or remove filters. In the months view, only epics in the past month, current month, and next 5 months are shown &ndash; from Dec 1, 2017 to Jun 30, 2018.');
})
.then(done)
.catch(done.fail);
});
});
describe('with presetType `WEEKS`', () => {
beforeEach(() => {
const timeframeEnd = mockTimeframeWeeks[mockTimeframeWeeks.length - 1];
timeframeEnd.setDate(timeframeEnd.getDate() + 6);
vm.presetType = PRESET_TYPES.WEEKS;
vm.timeframeStart = mockTimeframeWeeks[0];
vm.timeframeEnd = timeframeEnd;
});
it('returns default empty state sub-message when `hasFiltersApplied` props is false', done => {
Vue.nextTick()
.then(() => {
expect(vm.subMessage).toBe('To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. In the weeks view, only epics in the past week, current week, and next 4 weeks are shown &ndash; from Dec 24, 2017 to Feb 9, 2018.');
})
.then(done)
.catch(done.fail);
});
it('returns empty state sub-message when `hasFiltersApplied` prop is true', done => {
vm.hasFiltersApplied = true;
Vue.nextTick()
.then(() => {
expect(vm.subMessage).toBe('To widen your search, change or remove filters. In the weeks view, only epics in the past week, current week, and next 4 weeks are shown &ndash; from Dec 24, 2017 to Feb 15, 2018.');
})
.then(done)
.catch(done.fail);
});
});
});
describe('timeframeRange', () => {
it('returns correct timeframe startDate and endDate in words', () => {
expect(vm.timeframeRange.startDate).toBe('Nov 1, 2017');
expect(vm.timeframeRange.endDate).toBe('Apr 30, 2018');
expect(vm.timeframeRange.startDate).toBe('Dec 1, 2017');
expect(vm.timeframeRange.endDate).toBe('Jun 30, 2018');
});
});
});
......
......@@ -3,23 +3,26 @@ import Vue from 'vue';
import epicsListSectionComponent from 'ee/roadmap/components/epics_list_section.vue';
import RoadmapStore from 'ee/roadmap/store/roadmap_store';
import eventHub from 'ee/roadmap/event_hub';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { rawEpics, mockTimeframe, mockGroupId, mockShellWidth } from '../mock_data';
import { rawEpics, mockTimeframeMonths, mockGroupId, mockShellWidth } from '../mock_data';
const store = new RoadmapStore(mockGroupId, mockTimeframe);
const store = new RoadmapStore(mockGroupId, mockTimeframeMonths, PRESET_TYPES.MONTHS);
store.setEpics(rawEpics);
const mockEpics = store.getEpics();
const createComponent = ({
epics = mockEpics,
timeframe = mockTimeframe,
timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId,
shellWidth = mockShellWidth,
listScrollable = false,
presetType = PRESET_TYPES.MONTHS,
}) => {
const Component = Vue.extend(epicsListSectionComponent);
return mountComponent(Component, {
presetType,
epics,
timeframe,
currentGroupId,
......@@ -59,7 +62,7 @@ describe('EpicsListSectionComponent', () => {
describe('emptyRowCellStyles', () => {
it('returns computed style object based on sectionItemWidth prop value', () => {
expect(vm.emptyRowCellStyles.width).toBe('280px');
expect(vm.emptyRowCellStyles.width).toBe('240px');
});
});
......
import Vue from 'vue';
import timelineHeaderItemComponent from 'ee/roadmap/components/timeline_header_item.vue';
import MonthsHeaderItemComponent from 'ee/roadmap/components/preset_months/months_header_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframe, mockShellWidth, mockItemWidth } from '../mock_data';
import { mockTimeframeMonths, mockShellWidth, mockItemWidth } from '../../mock_data';
const mockTimeframeIndex = 0;
const createComponent = ({
timeframeIndex = mockTimeframeIndex,
timeframeItem = mockTimeframe[mockTimeframeIndex],
timeframe = mockTimeframe,
timeframeItem = mockTimeframeMonths[mockTimeframeIndex],
timeframe = mockTimeframeMonths,
shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
}) => {
const Component = Vue.extend(timelineHeaderItemComponent);
const Component = Vue.extend(MonthsHeaderItemComponent);
return mountComponent(Component, {
timeframeIndex,
......@@ -25,7 +25,7 @@ const createComponent = ({
});
};
describe('TimelineHeaderItemComponent', () => {
describe('MonthsHeaderItemComponent', () => {
let vm;
afterEach(() => {
......@@ -53,15 +53,15 @@ describe('TimelineHeaderItemComponent', () => {
describe('timelineHeaderLabel', () => {
it('returns string containing Year and Month for current timeline header item', () => {
vm = createComponent({});
expect(vm.timelineHeaderLabel).toBe('2017 Nov');
expect(vm.timelineHeaderLabel).toBe('2017 Dec');
});
it('returns string containing only Month for current timeline header item when previous header contained Year', () => {
vm = createComponent({
timeframeIndex: mockTimeframeIndex + 1,
timeframeItem: mockTimeframe[mockTimeframeIndex + 1],
timeframeItem: mockTimeframeMonths[mockTimeframeIndex + 1],
});
expect(vm.timelineHeaderLabel).toBe('Dec');
expect(vm.timelineHeaderLabel).toBe('2018 Jan');
});
});
......@@ -81,8 +81,8 @@ describe('TimelineHeaderItemComponent', () => {
it('returns string containing `label-dark` when current year and month is less than timeframeItem year and month', () => {
const timeframeIndex = 2;
const timeframeItem = new Date(
mockTimeframe[timeframeIndex].getFullYear(),
mockTimeframe[timeframeIndex].getMonth() + 2,
mockTimeframeMonths[timeframeIndex].getFullYear(),
mockTimeframeMonths[timeframeIndex].getMonth() + 2,
1,
);
vm = createComponent({
......@@ -90,8 +90,8 @@ describe('TimelineHeaderItemComponent', () => {
timeframeItem,
});
vm.currentYear = mockTimeframe[timeframeIndex].getFullYear();
vm.currentMonth = mockTimeframe[timeframeIndex].getMonth() + 1;
vm.currentYear = mockTimeframeMonths[timeframeIndex].getFullYear();
vm.currentMonth = mockTimeframeMonths[timeframeIndex].getMonth() + 1;
expect(vm.timelineHeaderClass).toBe('label-dark');
});
});
......@@ -109,7 +109,7 @@ describe('TimelineHeaderItemComponent', () => {
it('renders item label element class `item-label` and value as `timelineHeaderLabel`', () => {
const itemLabelEl = vm.$el.querySelector('.item-label');
expect(itemLabelEl).not.toBeNull();
expect(itemLabelEl.innerText.trim()).toBe('2017 Nov');
expect(itemLabelEl.innerText.trim()).toBe('2017 Dec');
});
});
});
import Vue from 'vue';
import timelineHeaderSubItemComponent from 'ee/roadmap/components/timeline_header_sub_item.vue';
import MonthsHeaderSubItemComponent from 'ee/roadmap/components/preset_months/months_header_sub_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframe } from '../mock_data';
import { mockTimeframeMonths } from '../../mock_data';
const createComponent = ({
currentDate = mockTimeframe[0],
timeframeItem = mockTimeframe[0],
currentDate = mockTimeframeMonths[0],
timeframeItem = mockTimeframeMonths[0],
}) => {
const Component = Vue.extend(timelineHeaderSubItemComponent);
const Component = Vue.extend(MonthsHeaderSubItemComponent);
return mountComponent(Component, {
currentDate,
......@@ -17,7 +17,7 @@ const createComponent = ({
});
};
describe('TimelineHeaderSubItemComponent', () => {
describe('MonthsHeaderSubItemComponent', () => {
let vm;
afterEach(() => {
......@@ -29,6 +29,9 @@ describe('TimelineHeaderSubItemComponent', () => {
it('returns array of dates containing Sundays from timeframeItem', () => {
vm = createComponent({});
expect(Array.isArray(vm.headerSubItems)).toBe(true);
vm.headerSubItems.forEach(subItem => {
expect(subItem instanceof Date).toBe(true);
});
});
});
......@@ -65,20 +68,12 @@ describe('TimelineHeaderSubItemComponent', () => {
describe('methods', () => {
describe('getSubItemValueClass', () => {
it('returns empty string when provided subItem is greater than current date', () => {
it('returns string containing `label-dark` when provided subItem is greater than current date', () => {
vm = createComponent({
currentDate: new Date(2018, 0, 1), // Jan 1, 2018
});
const subItem = new Date(2018, 0, 15); // Jan 15, 2018
expect(vm.getSubItemValueClass(subItem)).toBe('');
});
it('returns string containing `value-light` when provided subItem is less than current date', () => {
vm = createComponent({
currentDate: new Date(2018, 0, 15), // Jan 15, 2018
});
const subItem = new Date(2018, 0, 1); // Jan 1, 2018
expect(vm.getSubItemValueClass(subItem)).toBe('value-light');
expect(vm.getSubItemValueClass(subItem)).toBe('label-dark');
});
});
});
......
import Vue from 'vue';
import QuartersHeaderItemComponent from 'ee/roadmap/components/preset_quarters/quarters_header_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeQuarters, mockShellWidth, mockItemWidth } from '../../mock_data';
const mockTimeframeIndex = 0;
const createComponent = ({
timeframeIndex = mockTimeframeIndex,
timeframeItem = mockTimeframeQuarters[mockTimeframeIndex],
timeframe = mockTimeframeQuarters,
shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
}) => {
const Component = Vue.extend(QuartersHeaderItemComponent);
return mountComponent(Component, {
timeframeIndex,
timeframeItem,
timeframe,
shellWidth,
itemWidth,
});
};
describe('QuartersHeaderItemComponent', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
vm = createComponent({});
const currentDate = new Date();
expect(vm.currentDate.getDate()).toBe(currentDate.getDate());
expect(vm.quarterBeginDate).toBe(mockTimeframeQuarters[mockTimeframeIndex].range[0]);
expect(vm.quarterEndDate).toBe(mockTimeframeQuarters[mockTimeframeIndex].range[2]);
});
});
describe('computed', () => {
describe('itemStyles', () => {
it('returns style object for container element based on value of `itemWidth` prop', () => {
vm = createComponent({});
expect(vm.itemStyles.width).toBe('180px');
});
});
describe('timelineHeaderLabel', () => {
it('returns string containing Year and Quarter for current timeline header item', () => {
vm = createComponent({});
expect(vm.timelineHeaderLabel).toBe('2017 Q4');
});
it('returns string containing only Quarter for current timeline header item when previous header contained Year', () => {
vm = createComponent({
timeframeIndex: mockTimeframeIndex + 2,
timeframeItem: mockTimeframeQuarters[mockTimeframeIndex + 2],
});
expect(vm.timelineHeaderLabel).toBe('Q2');
});
});
describe('timelineHeaderClass', () => {
it('returns empty string when timeframeItem quarter is less than current quarter', () => {
vm = createComponent({});
expect(vm.timelineHeaderClass).toBe('');
});
it('returns string containing `label-dark label-bold` when current quarter is same as timeframeItem quarter', done => {
vm = createComponent({
timeframeItem: mockTimeframeQuarters[1],
});
vm.currentDate = mockTimeframeQuarters[1].range[1];
Vue.nextTick()
.then(() => {
expect(vm.timelineHeaderClass).toBe('label-dark label-bold');
})
.then(done)
.catch(done.fail);
});
it('returns string containing `label-dark` when current quarter is less than timeframeItem quarter', () => {
const timeframeIndex = 2;
const timeframeItem = mockTimeframeQuarters[1];
vm = createComponent({
timeframeIndex,
timeframeItem,
});
vm.currentDate = mockTimeframeQuarters[0].range[0];
expect(vm.timelineHeaderClass).toBe('label-dark');
});
});
});
describe('template', () => {
beforeEach(() => {
vm = createComponent({});
});
it('renders component container element with class `timeline-header-item`', () => {
expect(vm.$el.classList.contains('timeline-header-item')).toBeTruthy();
});
it('renders item label element class `item-label` and value as `timelineHeaderLabel`', () => {
const itemLabelEl = vm.$el.querySelector('.item-label');
expect(itemLabelEl).not.toBeNull();
expect(itemLabelEl.innerText.trim()).toBe('2017 Q4');
});
});
});
import Vue from 'vue';
import QuartersHeaderSubItemComponent from 'ee/roadmap/components/preset_quarters/quarters_header_sub_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeQuarters } from '../../mock_data';
const createComponent = ({
currentDate = mockTimeframeQuarters[0].range[1],
timeframeItem = mockTimeframeQuarters[0],
}) => {
const Component = Vue.extend(QuartersHeaderSubItemComponent);
return mountComponent(Component, {
currentDate,
timeframeItem,
});
};
describe('QuartersHeaderSubItemComponent', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('headerSubItems', () => {
it('returns array of dates containing Months from timeframeItem', () => {
vm = createComponent({});
expect(Array.isArray(vm.headerSubItems)).toBe(true);
vm.headerSubItems.forEach(subItem => {
expect(subItem instanceof Date).toBe(true);
});
});
});
describe('hasToday', () => {
it('returns true when current quarter is same as timeframe quarter', () => {
vm = createComponent({});
expect(vm.hasToday).toBe(true);
});
it('returns false when current quarter month is different from timeframe quarter', () => {
vm = createComponent({
currentDate: new Date(2017, 10, 1), // Nov 1, 2017
timeframeItem: mockTimeframeQuarters[1], // 2018 Apr May Jun
});
expect(vm.hasToday).toBe(false);
});
});
});
describe('methods', () => {
describe('getSubItemValueClass', () => {
it('returns string containing `label-dark` when provided subItem is greater than current date', () => {
vm = createComponent({
currentDate: new Date(2018, 0, 1), // Jan 1, 2018
});
const subItem = new Date(2018, 1, 15); // Feb 15, 2018
expect(vm.getSubItemValueClass(subItem)).toBe('label-dark');
});
});
});
describe('template', () => {
beforeEach(() => {
vm = createComponent({});
});
it('renders component container element with class `item-sublabel`', () => {
expect(vm.$el.classList.contains('item-sublabel')).toBe(true);
});
it('renders sub item element with class `sublabel-value`', () => {
expect(vm.$el.querySelector('.sublabel-value')).not.toBeNull();
});
});
});
import Vue from 'vue';
import WeeksHeaderItemComponent from 'ee/roadmap/components/preset_weeks/weeks_header_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeWeeks, mockShellWidth, mockItemWidth } from '../../mock_data';
const mockTimeframeIndex = 0;
const createComponent = ({
timeframeIndex = mockTimeframeIndex,
timeframeItem = mockTimeframeWeeks[mockTimeframeIndex],
timeframe = mockTimeframeWeeks,
shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
}) => {
const Component = Vue.extend(WeeksHeaderItemComponent);
return mountComponent(Component, {
timeframeIndex,
timeframeItem,
timeframe,
shellWidth,
itemWidth,
});
};
describe('WeeksHeaderItemComponent', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
vm = createComponent({});
const currentDate = new Date();
expect(vm.currentDate.getDate()).toBe(currentDate.getDate());
expect(vm.lastDayOfCurrentWeek.getDate()).toBe(
mockTimeframeWeeks[mockTimeframeIndex].getDate() + 7,
);
});
});
describe('computed', () => {
describe('itemStyles', () => {
it('returns style object for container element based on value of `itemWidth` prop', () => {
vm = createComponent({});
expect(vm.itemStyles.width).toBe('180px');
});
});
describe('timelineHeaderLabel', () => {
it('returns string containing Year, Month and Date for current timeline header item', () => {
vm = createComponent({});
expect(vm.timelineHeaderLabel).toBe('2017 Dec 24');
});
it('returns string containing only Month and Date for current timeline header item when previous header contained Year', () => {
vm = createComponent({
timeframeIndex: mockTimeframeIndex + 1,
timeframeItem: mockTimeframeWeeks[mockTimeframeIndex + 1],
});
expect(vm.timelineHeaderLabel).toBe('Dec 31');
});
});
describe('timelineHeaderClass', () => {
it('returns empty string when timeframeItem week is less than current week', () => {
vm = createComponent({});
expect(vm.timelineHeaderClass).toBe('');
});
it('returns string containing `label-dark label-bold` when current week is same as timeframeItem week', () => {
vm = createComponent({});
vm.currentDate = mockTimeframeWeeks[mockTimeframeIndex];
expect(vm.timelineHeaderClass).toBe('label-dark label-bold');
});
it('returns string containing `label-dark` when current week is less than timeframeItem week', () => {
const timeframeIndex = 2;
const timeframeItem = mockTimeframeWeeks[timeframeIndex];
vm = createComponent({
timeframeIndex,
timeframeItem,
});
vm.currentDate = mockTimeframeWeeks[0];
expect(vm.timelineHeaderClass).toBe('label-dark');
});
});
});
describe('template', () => {
beforeEach(() => {
vm = createComponent({});
});
it('renders component container element with class `timeline-header-item`', () => {
expect(vm.$el.classList.contains('timeline-header-item')).toBeTruthy();
});
it('renders item label element class `item-label` and value as `timelineHeaderLabel`', () => {
const itemLabelEl = vm.$el.querySelector('.item-label');
expect(itemLabelEl).not.toBeNull();
expect(itemLabelEl.innerText.trim()).toBe('2017 Dec 24');
});
});
});
import Vue from 'vue';
import WeeksHeaderSubItemComponent from 'ee/roadmap/components/preset_weeks/weeks_header_sub_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeWeeks } from '../../mock_data';
const createComponent = ({
currentDate = mockTimeframeWeeks[0],
timeframeItem = mockTimeframeWeeks[0],
}) => {
const Component = Vue.extend(WeeksHeaderSubItemComponent);
return mountComponent(Component, {
currentDate,
timeframeItem,
});
};
describe('MonthsHeaderSubItemComponent', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('sets prop `headerSubItems` with array of dates containing days of week from timeframeItem', () => {
vm = createComponent({});
expect(Array.isArray(vm.headerSubItems)).toBe(true);
expect(vm.headerSubItems.length).toBe(7);
vm.headerSubItems.forEach(subItem => {
expect(subItem instanceof Date).toBe(true);
});
});
});
describe('computed', () => {
describe('hasToday', () => {
it('returns true when current week is same as timeframe week', () => {
vm = createComponent({});
expect(vm.hasToday).toBe(true);
});
it('returns false when current week is different from timeframe week', () => {
vm = createComponent({
currentDate: new Date(2017, 10, 1), // Nov 1, 2017
timeframeItem: new Date(2018, 0, 1), // Jan 1, 2018
});
expect(vm.hasToday).toBe(false);
});
});
});
describe('methods', () => {
describe('getSubItemValueClass', () => {
it('returns string containing `label-dark` when provided subItem is greater than current week day', () => {
vm = createComponent({
currentDate: new Date(2018, 0, 1), // Jan 1, 2018
});
const subItem = new Date(2018, 0, 25); // Jan 25, 2018
expect(vm.getSubItemValueClass(subItem)).toBe('label-dark');
});
it('returns string containing `label-dark label-bold` when provided subItem is same as current week day', () => {
const currentDate = new Date(2018, 0, 25);
vm = createComponent({
currentDate,
});
const subItem = currentDate;
expect(vm.getSubItemValueClass(subItem)).toBe('label-dark label-bold');
});
});
});
describe('template', () => {
beforeEach(() => {
vm = createComponent({});
});
it('renders component container element with class `item-sublabel`', () => {
expect(vm.$el.classList.contains('item-sublabel')).toBe(true);
});
it('renders sub item element with class `sublabel-value`', () => {
expect(vm.$el.querySelector('.sublabel-value')).not.toBeNull();
});
});
});
......@@ -3,17 +3,20 @@ import Vue from 'vue';
import roadmapShellComponent from 'ee/roadmap/components/roadmap_shell.vue';
import eventHub from 'ee/roadmap/event_hub';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockEpic, mockTimeframe, mockGroupId, mockScrollBarSize } from '../mock_data';
import { mockEpic, mockTimeframeMonths, mockGroupId, mockScrollBarSize } from '../mock_data';
const createComponent = ({
epics = [mockEpic],
timeframe = mockTimeframe,
timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId,
}, el) => {
const Component = Vue.extend(roadmapShellComponent);
return mountComponent(Component, {
presetType: PRESET_TYPES.MONTHS,
epics,
timeframe,
currentGroupId,
......@@ -82,17 +85,11 @@ describe('RoadmapShellComponent', () => {
spyOn(eventHub, '$emit');
});
it('emits `epicsListScrolled` event via eventHub when `noScroll` prop is false', () => {
it('emits `epicsListScrolled` event via eventHub', () => {
vm.noScroll = false;
vm.handleScroll();
expect(eventHub.$emit).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Object));
});
it('does not emit any event via eventHub when `noScroll` prop is true', () => {
vm.noScroll = true;
vm.handleScroll();
expect(eventHub.$emit).not.toHaveBeenCalled();
});
});
});
......
......@@ -3,18 +3,22 @@ import Vue from 'vue';
import roadmapTimelineSectionComponent from 'ee/roadmap/components/roadmap_timeline_section.vue';
import eventHub from 'ee/roadmap/event_hub';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockEpic, mockTimeframe, mockShellWidth } from '../mock_data';
import { mockEpic, mockTimeframeMonths, mockShellWidth } from '../mock_data';
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
epics = [mockEpic],
timeframe = mockTimeframe,
timeframe = mockTimeframeMonths,
shellWidth = mockShellWidth,
listScrollable = false,
}) => {
const Component = Vue.extend(roadmapTimelineSectionComponent);
return mountComponent(Component, {
presetType,
epics,
timeframe,
shellWidth,
......
......@@ -2,23 +2,26 @@ import Vue from 'vue';
import timelineTodayIndicatorComponent from 'ee/roadmap/components/timeline_today_indicator.vue';
import eventHub from 'ee/roadmap/event_hub';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframe } from '../mock_data';
import { mockTimeframeMonths } from '../mock_data';
const mockCurrentDate = new Date(
mockTimeframe[0].getFullYear(),
mockTimeframe[0].getMonth(),
mockTimeframeMonths[0].getFullYear(),
mockTimeframeMonths[0].getMonth(),
15,
);
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
currentDate = mockCurrentDate,
timeframeItem = mockTimeframe[0],
timeframeItem = mockTimeframeMonths[0],
}) => {
const Component = Vue.extend(timelineTodayIndicatorComponent);
return mountComponent(Component, {
presetType,
currentDate,
timeframeItem,
});
......@@ -48,7 +51,7 @@ describe('TimelineTodayIndicatorComponent', () => {
});
const stylesObj = vm.todayBarStyles;
expect(stylesObj.height).toBe('120px');
expect(stylesObj.left).toBe('50%');
expect(stylesObj.left).toBe('48%');
expect(vm.todayBarReady).toBe(true);
});
});
......
import Vue from 'vue';
import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeMonths, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
timeframe = mockTimeframeMonths,
timeframeItem = mockTimeframeMonths[0],
epic = mockEpic,
shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
}) => {
const Component = Vue.extend(EpicItemTimelineComponent);
return mountComponent(Component, {
presetType,
timeframe,
timeframeItem,
epic,
shellWidth,
itemWidth,
});
};
describe('MonthsPresetMixin', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('hasStartDateForMonth', () => {
it('returns true when Epic.startDate falls within timeframeItem', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframeMonths[1] }),
timeframeItem: mockTimeframeMonths[1],
});
expect(vm.hasStartDateForMonth()).toBe(true);
});
it('returns false when Epic.startDate does not fall within timeframeItem', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframeMonths[0] }),
timeframeItem: mockTimeframeMonths[1],
});
expect(vm.hasStartDateForMonth()).toBe(false);
});
});
describe('isTimeframeUnderEndDateForMonth', () => {
beforeEach(() => {
vm = createComponent({});
});
it('returns true if provided timeframeItem is under epicEndDate', () => {
const timeframeItem = new Date(2018, 0, 10); // Jan 10, 2018
const epicEndDate = new Date(2018, 0, 26); // Jan 26, 2018
expect(vm.isTimeframeUnderEndDateForMonth(timeframeItem, epicEndDate)).toBe(true);
});
it('returns false if provided timeframeItem is NOT under epicEndDate', () => {
const timeframeItem = new Date(2018, 0, 10); // Jan 10, 2018
const epicEndDate = new Date(2018, 1, 26); // Feb 26, 2018
expect(vm.isTimeframeUnderEndDateForMonth(timeframeItem, epicEndDate)).toBe(false);
});
});
describe('getBarWidthForSingleMonth', () => {
it('returns calculated bar width based on provided cellWidth, daysInMonth and date', () => {
vm = createComponent({});
expect(vm.getBarWidthForSingleMonth(300, 30, 1)).toBe(10); // 10% size
expect(vm.getBarWidthForSingleMonth(300, 30, 15)).toBe(150); // 50% size
expect(vm.getBarWidthForSingleMonth(300, 30, 30)).toBe(300); // Full size
});
});
describe('getTimelineBarStartOffsetForMonths', () => {
it('returns empty string when Epic startDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDateOutOfRange: true }),
});
expect(vm.getTimelineBarStartOffsetForMonths()).toBe('');
});
it('returns empty string when Epic startDate is undefined and endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDateUndefined: true,
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarStartOffsetForMonths()).toBe('');
});
it('return `left: 0;` when Epic startDate is first day of the month', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: new Date(2018, 0, 1),
}),
});
expect(vm.getTimelineBarStartOffsetForMonths()).toBe('left: 0;');
});
it('returns `right: 8px;` when Epic startDate is in last timeframe month and endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframeMonths[mockTimeframeMonths.length - 1],
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarStartOffsetForMonths()).toBe('right: 8px;');
});
it('returns proportional `left` value based on Epic startDate and days in the month', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: new Date(2018, 0, 15),
}),
});
expect(vm.getTimelineBarStartOffsetForMonths()).toContain('left: 48');
});
});
describe('getTimelineBarWidthForMonths', () => {
it('returns calculated width value based on Epic.startDate and Epic.endDate', () => {
vm = createComponent({
shellWidth: 2000,
timeframeItem: mockTimeframeMonths[0],
epic: Object.assign({}, mockEpic, {
startDate: new Date(2017, 11, 15), // Dec 15, 2017
endDate: new Date(2018, 1, 15), // Feb 15, 2017
}),
});
expect(Math.floor(vm.getTimelineBarWidthForMonths())).toBe(492);
});
});
});
});
import Vue from 'vue';
import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeQuarters, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
const createComponent = ({
presetType = PRESET_TYPES.QUARTERS,
timeframe = mockTimeframeQuarters,
timeframeItem = mockTimeframeQuarters[0],
epic = mockEpic,
shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
}) => {
const Component = Vue.extend(EpicItemTimelineComponent);
return mountComponent(Component, {
presetType,
timeframe,
timeframeItem,
epic,
shellWidth,
itemWidth,
});
};
describe('QuartersPresetMixin', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('hasStartDateForQuarter', () => {
it('returns true when Epic.startDate falls within timeframeItem', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframeQuarters[1].range[0] }),
timeframeItem: mockTimeframeQuarters[1],
});
expect(vm.hasStartDateForQuarter()).toBe(true);
});
it('returns false when Epic.startDate does not fall within timeframeItem', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframeQuarters[0].range[0] }),
timeframeItem: mockTimeframeQuarters[1],
});
expect(vm.hasStartDateForQuarter()).toBe(false);
});
});
describe('isTimeframeUnderEndDateForQuarter', () => {
beforeEach(() => {
vm = createComponent({});
});
it('returns true if provided timeframeItem is under epicEndDate', () => {
const timeframeItem = mockTimeframeQuarters[1];
const epicEndDate = mockTimeframeQuarters[1].range[2];
expect(vm.isTimeframeUnderEndDateForQuarter(timeframeItem, epicEndDate)).toBe(true);
});
it('returns false if provided timeframeItem is NOT under epicEndDate', () => {
const timeframeItem = mockTimeframeQuarters[1];
const epicEndDate = mockTimeframeQuarters[2].range[1];
expect(vm.isTimeframeUnderEndDateForQuarter(timeframeItem, epicEndDate)).toBe(false);
});
});
describe('getBarWidthForSingleQuarter', () => {
it('returns calculated bar width based on provided cellWidth, daysInQuarter and day of quarter', () => {
vm = createComponent({});
expect(Math.floor(vm.getBarWidthForSingleQuarter(300, 91, 1))).toBe(3); // 10% size
expect(Math.floor(vm.getBarWidthForSingleQuarter(300, 91, 45))).toBe(148); // 50% size
expect(vm.getBarWidthForSingleQuarter(300, 91, 91)).toBe(300); // Full size
});
});
describe('getTimelineBarStartOffsetForQuarters', () => {
it('returns empty string when Epic startDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDateOutOfRange: true }),
});
expect(vm.getTimelineBarStartOffsetForQuarters()).toBe('');
});
it('returns empty string when Epic startDate is undefined and endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDateUndefined: true,
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarStartOffsetForQuarters()).toBe('');
});
it('return `left: 0;` when Epic startDate is first day of the quarter', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframeQuarters[0].range[0],
}),
});
expect(vm.getTimelineBarStartOffsetForQuarters()).toBe('left: 0;');
});
it('returns `right: 8px;` when Epic startDate is in last timeframe month and endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframeQuarters[mockTimeframeQuarters.length - 1].range[1],
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarStartOffsetForQuarters()).toBe('right: 8px;');
});
it('returns proportional `left` value based on Epic startDate and days in the quarter', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframeQuarters[0].range[1],
}),
});
expect(vm.getTimelineBarStartOffsetForQuarters()).toContain('left: 34');
});
});
describe('getTimelineBarWidthForQuarters', () => {
it('returns calculated width value based on Epic.startDate and Epic.endDate', () => {
vm = createComponent({
shellWidth: 2000,
timeframeItem: mockTimeframeQuarters[0],
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframeQuarters[0].range[1],
endDate: mockTimeframeQuarters[1].range[1],
}),
});
expect(Math.floor(vm.getTimelineBarWidthForQuarters())).toBe(282);
});
});
});
});
......@@ -2,18 +2,22 @@ import Vue from 'vue';
import roadmapTimelineSectionComponent from 'ee/roadmap/components/roadmap_timeline_section.vue';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockEpic, mockTimeframe, mockShellWidth, mockScrollBarSize } from '../mock_data';
import { mockEpic, mockTimeframeMonths, mockShellWidth, mockScrollBarSize } from '../mock_data';
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
epics = [mockEpic],
timeframe = mockTimeframe,
timeframe = mockTimeframeMonths,
shellWidth = mockShellWidth,
listScrollable = false,
}) => {
const Component = Vue.extend(roadmapTimelineSectionComponent);
return mountComponent(Component, {
presetType,
epics,
timeframe,
shellWidth,
......@@ -47,7 +51,7 @@ describe('SectionMixin', () => {
describe('sectionItemWidth', () => {
it('returns calculated item width based on sectionShellWidth and timeframe size', () => {
expect(vm.sectionItemWidth).toBe(280);
expect(vm.sectionItemWidth).toBe(240);
});
});
......
import Vue from 'vue';
import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeWeeks, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
const createComponent = ({
presetType = PRESET_TYPES.WEEKS,
timeframe = mockTimeframeWeeks,
timeframeItem = mockTimeframeWeeks[0],
epic = mockEpic,
shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
}) => {
const Component = Vue.extend(EpicItemTimelineComponent);
return mountComponent(Component, {
presetType,
timeframe,
timeframeItem,
epic,
shellWidth,
itemWidth,
});
};
describe('WeeksPresetMixin', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('hasStartDateForWeek', () => {
it('returns true when Epic.startDate falls within timeframeItem', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframeWeeks[1] }),
timeframeItem: mockTimeframeWeeks[1],
});
expect(vm.hasStartDateForWeek()).toBe(true);
});
it('returns false when Epic.startDate does not fall within timeframeItem', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframeWeeks[0] }),
timeframeItem: mockTimeframeWeeks[1],
});
expect(vm.hasStartDateForWeek()).toBe(false);
});
});
describe('getLastDayOfWeek', () => {
it('returns date object set to last day of the week from provided timeframeItem', () => {
vm = createComponent({});
const lastDayOfWeek = vm.getLastDayOfWeek(mockTimeframeWeeks[0]);
expect(lastDayOfWeek.getDate()).toBe(30);
expect(lastDayOfWeek.getMonth()).toBe(11);
expect(lastDayOfWeek.getFullYear()).toBe(2017);
});
});
describe('isTimeframeUnderEndDateForWeek', () => {
beforeEach(() => {
vm = createComponent({});
});
it('returns true if provided timeframeItem is under epicEndDate', () => {
const timeframeItem = new Date(2018, 0, 7); // Jan 7, 2018
const epicEndDate = new Date(2018, 0, 3); // Jan 3, 2018
expect(vm.isTimeframeUnderEndDateForWeek(timeframeItem, epicEndDate)).toBe(true);
});
it('returns false if provided timeframeItem is NOT under epicEndDate', () => {
const timeframeItem = new Date(2018, 0, 7); // Jan 7, 2018
const epicEndDate = new Date(2018, 0, 15); // Jan 15, 2018
expect(vm.isTimeframeUnderEndDateForWeek(timeframeItem, epicEndDate)).toBe(false);
});
});
describe('getBarWidthForSingleWeek', () => {
it('returns calculated bar width based on provided cellWidth and day of week', () => {
vm = createComponent({});
expect(Math.floor(vm.getBarWidthForSingleWeek(300, 1))).toBe(42); // 10% size
expect(Math.floor(vm.getBarWidthForSingleWeek(300, 3))).toBe(128); // 50% size
expect(vm.getBarWidthForSingleWeek(300, 7)).toBe(300); // Full size
});
});
describe('getTimelineBarEndOffsetHalfForWeek', () => {
it('returns timeline bar end offset for Weeks view', () => {
vm = createComponent({});
expect(vm.getTimelineBarEndOffsetHalfForWeek()).toBe(28);
});
});
describe('getTimelineBarStartOffsetForWeeks', () => {
it('returns empty string when Epic startDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDateOutOfRange: true }),
});
expect(vm.getTimelineBarStartOffsetForWeeks()).toBe('');
});
it('returns empty string when Epic startDate is undefined and endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDateUndefined: true,
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarStartOffsetForWeeks()).toBe('');
});
it('return `left: 0;` when Epic startDate is first day of the week', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframeWeeks[0],
}),
});
expect(vm.getTimelineBarStartOffsetForWeeks()).toBe('left: 0;');
});
it('returns `right: 8px;` when Epic startDate is in last timeframe month and endDate is out of range', () => {
const startDate = new Date(mockTimeframeWeeks[mockTimeframeWeeks.length - 1].getTime());
startDate.setDate(startDate.getDate() + 1);
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate,
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarStartOffsetForWeeks()).toBe('right: 8px;');
});
it('returns proportional `left` value based on Epic startDate and days in the month', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: new Date(2018, 0, 15),
}),
});
expect(vm.getTimelineBarStartOffsetForWeeks()).toContain('left: 60');
});
});
describe('getTimelineBarWidthForWeeks', () => {
it('returns calculated width value based on Epic.startDate and Epic.endDate', () => {
vm = createComponent({
shellWidth: 2000,
timeframeItem: mockTimeframeWeeks[0],
epic: Object.assign({}, mockEpic, {
startDate: new Date(2018, 0, 1), // Jan 1, 2018
endDate: new Date(2018, 1, 2), // Feb 2, 2018
}),
});
expect(Math.floor(vm.getTimelineBarWidthForWeeks())).toBe(1600);
});
});
});
});
import { getTimeframeWindow } from '~/lib/utils/datetime_utility';
import { TIMEFRAME_LENGTH } from 'ee/roadmap/constants';
import {
getTimeframeForQuartersView,
getTimeframeForMonthsView,
getTimeframeForWeeksView,
} from 'ee/roadmap/utils/roadmap_utils';
export const mockScrollBarSize = 15;
......@@ -15,7 +18,9 @@ export const mockNewEpicEndpoint = '/groups/gitlab-org/-/epics';
export const mockSvgPath = '/foo/bar.svg';
export const mockTimeframe = getTimeframeWindow(TIMEFRAME_LENGTH, new Date(2018, 1, 1));
export const mockTimeframeQuarters = getTimeframeForQuartersView(new Date(2018, 0, 1));
export const mockTimeframeMonths = getTimeframeForMonthsView(new Date(2018, 0, 1));
export const mockTimeframeWeeks = getTimeframeForWeeksView(new Date(2018, 0, 1));
export const mockEpic = {
id: 1,
......
import RoadmapStore from 'ee/roadmap/store/roadmap_store';
import { mockGroupId, mockTimeframe, rawEpics } from '../mock_data';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import { mockGroupId, mockTimeframeMonths, rawEpics } from '../mock_data';
describe('RoadmapStore', () => {
let store;
beforeEach(() => {
store = new RoadmapStore(mockGroupId, mockTimeframe);
store = new RoadmapStore(mockGroupId, mockTimeframeMonths, PRESET_TYPES.MONTHS);
});
describe('constructor', () => {
......@@ -13,9 +14,10 @@ describe('RoadmapStore', () => {
expect(store.state).toBeDefined();
expect(Array.isArray(store.state.epics)).toBe(true);
expect(store.state.currentGroupId).toBe(mockGroupId);
expect(store.state.timeframe).toBe(mockTimeframe);
expect(store.firstTimeframeItem).toBe(store.state.timeframe[0]);
expect(store.lastTimeframeItem).toBe(store.state.timeframe[store.state.timeframe.length - 1]);
expect(store.state.timeframe).toBe(mockTimeframeMonths);
expect(store.presetType).toBe(PRESET_TYPES.MONTHS);
expect(store.timeframeStartDate).toBeDefined();
expect(store.timeframeEndDate).toBeDefined();
});
});
......@@ -34,7 +36,7 @@ describe('RoadmapStore', () => {
describe('getTimeframe', () => {
it('gets timeframe from store state', () => {
expect(store.getTimeframe()).toBe(mockTimeframe);
expect(store.getTimeframe()).toBe(mockTimeframeMonths);
});
});
......@@ -55,12 +57,12 @@ describe('RoadmapStore', () => {
});
const epic = RoadmapStore.formatEpicDetails(
rawEpicWithoutSD,
store.firstTimeframeItem,
store.lastTimeframeItem,
store.timeframeStartDate,
store.timeframeEndDate,
);
expect(epic.id).toBe(rawEpic.id);
expect(epic.startDateUndefined).toBe(true);
expect(epic.startDate.getTime()).toBe(store.firstTimeframeItem.getTime());
expect(epic.startDate.getTime()).toBe(store.timeframeStartDate.getTime());
});
it('returns formatted Epic object with endDateUndefined and proxy date set when end date is not available', () => {
......@@ -69,12 +71,12 @@ describe('RoadmapStore', () => {
});
const epic = RoadmapStore.formatEpicDetails(
rawEpicWithoutED,
store.firstTimeframeItem,
store.lastTimeframeItem,
store.timeframeStartDate,
store.timeframeEndDate,
);
expect(epic.id).toBe(rawEpic.id);
expect(epic.endDateUndefined).toBe(true);
expect(epic.endDate.getTime()).toBe(store.lastTimeframeItem.getTime());
expect(epic.endDate.getTime()).toBe(store.timeframeEndDate.getTime());
});
it('returns formatted Epic object with startDateOutOfRange, proxy date and cached original start date set when start date is out of timeframe range', () => {
......@@ -84,12 +86,12 @@ describe('RoadmapStore', () => {
});
const epic = RoadmapStore.formatEpicDetails(
rawEpicSDOut,
store.firstTimeframeItem,
store.lastTimeframeItem,
store.timeframeStartDate,
store.timeframeEndDate,
);
expect(epic.id).toBe(rawEpic.id);
expect(epic.startDateOutOfRange).toBe(true);
expect(epic.startDate.getTime()).toBe(store.firstTimeframeItem.getTime());
expect(epic.startDate.getTime()).toBe(store.timeframeStartDate.getTime());
expect(epic.originalStartDate.getTime()).toBe(new Date(rawStartDate).getTime());
});
......@@ -100,12 +102,12 @@ describe('RoadmapStore', () => {
});
const epic = RoadmapStore.formatEpicDetails(
rawEpicEDOut,
store.firstTimeframeItem,
store.lastTimeframeItem,
store.timeframeStartDate,
store.timeframeEndDate,
);
expect(epic.id).toBe(rawEpic.id);
expect(epic.endDateOutOfRange).toBe(true);
expect(epic.endDate.getTime()).toBe(store.lastTimeframeItem.getTime());
expect(epic.endDate.getTime()).toBe(store.timeframeEndDate.getTime());
expect(epic.originalEndDate.getTime()).toBe(new Date(rawEndDate).getTime());
});
});
......
import {
getTimeframeForQuartersView,
getTimeframeForMonthsView,
getTimeframeForWeeksView,
getEpicsPathForPreset,
} from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee//roadmap/constants';
describe('getTimeframeForQuartersView', () => {
let timeframe;
beforeEach(() => {
timeframe = getTimeframeForQuartersView(new Date(2018, 0, 1));
});
it('returns timeframe with total of 6 quarters', () => {
expect(timeframe.length).toBe(6);
});
it('each timeframe item has `quarterSequence`, `year` and `range` present', () => {
const timeframeItem = timeframe[0];
expect(timeframeItem.quarterSequence).toEqual(jasmine.any(Number));
expect(timeframeItem.year).toEqual(jasmine.any(Number));
expect(Array.isArray(timeframeItem.range)).toBe(true);
});
it('first timeframe item refers to quarter prior to current quarter', () => {
const timeframeItem = timeframe[0];
const expectedQuarter = {
0: { month: 9, date: 1 }, // 1 Oct 2017
1: { month: 10, date: 1 }, // 1 Nov 2017
2: { month: 11, date: 31 }, // 31 Dec 2017
};
expect(timeframeItem.quarterSequence).toEqual(4);
expect(timeframeItem.year).toEqual(2017);
timeframeItem.range.forEach((month, index) => {
expect(month.getFullYear()).toBe(2017);
expect(expectedQuarter[index].month).toBe(month.getMonth());
expect(expectedQuarter[index].date).toBe(month.getDate());
});
});
it('last timeframe item refers to 5th quarter from current quarter', () => {
const timeframeItem = timeframe[timeframe.length - 1];
const expectedQuarter = {
0: { month: 0, date: 1 }, // 1 Jan 2019
1: { month: 1, date: 1 }, // 1 Feb 2019
2: { month: 2, date: 31 }, // 31 Mar 2019
};
expect(timeframeItem.quarterSequence).toEqual(1);
expect(timeframeItem.year).toEqual(2019);
timeframeItem.range.forEach((month, index) => {
expect(month.getFullYear()).toBe(2019);
expect(expectedQuarter[index].month).toBe(month.getMonth());
expect(expectedQuarter[index].date).toBe(month.getDate());
});
});
});
describe('getTimeframeForMonthsView', () => {
let timeframe;
beforeEach(() => {
timeframe = getTimeframeForMonthsView(new Date(2018, 0, 1));
});
it('returns timeframe with total of 7 months', () => {
expect(timeframe.length).toBe(7);
});
it('first timeframe item refers to month prior to current month', () => {
const timeframeItem = timeframe[0];
const expectedMonth = {
year: 2017,
month: 11,
date: 1,
};
expect(timeframeItem.getFullYear()).toBe(expectedMonth.year);
expect(timeframeItem.getMonth()).toBe(expectedMonth.month);
expect(timeframeItem.getDate()).toBe(expectedMonth.date);
});
it('last timeframe item refers to 6th month from current month', () => {
const timeframeItem = timeframe[timeframe.length - 1];
const expectedMonth = {
year: 2018,
month: 5,
date: 30,
};
expect(timeframeItem.getFullYear()).toBe(expectedMonth.year);
expect(timeframeItem.getMonth()).toBe(expectedMonth.month);
expect(timeframeItem.getDate()).toBe(expectedMonth.date);
});
});
describe('getTimeframeForWeeksView', () => {
let timeframe;
beforeEach(() => {
timeframe = getTimeframeForWeeksView(new Date(2018, 0, 1));
});
it('returns timeframe with total of 6 weeks', () => {
expect(timeframe.length).toBe(6);
});
it('first timeframe item refers to week prior to current week', () => {
const timeframeItem = timeframe[0];
const expectedMonth = {
year: 2017,
month: 11,
date: 24,
};
expect(timeframeItem.getFullYear()).toBe(expectedMonth.year);
expect(timeframeItem.getMonth()).toBe(expectedMonth.month);
expect(timeframeItem.getDate()).toBe(expectedMonth.date);
});
it('last timeframe item refers to 5th week from current month', () => {
const timeframeItem = timeframe[timeframe.length - 1];
const expectedMonth = {
year: 2018,
month: 0,
date: 28,
};
expect(timeframeItem.getFullYear()).toBe(expectedMonth.year);
expect(timeframeItem.getMonth()).toBe(expectedMonth.month);
expect(timeframeItem.getDate()).toBe(expectedMonth.date);
});
});
describe('getEpicsPathForPreset', () => {
const basePath = '/groups/gitlab-org/-/epics.json';
const filterQueryString = 'scope=all&utf8=✓&state=opened&label_name[]=Bug';
it('returns epics path string based on provided basePath and timeframe for Quarters', () => {
const timeframeQuarters = getTimeframeForQuartersView(new Date(2018, 0, 1));
const epicsPath = getEpicsPathForPreset({
basePath,
timeframe: timeframeQuarters,
presetType: PRESET_TYPES.QUARTERS,
});
expect(epicsPath).toBe(`${basePath}?start_date=2017-10-1&end_date=2019-3-31`);
});
it('returns epics path string based on provided basePath and timeframe for Months', () => {
const timeframeMonths = getTimeframeForMonthsView(new Date(2018, 0, 1));
const epicsPath = getEpicsPathForPreset({
basePath,
timeframe: timeframeMonths,
presetType: PRESET_TYPES.MONTHS,
});
expect(epicsPath).toBe(`${basePath}?start_date=2017-12-1&end_date=2018-6-30`);
});
it('returns epics path string based on provided basePath and timeframe for Weeks', () => {
const timeframeWeeks = getTimeframeForWeeksView(new Date(2018, 0, 1));
const epicsPath = getEpicsPathForPreset({
basePath,
timeframe: timeframeWeeks,
presetType: PRESET_TYPES.WEEKS,
});
expect(epicsPath).toBe(`${basePath}?start_date=2017-12-24&end_date=2018-2-3`);
});
it('returns epics path string while preserving filterQueryString', () => {
const timeframeMonths = getTimeframeForMonthsView(new Date(2018, 0, 1));
const epicsPath = getEpicsPathForPreset({
basePath,
filterQueryString,
timeframe: timeframeMonths,
presetType: PRESET_TYPES.MONTHS,
});
expect(epicsPath).toBe(
`${basePath}?start_date=2017-12-1&end_date=2018-6-30&scope=all&utf8=✓&state=opened&label_name[]=Bug`,
);
});
});
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