Commit 76914a77 authored by Kushal Pandya's avatar Kushal Pandya

Add support for auto-expanding Roadmap timeline on horizontal scroll

Makes Roadmap timeline dynamic to be always
scrollable on any screen size.
Fetches new epics as user scrolls into past or future and renders it.
parent 989b363e
......@@ -6,6 +6,9 @@ import { s__ } from '~/locale';
import { GlLoadingIcon } from '@gitlab/ui';
import epicsListEmpty from './epics_list_empty.vue';
import roadmapShell from './roadmap_shell.vue';
import eventHub from '../event_hub';
import { EXTEND_AS } from '../constants';
export default {
components: {
......@@ -96,6 +99,28 @@ export default {
Flash(s__('GroupRoadmap|Something went wrong while fetching epics'));
});
},
fetchEpicsForTimeframe({ timeframe, roadmapTimelineEl, extendType }) {
this.hasError = false;
this.service
.getEpicsForTimeframe(this.presetType, timeframe)
.then(res => res.data)
.then(epics => {
if (epics.length) {
this.store.addEpics(epics);
this.$nextTick(() => {
// Re-render timeline bars with updated timeline
eventHub.$emit('refreshTimeline', {
height: window.innerHeight - roadmapTimelineEl.offsetTop,
todayBarReady: extendType === EXTEND_AS.PREPEND,
});
});
}
})
.catch(() => {
this.hasError = true;
Flash(s__('GroupRoadmap|Something went wrong while fetching epics'));
});
},
/**
* Roadmap view works with absolute sizing and positioning
* of following child components of RoadmapShell;
......@@ -117,6 +142,49 @@ export default {
this.isLoading = false;
}, 200)();
},
/**
* Once timeline is expanded (either with prepend or append)
* We need performing following actions;
*
* 1. Reset start and end edges of the timeline for
* infinite scrolling to continue further.
* 2. Re-render timeline bars to account for
* updated timeframe.
* 3. In case of prepending timeframe,
* reset scroll-position (due to DOM prepend).
*/
processExtendedTimeline({ extendType = EXTEND_AS.PREPEND, roadmapTimelineEl, itemsCount = 0 }) {
// Re-render timeline bars with updated timeline
eventHub.$emit('refreshTimeline', {
height: window.innerHeight - roadmapTimelineEl.offsetTop,
todayBarReady: extendType === EXTEND_AS.PREPEND,
});
if (extendType === EXTEND_AS.PREPEND) {
// When DOM is prepended with elements
// we compensate the scrolling for added elements' width
roadmapTimelineEl.parentElement.scrollBy(
roadmapTimelineEl.querySelector('.timeline-header-item').clientWidth * itemsCount,
0,
);
}
},
handleScrollToExtend(roadmapTimelineEl, extendType = EXTEND_AS.PREPEND) {
const timeframe = this.store.extendTimeframe(extendType);
this.$nextTick(() => {
this.processExtendedTimeline({
itemsCount: timeframe ? timeframe.length : 0,
extendType,
roadmapTimelineEl,
});
this.fetchEpicsForTimeframe({
timeframe,
roadmapTimelineEl,
extendType,
});
});
},
},
};
</script>
......@@ -135,6 +203,8 @@ export default {
:epics="epics"
:timeframe="timeframe"
:current-group-id="currentGroupId"
@onScrollToStart="handleScrollToExtend"
@onScrollToEnd="handleScrollToExtend"
/>
<epics-list-empty
v-if="isEpicsListEmpty"
......
<script>
import _ from 'underscore';
import epicItemDetails from './epic_item_details.vue';
import epicItemTimeline from './epic_item_timeline.vue';
import { EPIC_HIGHLIGHT_REMOVE_AFTER } from '../constants';
export default {
components: {
epicItemDetails,
......@@ -33,11 +37,36 @@ export default {
required: true,
},
},
updated() {
this.removeHighlight();
},
methods: {
/**
* When new epics are added to the list on
* timeline scroll, we set `newEpic` flag
* as true and then use it in template
* to set `newly-added-epic` class for
* highlighting epic using CSS animations
*
* Once animation is complete, we need to
* remove the flag so that animation is not
* replayed when list is re-rendered.
*/
removeHighlight() {
if (this.epic.newEpic) {
this.$nextTick(() => {
_.delay(() => {
this.epic.newEpic = false;
}, EPIC_HIGHLIGHT_REMOVE_AFTER);
});
}
},
},
};
</script>
<template>
<div class="epics-list-item clearfix">
<div :class="{ 'newly-added-epic': epic.newEpic }" class="epics-list-item clearfix">
<epic-item-details :epic="epic" :current-group-id="currentGroupId" />
<epic-item-timeline
v-for="(timeframeItem, index) in timeframe"
......
......@@ -5,13 +5,9 @@ import QuartersPresetMixin from '../mixins/quarters_preset_mixin';
import MonthsPresetMixin from '../mixins/months_preset_mixin';
import WeeksPresetMixin from '../mixins/weeks_preset_mixin';
import {
EPIC_DETAILS_CELL_WIDTH,
TIMELINE_CELL_MIN_WIDTH,
TIMELINE_END_OFFSET_FULL,
TIMELINE_END_OFFSET_HALF,
PRESET_TYPES,
} from '../constants';
import eventHub from '../event_hub';
import { EPIC_DETAILS_CELL_WIDTH, TIMELINE_CELL_MIN_WIDTH, PRESET_TYPES } from '../constants';
export default {
directives: {
......@@ -66,6 +62,12 @@ export default {
this.renderTimelineBar();
},
},
mounted() {
eventHub.$on('refreshTimeline', this.renderTimelineBar);
},
beforeDestroy() {
eventHub.$off('refreshTimeline', this.renderTimelineBar);
},
methods: {
/**
* Gets cell width based on total number months for
......@@ -89,49 +91,6 @@ export default {
}
return false;
},
getTimelineBarEndOffsetHalf() {
if (this.presetType === PRESET_TYPES.QUARTERS) {
return TIMELINE_END_OFFSET_HALF;
} else if (this.presetType === PRESET_TYPES.MONTHS) {
return TIMELINE_END_OFFSET_HALF;
} else if (this.presetType === PRESET_TYPES.WEEKS) {
return this.getTimelineBarEndOffsetHalfForWeek();
}
return 0;
},
/**
* In case startDate or endDate for any epic is undefined or is out of range
* for current timeframe, we have to provide specific offset while
* setting width to ensure that;
*
* 1. Timeline bar ends at correct position based on end date.
* 2. A "triangle" shape is shown at the end of timeline bar
* when endDate is out of range.
*/
getTimelineBarEndOffset() {
let offset = 0;
if (
(this.epic.startDateOutOfRange && this.epic.endDateOutOfRange) ||
(this.epic.startDateUndefined && this.epic.endDateOutOfRange)
) {
// If Epic startDate is undefined or out of range
// AND
// endDate is out of range
// Reduce offset size from the width to compensate for fadeout of timelinebar
// and/or showing triangle at the end and beginning
offset = TIMELINE_END_OFFSET_FULL;
} else if (this.epic.endDateOutOfRange) {
// If Epic end date is out of range
// Reduce offset size from the width to compensate for triangle (which is sized at 8px)
offset = this.getTimelineBarEndOffsetHalf();
} else {
// No offset needed if all dates are defined.
offset = 0;
}
return offset;
},
/**
* Renders timeline bar only if current
* timeframe item has startDate for the epic.
......@@ -160,9 +119,7 @@ export default {
:href="epic.webUrl"
:class="{
'start-date-undefined': epic.startDateUndefined,
'start-date-outside': epic.startDateOutOfRange,
'end-date-undefined': epic.endDateUndefined,
'end-date-outside': epic.endDateOutOfRange,
}"
:style="timelineBarStyles"
class="timeline-bar"
......
......@@ -2,7 +2,7 @@
import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility';
import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants';
import { PRESET_TYPES, emptyStateDefault, emptyStateWithFilters } from '../constants';
import NewEpic from '../../epics/new_epic/components/new_epic.vue';
......@@ -82,12 +82,12 @@ export default {
},
subMessage() {
if (this.hasFiltersApplied) {
return sprintf(PRESET_DEFAULTS[this.presetType].emptyStateWithFilters, {
return sprintf(emptyStateWithFilters, {
startDate: this.timeframeRange.startDate,
endDate: this.timeframeRange.endDate,
});
}
return sprintf(PRESET_DEFAULTS[this.presetType].emptyStateDefault, {
return sprintf(emptyStateDefault, {
startDate: this.timeframeRange.startDate,
endDate: this.timeframeRange.endDate,
});
......
......@@ -3,6 +3,8 @@ import eventHub from '../event_hub';
import SectionMixin from '../mixins/section_mixin';
import { TIMELINE_CELL_MIN_WIDTH } from '../constants';
import epicItem from './epic_item.vue';
export default {
......@@ -72,12 +74,18 @@ export default {
},
mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$on('refreshTimeline', () => {
this.initEmptyRow(false);
});
this.$nextTick(() => {
this.initMounted();
});
},
beforeDestroy() {
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$off('refreshTimeline', () => {
this.initEmptyRow(false);
});
},
methods: {
initMounted() {
......@@ -104,7 +112,7 @@ export default {
* based on height of available list items and sets it to component
* props.
*/
initEmptyRow() {
initEmptyRow(showEmptyRow = false) {
const children = this.$children;
let approxChildrenHeight = children[0].$el.clientHeight * this.epics.length;
......@@ -122,18 +130,16 @@ export default {
this.emptyRowHeight = this.shellHeight - approxChildrenHeight;
this.showEmptyRow = true;
} else {
this.showEmptyRow = showEmptyRow;
this.showBottomShadow = true;
}
},
/**
* `clientWidth` is full width of list section, and we need to
* scroll up to 60% of the view where today indicator is present.
*
* Reason for 60% is that "today" always falls in the middle of timeframe range.
* Scroll timeframe to the right of the timeline
* by half the column size
*/
scrollToTodayIndicator() {
const uptoTodayIndicator = Math.ceil((this.$el.clientWidth * 60) / 100);
this.$el.scrollTo(uptoTodayIndicator, 0);
this.$el.parentElement.scrollBy(TIMELINE_CELL_MIN_WIDTH / 2, 0);
},
handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) {
this.showBottomShadow = Math.ceil(scrollTop) + clientHeight < scrollHeight;
......
......@@ -29,8 +29,6 @@ export default {
return {
currentDate,
quarterBeginDate: this.timeframeItem.range[0],
quarterEndDate: this.timeframeItem.range[2],
};
},
computed: {
......@@ -39,6 +37,12 @@ export default {
width: `${this.itemWidth}px`,
};
},
quarterBeginDate() {
return this.timeframeItem.range[0];
},
quarterEndDate() {
return this.timeframeItem.range[2];
},
timelineHeaderLabel() {
const { quarterSequence } = this.timeframeItem;
if (quarterSequence === 1 || (this.timeframeIndex === 0 && quarterSequence !== 1)) {
......
......@@ -20,13 +20,13 @@ export default {
required: true,
},
},
data() {
return {
quarterBeginDate: this.timeframeItem.range[0],
quarterEndDate: this.timeframeItem.range[2],
};
},
computed: {
quarterBeginDate() {
return this.timeframeItem.range[0];
},
quarterEndDate() {
return this.timeframeItem.range[2];
},
headerSubItems() {
return this.timeframeItem.range;
},
......
......@@ -29,12 +29,8 @@ export default {
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: {
......@@ -43,19 +39,35 @@ export default {
width: `${this.itemWidth}px`,
};
},
lastDayOfCurrentWeek() {
const lastDayOfCurrentWeek = new Date(this.timeframeItem.getTime());
lastDayOfCurrentWeek.setDate(lastDayOfCurrentWeek.getDate() + 7);
return lastDayOfCurrentWeek;
},
timelineHeaderLabel() {
if (this.timeframeIndex === 0) {
const timeframeItemMonth = this.timeframeItem.getMonth();
const timeframeItemDate = this.timeframeItem.getDate();
if (this.timeframeIndex === 0 || (timeframeItemMonth === 0 && timeframeItemDate <= 7)) {
return `${this.timeframeItem.getFullYear()} ${monthInWords(
this.timeframeItem,
true,
)} ${this.timeframeItem.getDate()}`;
)} ${timeframeItemDate}`;
}
return `${monthInWords(this.timeframeItem, true)} ${this.timeframeItem.getDate()}`;
return `${monthInWords(this.timeframeItem, true)} ${timeframeItemDate}`;
},
timelineHeaderClass() {
if (this.currentDate >= this.timeframeItem && this.currentDate <= this.lastDayOfCurrentWeek) {
const currentDateTime = this.currentDate.getTime();
const lastDayOfCurrentWeekTime = this.lastDayOfCurrentWeek.getTime();
if (
currentDateTime >= this.timeframeItem.getTime() &&
currentDateTime <= lastDayOfCurrentWeekTime
) {
return 'label-dark label-bold';
} else if (this.currentDate < this.lastDayOfCurrentWeek) {
} else if (currentDateTime < lastDayOfCurrentWeekTime) {
return 'label-dark';
}
return '';
......
......@@ -18,28 +18,26 @@ export default {
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: {
headerSubItems() {
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;
},
hasToday() {
return (
this.currentDate >= this.headerSubItems[0] &&
this.currentDate <= this.headerSubItems[this.headerSubItems.length - 1]
this.currentDate.getTime() >= this.headerSubItems[0].getTime() &&
this.currentDate.getTime() <= this.headerSubItems[this.headerSubItems.length - 1].getTime()
);
},
},
......
<script>
import bp from '~/breakpoints';
import { SCROLL_BAR_SIZE, EPIC_ITEM_HEIGHT, SHELL_MIN_WIDTH } from '../constants';
import { isInViewport } from '~/lib/utils/common_utils';
import { SCROLL_BAR_SIZE, EPIC_ITEM_HEIGHT, SHELL_MIN_WIDTH, EXTEND_AS } from '../constants';
import eventHub from '../event_hub';
import epicsListSection from './epics_list_section.vue';
......@@ -34,6 +35,7 @@ export default {
shellWidth: 0,
shellHeight: 0,
noScroll: false,
timeframeStartOffset: 0,
};
},
computed: {
......@@ -61,6 +63,11 @@ export default {
this.shellHeight = window.innerHeight - this.$el.offsetTop;
this.noScroll = this.shellHeight > EPIC_ITEM_HEIGHT * (this.epics.length + 1);
this.shellWidth = this.$el.parentElement.clientWidth + this.getWidthOffset();
this.timeframeStartOffset = this.$refs.roadmapTimeline.$el
.querySelector('.timeline-header-item')
.querySelector('.item-sublabel .sublabel-value:first-child')
.getBoundingClientRect().left;
}
});
},
......@@ -70,6 +77,22 @@ export default {
},
handleScroll() {
const { scrollTop, scrollLeft, clientHeight, scrollHeight } = this.$el;
const timelineEdgeStartEl = this.$refs.roadmapTimeline.$el
.querySelector('.timeline-header-item')
.querySelector('.item-sublabel .sublabel-value:first-child');
const timelineEdgeEndEl = this.$refs.roadmapTimeline.$el
.querySelector('.timeline-header-item:last-child')
.querySelector('.item-sublabel .sublabel-value:last-child');
// If timeline was scrolled to start
if (isInViewport(timelineEdgeStartEl, { left: this.timeframeStartOffset })) {
this.$emit('onScrollToStart', this.$refs.roadmapTimeline.$el, EXTEND_AS.PREPEND);
} else if (isInViewport(timelineEdgeEndEl)) {
// If timeline was scrolled to end
this.$emit('onScrollToEnd', this.$refs.roadmapTimeline.$el, EXTEND_AS.APPEND);
}
this.noScroll = this.shellHeight > EPIC_ITEM_HEIGHT * (this.epics.length + 1);
eventHub.$emit('epicsListScrolled', { scrollTop, scrollLeft, clientHeight, scrollHeight });
},
},
......@@ -84,6 +107,7 @@ export default {
@scroll="handleScroll"
>
<roadmap-timeline-section
ref="roadmapTimeline"
:preset-type="presetType"
:epics="epics"
:timeframe="timeframe"
......
......@@ -29,10 +29,12 @@ export default {
mounted() {
eventHub.$on('epicsListRendered', this.handleEpicsListRender);
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$on('refreshTimeline', this.handleEpicsListRender);
},
beforeDestroy() {
eventHub.$off('epicsListRendered', this.handleEpicsListRender);
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$off('refreshTimeline', this.handleEpicsListRender);
},
methods: {
/**
......@@ -40,7 +42,7 @@ export default {
* and renders vertical line over the area where
* today falls in current timeline
*/
handleEpicsListRender({ height }) {
handleEpicsListRender({ height, todayBarReady }) {
let left = 0;
// Get total days of current timeframe Item and then
......@@ -68,7 +70,7 @@ export default {
height: `${height + 20}px`,
left: `${left}%`,
};
this.todayBarReady = true;
this.todayBarReady = todayBarReady === undefined ? true : todayBarReady;
},
handleEpicsListScroll() {
const indicatorX = this.$el.getBoundingClientRect().x;
......
import { s__ } from '~/locale';
export const TIMEFRAME_LENGTH = 6;
export const EPIC_DETAILS_CELL_WIDTH = 320;
export const EPIC_ITEM_HEIGHT = 50;
......@@ -12,9 +10,7 @@ export const SHELL_MIN_WIDTH = 1620;
export const SCROLL_BAR_SIZE = 15;
export const TIMELINE_END_OFFSET_HALF = 8;
export const TIMELINE_END_OFFSET_FULL = 16;
export const EPIC_HIGHLIGHT_REMOVE_AFTER = 3000;
export const PRESET_TYPES = {
QUARTERS: 'QUARTERS',
......@@ -22,32 +18,27 @@ export const PRESET_TYPES = {
WEEKS: 'WEEKS',
};
export const EXTEND_AS = {
PREPEND: 'prepend',
APPEND: 'append',
};
export const emptyStateDefault = s__(
'GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups; from %{startDate} to %{endDate}.',
);
export const emptyStateWithFilters = s__(
'GroupRoadmap|To widen your search, change or remove filters; from %{startDate} to %{endDate}.',
);
export const PRESET_DEFAULTS = {
QUARTERS: {
TIMEFRAME_LENGTH: 18,
emptyStateDefault: s__(
'GroupRoadmap|To view the roadmap, add a start or due 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}.',
),
TIMEFRAME_LENGTH: 21,
},
MONTHS: {
TIMEFRAME_LENGTH: 7,
emptyStateDefault: s__(
'GroupRoadmap|To view the roadmap, add a start or due 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}.',
),
TIMEFRAME_LENGTH: 8,
},
WEEKS: {
TIMEFRAME_LENGTH: 42,
emptyStateDefault: s__(
'GroupRoadmap|To view the roadmap, add a start or due 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}.',
),
TIMEFRAME_LENGTH: 7,
},
};
......@@ -5,7 +5,7 @@ import Translate from '~/vue_shared/translate';
import { parseBoolean } from '~/lib/utils/common_utils';
import { visitUrl, mergeUrlParams } from '~/lib/utils/url_utility';
import { PRESET_TYPES } from './constants';
import { PRESET_TYPES, EPIC_DETAILS_CELL_WIDTH } from './constants';
import { getTimeframeForPreset, getEpicsPathForPreset } from './utils/roadmap_utils';
......@@ -48,23 +48,38 @@ export default () => {
? dataset.presetType
: PRESET_TYPES.MONTHS;
const filterQueryString = window.location.search.substring(1);
const timeframe = getTimeframeForPreset(presetType);
const epicsPath = getEpicsPathForPreset({
const timeframe = getTimeframeForPreset(
presetType,
window.innerWidth - el.offsetLeft - EPIC_DETAILS_CELL_WIDTH,
);
const initialEpicsPath = getEpicsPathForPreset({
basePath: dataset.epicsPath,
epicsState: dataset.epicsState,
filterQueryString,
presetType,
timeframe,
state: dataset.epicsState,
});
const store = new RoadmapStore(parseInt(dataset.groupId, 0), timeframe, presetType);
const service = new RoadmapService(epicsPath);
const store = new RoadmapStore({
groupId: parseInt(dataset.groupId, 0),
sortedBy: dataset.sortedBy,
timeframe,
presetType,
});
const service = new RoadmapService({
initialEpicsPath,
filterQueryString,
basePath: dataset.epicsPath,
epicsState: dataset.epicsState,
});
return {
store,
service,
presetType,
hasFiltersApplied,
epicsState: dataset.epicsState,
newEpicEndpoint: dataset.newEpicEndpoint,
emptyStateIllustrationPath: dataset.emptyStateIllustration,
};
......@@ -76,6 +91,7 @@ export default () => {
service: this.service,
presetType: this.presetType,
hasFiltersApplied: this.hasFiltersApplied,
epicsState: this.epicsState,
newEpicEndpoint: this.newEpicEndpoint,
emptyStateIllustrationPath: this.emptyStateIllustrationPath,
},
......
import { totalDaysInMonth } from '~/lib/utils/datetime_utility';
import { TIMELINE_END_OFFSET_HALF } from '../constants';
export default {
methods: {
/**
......@@ -17,10 +15,10 @@ export default {
* Check if current epic ends within current month (timeline cell)
*/
isTimeframeUnderEndDateForMonth(timeframeItem, epicEndDate) {
return (
timeframeItem.getYear() <= epicEndDate.getYear() &&
timeframeItem.getMonth() === epicEndDate.getMonth()
);
if (epicEndDate.getFullYear() <= timeframeItem.getFullYear()) {
return epicEndDate.getMonth() === timeframeItem.getMonth();
}
return epicEndDate.getTime() < timeframeItem.getTime();
},
/**
* Return timeline bar width for current month (timeline cell) based on
......@@ -61,16 +59,6 @@ export default {
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}%;`;
......@@ -99,7 +87,6 @@ export default {
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;
......@@ -149,8 +136,7 @@ export default {
}
}
// Reduce any offset from total width and round it off.
return timelineBarWidth - offsetEnd;
return timelineBarWidth;
},
},
};
import { totalDaysInQuarter, dayInQuarter } from '~/lib/utils/datetime_utility';
import { TIMELINE_END_OFFSET_HALF } from '../constants';
export default {
methods: {
/**
......@@ -11,7 +9,10 @@ export default {
const quarterStart = this.timeframeItem.range[0];
const quarterEnd = this.timeframeItem.range[2];
return this.epic.startDate >= quarterStart && this.epic.startDate <= quarterEnd;
return (
this.epic.startDate.getTime() >= quarterStart.getTime() &&
this.epic.startDate.getTime() <= quarterEnd.getTime()
);
},
/**
* Check if current epic ends within current quarter (timeline cell)
......@@ -19,7 +20,7 @@ export default {
isTimeframeUnderEndDateForQuarter(timeframeItem, epicEndDate) {
const quarterEnd = timeframeItem.range[2];
return epicEndDate <= quarterEnd;
return epicEndDate.getTime() <= quarterEnd.getTime();
},
/**
* Return timeline bar width for current quarter (timeline cell) based on
......@@ -57,14 +58,6 @@ export default {
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}%;`;
},
/**
......@@ -95,7 +88,6 @@ export default {
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;
......@@ -140,7 +132,7 @@ export default {
}
}
return timelineBarWidth - offsetEnd;
return timelineBarWidth;
},
},
};
import { TIMELINE_END_OFFSET_HALF } from '../constants';
import { newDate } from '~/lib/utils/datetime_utility';
export default {
methods: {
......@@ -7,16 +7,19 @@ export default {
*/
hasStartDateForWeek() {
const firstDayOfWeek = this.timeframeItem;
const lastDayOfWeek = new Date(this.timeframeItem.getTime());
const lastDayOfWeek = newDate(this.timeframeItem);
lastDayOfWeek.setDate(lastDayOfWeek.getDate() + 6);
return this.epic.startDate >= firstDayOfWeek && this.epic.startDate <= lastDayOfWeek;
return (
this.epic.startDate.getTime() >= firstDayOfWeek.getTime() &&
this.epic.startDate.getTime() <= lastDayOfWeek.getTime()
);
},
/**
* Return last date of the week from provided timeframeItem
*/
getLastDayOfWeek(timeframeItem) {
const lastDayOfWeek = new Date(timeframeItem.getTime());
const lastDayOfWeek = newDate(timeframeItem);
lastDayOfWeek.setDate(lastDayOfWeek.getDate() + 6);
return lastDayOfWeek;
},
......@@ -25,7 +28,7 @@ export default {
*/
isTimeframeUnderEndDateForWeek(timeframeItem, epicEndDate) {
const lastDayOfWeek = this.getLastDayOfWeek(timeframeItem);
return epicEndDate <= lastDayOfWeek;
return epicEndDate.getTime() <= lastDayOfWeek.getTime();
},
/**
* Return timeline bar width for current week (timeline cell) based on
......@@ -37,14 +40,6 @@ export default {
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
......@@ -73,15 +68,6 @@ export default {
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;`;
},
/**
......@@ -111,7 +97,6 @@ export default {
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;
......@@ -135,7 +120,7 @@ export default {
}
}
return timelineBarWidth - offsetEnd;
return timelineBarWidth;
},
},
};
import axios from '~/lib/utils/axios_utils';
import { getEpicsPathForPreset } from '../utils/roadmap_utils';
export default class RoadmapService {
constructor(epicsPath) {
this.epicsPath = epicsPath;
constructor({ basePath, epicsState, filterQueryString, initialEpicsPath }) {
this.basePath = basePath;
this.epicsState = epicsState;
this.filterQueryString = filterQueryString;
this.initialEpicsPath = initialEpicsPath;
}
getEpics() {
return axios.get(this.epicsPath);
return axios.get(this.initialEpicsPath);
}
getEpicsForTimeframe(presetType, timeframe) {
const epicsPath = getEpicsPathForPreset({
basePath: this.basePath,
epicsState: this.epicsState,
filterQueryString: this.filterQueryString,
presetType,
timeframe,
});
return axios.get(epicsPath);
}
}
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { parsePikadayDate } from '~/lib/utils/datetime_utility';
import { newDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
import { PRESET_TYPES } from '../constants';
import { extendTimeframeForPreset, sortEpics } from '../utils/roadmap_utils';
import { PRESET_TYPES, EXTEND_AS } from '../constants';
export default class RoadmapStore {
constructor(groupId, timeframe, presetType) {
constructor({ groupId, timeframe, presetType, sortedBy }) {
this.state = {};
this.state.epics = [];
this.state.epicIds = [];
this.state.currentGroupId = groupId;
this.state.timeframe = timeframe;
this.presetType = presetType;
this.sortedBy = sortedBy;
this.initTimeframeThreshold();
}
......@@ -27,24 +30,33 @@ export default class RoadmapStore {
this.timeframeEndDate = this.state.timeframe[lastTimeframeIndex];
} else if (this.presetType === PRESET_TYPES.WEEKS) {
this.timeframeStartDate = startFrame;
this.timeframeEndDate = new Date(this.state.timeframe[lastTimeframeIndex].getTime());
this.timeframeEndDate = newDate(this.state.timeframe[lastTimeframeIndex]);
this.timeframeEndDate.setDate(this.timeframeEndDate.getDate() + 7);
}
}
setEpics(epics) {
this.state.epics = epics.reduce((filteredEpics, epic) => {
const formattedEpic = RoadmapStore.formatEpicDetails(
epic,
this.timeframeStartDate,
this.timeframeEndDate,
);
// Exclude any Epic that has invalid dates
if (formattedEpic.startDate <= formattedEpic.endDate) {
filteredEpics.push(formattedEpic);
}
return filteredEpics;
}, []);
this.state.epicIds = [];
this.state.epics = RoadmapStore.filterInvalidEpics({
timeframeStartDate: this.timeframeStartDate,
timeframeEndDate: this.timeframeEndDate,
state: this.state,
epics,
});
}
addEpics(epics) {
this.state.epics = this.state.epics.concat(
RoadmapStore.filterInvalidEpics({
timeframeStartDate: this.timeframeStartDate,
timeframeEndDate: this.timeframeEndDate,
state: this.state,
newEpic: true,
epics,
}),
);
sortEpics(this.state.epics, this.sortedBy);
}
getEpics() {
......@@ -59,6 +71,57 @@ export default class RoadmapStore {
return this.state.timeframe;
}
extendTimeframe(extendAs = EXTEND_AS.PREPEND) {
const timeframeToExtend = extendTimeframeForPreset({
presetType: this.presetType,
extendAs,
initialDate: extendAs === EXTEND_AS.PREPEND ? this.timeframeStartDate : this.timeframeEndDate,
});
if (extendAs === EXTEND_AS.PREPEND) {
this.state.timeframe.unshift(...timeframeToExtend);
} else {
this.state.timeframe.push(...timeframeToExtend);
}
this.initTimeframeThreshold();
this.state.epics.forEach(epic =>
RoadmapStore.processEpicDates(epic, this.timeframeStartDate, this.timeframeEndDate),
);
return timeframeToExtend;
}
static filterInvalidEpics({
epics,
timeframeStartDate,
timeframeEndDate,
state,
newEpic = false,
}) {
return epics.reduce((filteredEpics, epic) => {
const formattedEpic = RoadmapStore.formatEpicDetails(
epic,
timeframeStartDate,
timeframeEndDate,
);
// Exclude any Epic that has invalid dates
// or is already present in Roadmap timeline
if (
formattedEpic.startDate <= formattedEpic.endDate &&
state.epicIds.indexOf(formattedEpic.id) < 0
) {
Object.assign(formattedEpic, {
newEpic,
});
filteredEpics.push(formattedEpic);
state.epicIds.push(formattedEpic.id);
}
return filteredEpics;
}, []);
}
/**
* This method constructs Epic object and assigns proxy dates
* in case start or end dates are unavailable.
......@@ -73,44 +136,75 @@ export default class RoadmapStore {
if (rawEpic.start_date) {
// If startDate is present
const startDate = parsePikadayDate(rawEpic.start_date);
if (startDate <= timeframeStartDate) {
// If startDate is less than first timeframe item
// startDate is out of range;
epicItem.startDateOutOfRange = true;
// store original start date in different object
epicItem.originalStartDate = startDate;
// Use startDate object to set a proxy date so
// that timeline bar can render it.
epicItem.startDate = new Date(timeframeStartDate.getTime());
} else {
// startDate is within timeframe range
epicItem.startDate = startDate;
}
epicItem.startDate = startDate;
epicItem.originalStartDate = startDate;
} else {
// Start date is not available
// startDate is not available
epicItem.startDateUndefined = true;
// Set proxy date so that timeline bar can render it.
epicItem.startDate = new Date(timeframeStartDate.getTime());
}
// Same as above but for endDate
// This entire chunk can be moved into generic method
// but we're keeping it here for the sake of simplicity.
if (rawEpic.end_date) {
// If endDate is present
const endDate = parsePikadayDate(rawEpic.end_date);
if (endDate >= timeframeEndDate) {
epicItem.endDateOutOfRange = true;
epicItem.originalEndDate = endDate;
epicItem.endDate = new Date(timeframeEndDate.getTime());
} else {
epicItem.endDate = endDate;
}
epicItem.endDate = endDate;
epicItem.originalEndDate = endDate;
} else {
// endDate is not available
epicItem.endDateUndefined = true;
epicItem.endDate = new Date(timeframeEndDate.getTime());
}
RoadmapStore.processEpicDates(epicItem, timeframeStartDate, timeframeEndDate);
return epicItem;
}
static processEpicDates(epic, timeframeStartDate, timeframeEndDate) {
if (!epic.startDateUndefined) {
// If startDate is less than first timeframe item
if (epic.originalStartDate.getTime() < timeframeStartDate.getTime()) {
Object.assign(epic, {
// startDate is out of range
startDateOutOfRange: true,
// Use startDate object to set a proxy date so
// that timeline bar can render it.
startDate: newDate(timeframeStartDate),
});
} else {
Object.assign(epic, {
// startDate is within range
startDateOutOfRange: false,
// Set startDate to original startDate
startDate: newDate(epic.originalStartDate),
});
}
} else {
Object.assign(epic, {
startDate: newDate(timeframeStartDate),
});
}
if (!epic.endDateUndefined) {
// If endDate is greater than last timeframe item
if (epic.originalEndDate.getTime() > timeframeEndDate.getTime()) {
Object.assign(epic, {
// endDate is out of range
endDateOutOfRange: true,
// Use endDate object to set a proxy date so
// that timeline bar can render it.
endDate: newDate(timeframeEndDate),
});
} else {
Object.assign(epic, {
// startDate is within range
endDateOutOfRange: false,
// Set startDate to original startDate
endDate: newDate(epic.originalEndDate),
});
}
} else {
Object.assign(epic, {
endDate: newDate(timeframeEndDate),
});
}
}
}
import { getTimeframeWindowFrom, totalDaysInMonth } from '~/lib/utils/datetime_utility';
import { newDate, getTimeframeWindowFrom, totalDaysInMonth } from '~/lib/utils/datetime_utility';
import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants';
import { PRESET_TYPES, PRESET_DEFAULTS, EXTEND_AS, TIMELINE_CELL_MIN_WIDTH } from '../constants';
const monthsForQuarters = {
1: [0, 1, 2],
2: [3, 4, 5],
3: [6, 7, 8],
4: [9, 10, 11],
};
/**
* 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
* 2 quarters before current quarters
* current quarter AND
* 4 quarters after current quarter
* thus, total of 6 quarters.
* thus, total of 7 quarters (21 Months).
*
* So returned array from this method will be;
* [
......@@ -47,37 +55,32 @@ import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants';
*
* @param {Date} initialDate
*/
export const getTimeframeForQuartersView = (initialDate = new Date()) => {
const startDate = initialDate;
export const getTimeframeForQuartersView = (initialDate = new Date(), timeframe = []) => {
const startDate = newDate(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(),
);
if (!timeframe.length) {
// 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);
// To move start back to first month of 2 quarters prior by
// adding quarter size (3 + 3) to month order will give us
// exact number of months we need to go back in time
const startMonth = currentMonthInCurrentQuarter + 6;
// 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);
// Get timeframe for the length we determined for this preset
// start from the startDate
timeframe.push(...getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH));
}
const quartersTimeframe = [];
// Iterate over the timeframe and break it down
// in chunks of quarters
for (let i = 0; i < timeframe.length; i += 3) {
......@@ -100,83 +103,246 @@ export const getTimeframeForQuartersView = (initialDate = new Date()) => {
return quartersTimeframe;
};
export const extendTimeframeForQuartersView = (initialDate = new Date(), length) => {
const startDate = newDate(initialDate);
startDate.setDate(1);
startDate.setMonth(startDate.getMonth() + (length > 0 ? 1 : -1));
const timeframe = getTimeframeWindowFrom(startDate, length);
return getTimeframeForQuartersView(startDate, length > 0 ? timeframe : timeframe.reverse());
};
/**
* 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
* 2 months before current month,
* current month AND
* 5 months after current month
* thus, total of 7 months.
* thus, total of 8 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
* 1 Nov 2017, 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);
const startDate = newDate(initialDate);
// Move startDate to a month prior to current month
startDate.setMonth(startDate.getMonth() - 1);
startDate.setMonth(startDate.getMonth() - 2);
return getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.MONTHS.TIMEFRAME_LENGTH);
};
export const extendTimeframeForMonthsView = (initialDate = new Date(), length) => {
const startDate = newDate(initialDate);
// When length is positive (which means extension is of type APPEND)
// Set initial date as first day of the month.
if (length > 0) {
startDate.setDate(1);
}
const timeframe = getTimeframeWindowFrom(startDate, length - 1).slice(1);
return length > 0 ? timeframe : timeframe.reverse();
};
/**
* 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
* 2 weeks before current week,
* current week AND
* 4 weeks after current week
* thus, total of 6 weeks.
* thus, total of 7 weeks.
* Note that week starts on Sunday
*
* So returned array from this method will be;
* [
* 7 Jan 2018, 14 Jan 2018, 21 Jan 2018,
* 31 Dec 2017, 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;
const startDate = newDate(initialDate);
startDate.setHours(0, 0, 0, 0);
const dayOfWeek = startDate.getDay();
const daysToFirstDayOfPrevWeek = dayOfWeek + 7;
const daysToFirstDayOfPrevWeek = dayOfWeek + 14;
const timeframe = [];
// Move startDate to first day (Sunday) of previous week
// Move startDate to first day (Sunday) of 2 weeks prior
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);
// first day (Sunday) of the week
timeframe.push(newDate(startDate));
// Move date next Sunday
startDate.setDate(startDate.getDate() + 7);
}
return timeframe;
};
export const getTimeframeForPreset = (presetType = PRESET_TYPES.MONTHS) => {
export const extendTimeframeForWeeksView = (initialDate = new Date(), length) => {
const startDate = newDate(initialDate);
if (length < 0) {
startDate.setDate(startDate.getDate() + (length + 1) * 7);
} else {
startDate.setDate(startDate.getDate() + 7);
}
const timeframe = getTimeframeForWeeksView(startDate, length + 1);
return timeframe.slice(1);
};
export const extendTimeframeForPreset = ({
presetType = PRESET_TYPES.MONTHS,
extendAs = EXTEND_AS.PREPEND,
extendByLength = 0,
initialDate,
}) => {
if (presetType === PRESET_TYPES.QUARTERS) {
return getTimeframeForQuartersView();
const length = extendByLength || PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH;
return extendTimeframeForQuartersView(
initialDate,
extendAs === EXTEND_AS.PREPEND ? -length : length,
);
} else if (presetType === PRESET_TYPES.MONTHS) {
return getTimeframeForMonthsView();
const length = extendByLength || PRESET_DEFAULTS.MONTHS.TIMEFRAME_LENGTH;
return extendTimeframeForMonthsView(
initialDate,
extendAs === EXTEND_AS.PREPEND ? -length : length,
);
}
return getTimeframeForWeeksView();
const length = extendByLength || PRESET_DEFAULTS.WEEKS.TIMEFRAME_LENGTH;
return extendTimeframeForWeeksView(
initialDate,
extendAs === EXTEND_AS.PREPEND ? -length : length,
);
};
export const extendTimeframeForAvailableWidth = ({
timeframe,
timeframeStart,
timeframeEnd,
availableTimeframeWidth,
presetType,
}) => {
let timeframeLength = timeframe.length;
// Estimate how many more timeframe columns are needed
// to fill in extra screen space so that timeline becomes
// horizontally scrollable.
while (availableTimeframeWidth / timeframeLength > TIMELINE_CELL_MIN_WIDTH) {
timeframeLength += 1;
}
// We double the increaseLengthBy to make sure there's enough room
// to perform horizontal scroll without triggering timeframe extension
// on initial page load.
const increaseLengthBy = (timeframeLength - timeframe.length) * 2;
// If there are timeframe items to be added
// to make timeline scrollable, do as follows.
if (increaseLengthBy > 0) {
// Split length in 2 parts and get
// count for both prepend and append.
const prependBy = Math.floor(increaseLengthBy / 2);
const appendBy = Math.ceil(increaseLengthBy / 2);
if (prependBy) {
// Prepend the timeline with
// the count as given by prependBy
timeframe.unshift(
...extendTimeframeForPreset({
extendAs: EXTEND_AS.PREPEND,
initialDate: timeframeStart,
// In case of presetType `quarters`, length would represent
// number of months for total quarters, hence we do `* 3`.
extendByLength: presetType === PRESET_TYPES.QUARTERS ? prependBy * 3 : prependBy,
presetType,
}),
);
}
if (appendBy) {
// Append the timeline with
// the count as given by appendBy
timeframe.push(
...extendTimeframeForPreset({
extendAs: EXTEND_AS.APPEND,
initialDate: timeframeEnd,
// In case of presetType `quarters`, length would represent
// number of months for total quarters, hence we do `* 3`.
//
// For other preset types, we add `2` to appendBy to compensate for
// last item of original timeframe (month or week)
extendByLength: presetType === PRESET_TYPES.QUARTERS ? appendBy * 3 : appendBy + 2,
presetType,
}),
);
}
}
};
export const getTimeframeForPreset = (
presetType = PRESET_TYPES.MONTHS,
availableTimeframeWidth = 0,
) => {
let timeframe;
let timeframeStart;
let timeframeEnd;
// Get timeframe based on presetType and
// extract timeframeStart and timeframeEnd
// date objects
if (presetType === PRESET_TYPES.QUARTERS) {
timeframe = getTimeframeForQuartersView();
[timeframeStart] = timeframe[0].range;
// eslint-disable-next-line prefer-destructuring
timeframeEnd = timeframe[timeframe.length - 1].range[2];
} else if (presetType === PRESET_TYPES.MONTHS) {
timeframe = getTimeframeForMonthsView();
[timeframeStart] = timeframe;
timeframeEnd = timeframe[timeframe.length - 1];
} else {
timeframe = getTimeframeForWeeksView();
timeframeStart = newDate(timeframe[0]);
timeframeEnd = newDate(timeframe[timeframe.length - 1]);
timeframeStart.setDate(timeframeStart.getDate() - 7); // Move date back by a week
timeframeEnd.setDate(timeframeEnd.getDate() + 7); // Move date ahead by a week
}
// Extend timeframe on initial load to ensure
// timeline is horizontally scrollable in all
// screen sizes.
extendTimeframeForAvailableWidth({
timeframe,
timeframeStart,
timeframeEnd,
availableTimeframeWidth,
presetType,
});
return timeframe;
};
export const getEpicsPathForPreset = ({
......@@ -184,7 +350,7 @@ export const getEpicsPathForPreset = ({
filterQueryString = '',
presetType = '',
timeframe = [],
state = 'all',
epicsState = 'all',
}) => {
let start;
let end;
......@@ -208,13 +374,13 @@ export const getEpicsPathForPreset = ({
end = lastTimeframe;
} else if (presetType === PRESET_TYPES.WEEKS) {
start = firstTimeframe;
end = new Date(lastTimeframe.getTime());
end = newDate(lastTimeframe);
end.setDate(end.getDate() + 6);
}
const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`;
const endDate = `${end.getFullYear()}-${end.getMonth() + 1}-${end.getDate()}`;
epicsPath += `?state=${state}&start_date=${startDate}&end_date=${endDate}`;
epicsPath += `?state=${epicsState}&start_date=${startDate}&end_date=${endDate}`;
if (filterQueryString) {
epicsPath += `&${filterQueryString}`;
......@@ -222,3 +388,43 @@ export const getEpicsPathForPreset = ({
return epicsPath;
};
export const sortEpics = (epics, sortedBy) => {
const sortByStartDate = sortedBy.indexOf('start_date') > -1;
const sortOrderAsc = sortedBy.indexOf('asc') > -1;
epics.sort((a, b) => {
let aDate;
let bDate;
if (sortByStartDate) {
aDate = a.startDate;
if (a.startDateOutOfRange) {
aDate = a.originalStartDate;
}
bDate = b.startDate;
if (b.startDateOutOfRange) {
bDate = b.originalStartDate;
}
} else {
aDate = a.endDate;
if (a.endDateOutOfRange) {
aDate = a.originalEndDate;
}
bDate = b.endDate;
if (b.endDateOutOfRange) {
bDate = b.originalEndDate;
}
}
// Sort in ascending or descending order
if (aDate.getTime() < bDate.getTime()) {
return sortOrderAsc ? -1 : 1;
} else if (aDate.getTime() > bDate.getTime()) {
return sortOrderAsc ? 1 : -1;
}
return 0;
});
};
......@@ -20,6 +20,36 @@ $column-right-gradient: linear-gradient(
$roadmap-gradient-gray 100%
);
@keyframes colorTransitionDetailsCell {
from {
background-color: $blue-100;
}
to {
background-color: $white-light;
}
}
@keyframes fadeInDetails {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeinTimelineBar {
from {
opacity: 0;
}
to {
opacity: 0.75;
}
}
@mixin roadmap-scroll-mixin {
height: $grid-size;
width: $details-cell-width;
......@@ -229,6 +259,12 @@ $column-right-gradient: linear-gradient(
}
}
&.newly-added-epic {
.epic-details-cell {
animation: colorTransitionDetailsCell 3s;
}
}
.epic-details-cell,
.epic-timeline-cell {
box-sizing: border-box;
......@@ -251,6 +287,11 @@ $column-right-gradient: linear-gradient(
height: $item-height;
}
.epic-title,
.epic-group-timeframe {
animation: fadeInDetails 1s;
}
.epic-title {
display: table;
table-layout: fixed;
......@@ -294,25 +335,12 @@ $column-right-gradient: linear-gradient(
background-color: $blue-500;
border-radius: $border-radius-default;
opacity: 0.75;
animation: fadeinTimelineBar 1s;
&:hover {
opacity: 1;
}
&.start-date-outside::before,
&.end-date-outside::after {
content: "";
position: absolute;
top: 0;
height: 100%;
}
&.start-date-outside::before,
&.end-date-outside::after {
border-top: 12px solid transparent;
border-bottom: 12px solid transparent;
}
&.start-date-undefined {
background: linear-gradient(
to right,
......@@ -330,31 +358,6 @@ $column-right-gradient: linear-gradient(
$roadmap-gradient-gray 100%
);
}
&.start-date-outside {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
&::before {
left: -$grid-size;
border-right: $grid-size solid $blue-500;
}
}
&.end-date-outside {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
&::after {
right: -$grid-size;
border-left: $grid-size solid $blue-500;
}
}
&.start-date-outside,
&.start-date-undefined.end-date-outside {
left: $grid-size;
}
}
&:last-child {
......
......@@ -6,25 +6,42 @@ import axios from '~/lib/utils/axios_utils';
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 eventHub from 'ee/roadmap/event_hub';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES, EXTEND_AS } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import {
mockTimeframeMonths,
mockTimeframeInitialDate,
mockGroupId,
basePath,
epicsPath,
mockNewEpicEndpoint,
rawEpics,
mockSvgPath,
mockSortedBy,
} from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = () => {
const Component = Vue.extend(appComponent);
const timeframe = mockTimeframeMonths;
const store = new RoadmapStore(mockGroupId, timeframe);
const service = new RoadmapService(epicsPath);
const store = new RoadmapStore({
groupId: mockGroupId,
presetType: PRESET_TYPES.MONTHS,
sortedBy: mockSortedBy,
timeframe,
});
const service = new RoadmapService({
initialEpicsPath: epicsPath,
epicsState: 'all',
basePath,
});
return mountComponent(Component, {
store,
......@@ -173,6 +190,193 @@ describe('AppComponent', () => {
}, 0);
});
});
describe('fetchEpicsForTimeframe', () => {
const roadmapTimelineEl = {
offsetTop: 0,
};
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
document.body.innerHTML += '<div class="flash-container"></div>';
});
afterEach(() => {
mock.restore();
document.querySelector('.flash-container').remove();
});
it('calls service.fetchEpicsForTimeframe and adds response to the store on success', done => {
mock.onGet(vm.service.epicsPath).reply(200, rawEpics);
spyOn(vm.service, 'getEpicsForTimeframe').and.callThrough();
spyOn(vm.store, 'addEpics');
spyOn(vm, '$nextTick').and.stub();
vm.fetchEpicsForTimeframe({
timeframe: mockTimeframeMonths,
extendType: EXTEND_AS.APPEND,
roadmapTimelineEl,
});
expect(vm.hasError).toBe(false);
expect(vm.service.getEpicsForTimeframe).toHaveBeenCalledWith(
PRESET_TYPES.MONTHS,
mockTimeframeMonths,
);
setTimeout(() => {
expect(vm.store.addEpics).toHaveBeenCalledWith(rawEpics);
done();
}, 0);
});
it('calls service.fetchEpicsForTimeframe and sets `hasError` to true and shows flash message when request failed', done => {
mock.onGet(vm.service.fetchEpicsForTimeframe).reply(500, {});
vm.fetchEpicsForTimeframe({
timeframe: mockTimeframeMonths,
extendType: EXTEND_AS.APPEND,
roadmapTimelineEl,
});
expect(vm.hasError).toBe(false);
setTimeout(() => {
expect(vm.hasError).toBe(true);
expect(document.querySelector('.flash-text').innerText.trim()).toBe(
'Something went wrong while fetching epics',
);
done();
}, 0);
});
});
describe('processExtendedTimeline', () => {
it('updates timeline by extending timeframe from the start when called with extendType as `prepend`', done => {
vm.store.setEpics(rawEpics);
vm.isLoading = false;
Vue.nextTick()
.then(() => {
const roadmapTimelineEl = vm.$el.querySelector('.roadmap-timeline-section');
spyOn(eventHub, '$emit');
spyOn(roadmapTimelineEl.parentElement, 'scrollBy');
vm.processExtendedTimeline({
extendType: EXTEND_AS.PREPEND,
roadmapTimelineEl,
itemsCount: 0,
});
expect(eventHub.$emit).toHaveBeenCalledWith('refreshTimeline', jasmine.any(Object));
expect(roadmapTimelineEl.parentElement.scrollBy).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('updates timeline by extending timeframe from the end when called with extendType as `append`', done => {
vm.store.setEpics(rawEpics);
vm.isLoading = false;
Vue.nextTick()
.then(() => {
const roadmapTimelineEl = vm.$el.querySelector('.roadmap-timeline-section');
spyOn(eventHub, '$emit');
vm.processExtendedTimeline({
extendType: EXTEND_AS.PREPEND,
roadmapTimelineEl,
itemsCount: 0,
});
expect(eventHub.$emit).toHaveBeenCalledWith('refreshTimeline', jasmine.any(Object));
})
.then(done)
.catch(done.fail);
});
});
describe('handleScrollToExtend', () => {
beforeEach(() => {
vm.store.setEpics(rawEpics);
vm.isLoading = false;
});
it('updates the store and refreshes roadmap with extended timeline when called with `extendType` param as `prepend`', done => {
spyOn(vm.store, 'extendTimeframe');
spyOn(vm, 'processExtendedTimeline');
spyOn(vm, 'fetchEpicsForTimeframe');
const extendType = EXTEND_AS.PREPEND;
const roadmapTimelineEl = vm.$el.querySelector('.roadmap-timeline-section');
vm.handleScrollToExtend(roadmapTimelineEl, extendType);
expect(vm.store.extendTimeframe).toHaveBeenCalledWith(extendType);
vm.$nextTick()
.then(() => {
expect(vm.processExtendedTimeline).toHaveBeenCalledWith(
jasmine.objectContaining({
itemsCount: 0,
extendType,
roadmapTimelineEl,
}),
);
expect(vm.fetchEpicsForTimeframe).toHaveBeenCalledWith(
jasmine.objectContaining({
// During tests, we don't extend timeframe
// as we spied on `vm.store.extendTimeframe` above
timeframe: undefined,
roadmapTimelineEl,
extendType,
}),
);
})
.then(done)
.catch(done.fail);
});
it('updates the store and refreshes roadmap with extended timeline when called with `extendType` param as `append`', done => {
spyOn(vm.store, 'extendTimeframe');
spyOn(vm, 'processExtendedTimeline');
spyOn(vm, 'fetchEpicsForTimeframe');
const extendType = EXTEND_AS.APPEND;
const roadmapTimelineEl = vm.$el.querySelector('.roadmap-timeline-section');
vm.handleScrollToExtend(roadmapTimelineEl, extendType);
expect(vm.store.extendTimeframe).toHaveBeenCalledWith(extendType);
vm.$nextTick()
.then(() => {
expect(vm.processExtendedTimeline).toHaveBeenCalledWith(
jasmine.objectContaining({
itemsCount: 0,
extendType,
roadmapTimelineEl,
}),
);
expect(vm.fetchEpicsForTimeframe).toHaveBeenCalledWith(
jasmine.objectContaining({
// During tests, we don't extend timeframe
// as we spied on `vm.store.extendTimeframe` above
timeframe: undefined,
roadmapTimelineEl,
extendType,
}),
);
})
.then(done)
.catch(done.fail);
});
});
});
describe('mounted', () => {
......
import Vue from 'vue';
import _ from 'underscore';
import epicItemComponent from 'ee/roadmap/components/epic_item.vue';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import {
mockTimeframeMonths,
mockTimeframeInitialDate,
mockEpic,
mockGroupId,
mockShellWidth,
mockItemWidth,
} from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
epic = mockEpic,
......@@ -44,6 +50,25 @@ describe('EpicItemComponent', () => {
vm.$destroy();
});
describe('methods', () => {
describe('removeHighlight', () => {
it('should call _.delay after 3 seconds with a callback function which would set `epic.newEpic` to false when it is true already', done => {
spyOn(_, 'delay');
vm.epic.newEpic = true;
vm.removeHighlight();
vm.$nextTick()
.then(() => {
expect(_.delay).toHaveBeenCalledWith(jasmine.any(Function), 3000);
})
.then(done)
.catch(done.fail);
});
});
});
describe('template', () => {
it('renders component container element class `epics-list-item`', () => {
expect(vm.$el.classList.contains('epics-list-item')).toBeTruthy();
......
import Vue from 'vue';
import epicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import {
TIMELINE_END_OFFSET_FULL,
TIMELINE_END_OFFSET_HALF,
TIMELINE_CELL_MIN_WIDTH,
PRESET_TYPES,
} from 'ee/roadmap/constants';
import eventHub from 'ee/roadmap/event_hub';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { TIMELINE_CELL_MIN_WIDTH, PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeMonths, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
import { mockTimeframeInitialDate, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
......@@ -62,7 +62,7 @@ describe('EpicItemTimelineComponent', () => {
it('returns proportionate width based on timeframe length and shellWidth', () => {
vm = createComponent({});
expect(vm.getCellWidth()).toBe(240);
expect(vm.getCellWidth()).toBe(210);
});
it('returns minimum fixed width when proportionate width available lower than minimum fixed width defined', () => {
......@@ -74,46 +74,6 @@ describe('EpicItemTimelineComponent', () => {
});
});
describe('getTimelineBarEndOffset', () => {
it('returns full offset value when both Epic startDate and endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDateOutOfRange: true,
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarEndOffset()).toBe(TIMELINE_END_OFFSET_FULL);
});
it('returns full offset value when Epic startDate is undefined and endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDateUndefined: true,
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarEndOffset()).toBe(TIMELINE_END_OFFSET_FULL);
});
it('returns half offset value when Epic endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarEndOffset()).toBe(TIMELINE_END_OFFSET_HALF);
});
it('returns 0 when both Epic startDate and endDate is defined and within range', () => {
vm = createComponent({});
expect(vm.getTimelineBarEndOffset()).toBe(0);
});
});
describe('renderTimelineBar', () => {
it('sets `timelineBarStyles` & `timelineBarReady` when timeframeItem has Epic.startDate', () => {
vm = createComponent({
......@@ -122,7 +82,7 @@ describe('EpicItemTimelineComponent', () => {
});
vm.renderTimelineBar();
expect(vm.timelineBarStyles).toBe('width: 1216px; left: 0;');
expect(vm.timelineBarStyles).toBe('width: 1274px; left: 0;');
expect(vm.timelineBarReady).toBe(true);
});
......@@ -139,6 +99,26 @@ describe('EpicItemTimelineComponent', () => {
});
});
describe('mounted', () => {
it('binds `refreshTimeline` event listener on eventHub', () => {
spyOn(eventHub, '$on');
const vmX = createComponent({});
expect(eventHub.$on).toHaveBeenCalledWith('refreshTimeline', jasmine.any(Function));
vmX.$destroy();
});
});
describe('beforeDestroy', () => {
it('unbinds `refreshTimeline` event listener on eventHub', () => {
spyOn(eventHub, '$off');
const vmX = createComponent({});
vmX.$destroy();
expect(eventHub.$off).toHaveBeenCalledWith('refreshTimeline', jasmine.any(Function));
});
});
describe('template', () => {
it('renders component container element with class `epic-timeline-cell`', () => {
vm = createComponent({});
......@@ -172,7 +152,7 @@ describe('EpicItemTimelineComponent', () => {
vm.renderTimelineBar();
vm.$nextTick(() => {
expect(timelineBarEl.getAttribute('style')).toBe('width: 608.571px; left: 0px;');
expect(timelineBarEl.getAttribute('style')).toBe('width: 742.5px; left: 0px;');
done();
});
});
......@@ -193,23 +173,6 @@ describe('EpicItemTimelineComponent', () => {
});
});
it('renders timeline bar with `start-date-outside` class when Epic startDate is out of range of timeframe', done => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDateOutOfRange: true,
startDate: mockTimeframeMonths[0],
originalStartDate: new Date(2017, 0, 1),
}),
});
const timelineBarEl = vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar');
vm.renderTimelineBar();
vm.$nextTick(() => {
expect(timelineBarEl.classList.contains('start-date-outside')).toBe(true);
done();
});
});
it('renders timeline bar with `end-date-undefined` class when Epic endDate is undefined', done => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
......@@ -226,23 +189,5 @@ describe('EpicItemTimelineComponent', () => {
done();
});
});
it('renders timeline bar with `end-date-outside` class when Epic endDate is out of range of timeframe', done => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframeMonths[0],
endDateOutOfRange: true,
endDate: mockTimeframeMonths[mockTimeframeMonths.length - 1],
originalEndDate: new Date(2018, 11, 1),
}),
});
const timelineBarEl = vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar');
vm.renderTimelineBar();
vm.$nextTick(() => {
expect(timelineBarEl.classList.contains('end-date-outside')).toBe(true);
done();
});
});
});
});
......@@ -2,16 +2,20 @@ import Vue from 'vue';
import epicsListEmptyComponent from 'ee/roadmap/components/epics_list_empty.vue';
import {
getTimeframeForQuartersView,
getTimeframeForWeeksView,
getTimeframeForMonthsView,
} from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import {
mockTimeframeQuarters,
mockTimeframeMonths,
mockTimeframeWeeks,
mockSvgPath,
mockNewEpicEndpoint,
} from '../mock_data';
import { mockTimeframeInitialDate, mockSvgPath, mockNewEpicEndpoint } from '../mock_data';
const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
const createComponent = ({
hasFiltersApplied = false,
......@@ -71,7 +75,7 @@ describe('EpicsListEmptyComponent', () => {
Vue.nextTick()
.then(() => {
expect(vm.subMessage).toBe(
'To view the roadmap, add a start or due 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.',
'To view the roadmap, add a start or due date to one of your epics in this group or its subgroups; from Jul 1, 2017 to Mar 31, 2019.',
);
})
.then(done)
......@@ -83,7 +87,7 @@ describe('EpicsListEmptyComponent', () => {
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.',
'To widen your search, change or remove filters; from Jul 1, 2017 to Mar 31, 2019.',
);
})
.then(done)
......@@ -100,7 +104,7 @@ describe('EpicsListEmptyComponent', () => {
Vue.nextTick()
.then(() => {
expect(vm.subMessage).toBe(
'To view the roadmap, add a start or due 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.',
'To view the roadmap, add a start or due date to one of your epics in this group or its subgroups; from Nov 1, 2017 to Jun 30, 2018.',
);
})
.then(done)
......@@ -112,7 +116,7 @@ describe('EpicsListEmptyComponent', () => {
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.',
'To widen your search, change or remove filters; from Nov 1, 2017 to Jun 30, 2018.',
);
})
.then(done)
......@@ -134,7 +138,7 @@ describe('EpicsListEmptyComponent', () => {
Vue.nextTick()
.then(() => {
expect(vm.subMessage).toBe(
'To view the roadmap, add a start or due 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.',
'To view the roadmap, add a start or due date to one of your epics in this group or its subgroups; from Dec 17, 2017 to Feb 9, 2018.',
);
})
.then(done)
......@@ -146,7 +150,7 @@ describe('EpicsListEmptyComponent', () => {
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.',
'To widen your search, change or remove filters; from Dec 17, 2017 to Feb 15, 2018.',
);
})
.then(done)
......@@ -157,7 +161,7 @@ describe('EpicsListEmptyComponent', () => {
describe('timeframeRange', () => {
it('returns correct timeframe startDate and endDate in words', () => {
expect(vm.timeframeRange.startDate).toBe('Dec 1, 2017');
expect(vm.timeframeRange.startDate).toBe('Nov 1, 2017');
expect(vm.timeframeRange.endDate).toBe('Jun 30, 2018');
});
});
......
......@@ -3,11 +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 { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { rawEpics, mockTimeframeMonths, mockGroupId, mockShellWidth } from '../mock_data';
import {
rawEpics,
mockTimeframeInitialDate,
mockGroupId,
mockShellWidth,
mockSortedBy,
} from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const store = new RoadmapStore({
groupId: mockGroupId,
presetType: PRESET_TYPES.MONTHS,
sortedBy: mockSortedBy,
timeframe: mockTimeframeMonths,
});
const store = new RoadmapStore(mockGroupId, mockTimeframeMonths, PRESET_TYPES.MONTHS);
store.setEpics(rawEpics);
const mockEpics = store.getEpics();
......@@ -63,7 +78,7 @@ describe('EpicsListSectionComponent', () => {
describe('emptyRowCellStyles', () => {
it('returns computed style object based on sectionItemWidth prop value', () => {
expect(vm.emptyRowCellStyles.width).toBe('240px');
expect(vm.emptyRowCellStyles.width).toBe('210px');
});
});
......@@ -129,15 +144,6 @@ describe('EpicsListSectionComponent', () => {
});
});
});
describe('scrollToTodayIndicator', () => {
it('scrolls table body to put timeline today indicator in focus', () => {
spyOn(vm.$el, 'scrollTo');
vm.scrollToTodayIndicator();
expect(vm.$el.scrollTo).toHaveBeenCalledWith(jasmine.any(Number), 0);
});
});
});
describe('template', () => {
......
import Vue from 'vue';
import MonthsHeaderItemComponent from 'ee/roadmap/components/preset_months/months_header_item.vue';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeMonths, mockShellWidth, mockItemWidth } from 'ee_spec/roadmap/mock_data';
import { mockTimeframeInitialDate, mockShellWidth, mockItemWidth } from 'ee_spec/roadmap/mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const mockTimeframeIndex = 0;
const createComponent = ({
......@@ -56,7 +58,7 @@ describe('MonthsHeaderItemComponent', () => {
it('returns string containing Year and Month for current timeline header item', () => {
vm = createComponent({});
expect(vm.timelineHeaderLabel).toBe('2017 Dec');
expect(vm.timelineHeaderLabel).toBe('2017 Nov');
});
it('returns string containing only Month for current timeline header item when previous header contained Year', () => {
......@@ -65,7 +67,7 @@ describe('MonthsHeaderItemComponent', () => {
timeframeItem: mockTimeframeMonths[mockTimeframeIndex + 1],
});
expect(vm.timelineHeaderLabel).toBe('2018 Jan');
expect(vm.timelineHeaderLabel).toBe('Dec');
});
});
......@@ -117,7 +119,7 @@ describe('MonthsHeaderItemComponent', () => {
const itemLabelEl = vm.$el.querySelector('.item-label');
expect(itemLabelEl).not.toBeNull();
expect(itemLabelEl.innerText.trim()).toBe('2017 Dec');
expect(itemLabelEl.innerText.trim()).toBe('2017 Nov');
});
});
});
import Vue from 'vue';
import MonthsHeaderSubItemComponent from 'ee/roadmap/components/preset_months/months_header_sub_item.vue';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeMonths } from 'ee_spec/roadmap/mock_data';
import { mockTimeframeInitialDate } from 'ee_spec/roadmap/mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ({
currentDate = mockTimeframeMonths[0],
......
import Vue from 'vue';
import QuartersHeaderItemComponent from 'ee/roadmap/components/preset_quarters/quarters_header_item.vue';
import { getTimeframeForQuartersView } from 'ee/roadmap/utils/roadmap_utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeQuarters, mockShellWidth, mockItemWidth } from 'ee_spec/roadmap/mock_data';
import { mockTimeframeInitialDate, mockShellWidth, mockItemWidth } from 'ee_spec/roadmap/mock_data';
const mockTimeframeIndex = 0;
const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
const createComponent = ({
timeframeIndex = mockTimeframeIndex,
......@@ -38,8 +40,6 @@ describe('QuartersHeaderItemComponent', () => {
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]);
});
});
......@@ -52,11 +52,23 @@ describe('QuartersHeaderItemComponent', () => {
});
});
describe('quarterBeginDate', () => {
it('returns date object representing quarter begin date for current `timeframeItem`', () => {
expect(vm.quarterBeginDate).toBe(mockTimeframeQuarters[mockTimeframeIndex].range[0]);
});
});
describe('quarterEndDate', () => {
it('returns date object representing quarter end date for current `timeframeItem`', () => {
expect(vm.quarterEndDate).toBe(mockTimeframeQuarters[mockTimeframeIndex].range[2]);
});
});
describe('timelineHeaderLabel', () => {
it('returns string containing Year and Quarter for current timeline header item', () => {
vm = createComponent({});
expect(vm.timelineHeaderLabel).toBe('2017 Q4');
expect(vm.timelineHeaderLabel).toBe('2017 Q3');
});
it('returns string containing only Quarter for current timeline header item when previous header contained Year', () => {
......@@ -65,7 +77,7 @@ describe('QuartersHeaderItemComponent', () => {
timeframeItem: mockTimeframeQuarters[mockTimeframeIndex + 2],
});
expect(vm.timelineHeaderLabel).toBe('Q2');
expect(vm.timelineHeaderLabel).toBe('2018 Q1');
});
});
......@@ -118,7 +130,7 @@ describe('QuartersHeaderItemComponent', () => {
const itemLabelEl = vm.$el.querySelector('.item-label');
expect(itemLabelEl).not.toBeNull();
expect(itemLabelEl.innerText.trim()).toBe('2017 Q4');
expect(itemLabelEl.innerText.trim()).toBe('2017 Q3');
});
});
});
import Vue from 'vue';
import QuartersHeaderSubItemComponent from 'ee/roadmap/components/preset_quarters/quarters_header_sub_item.vue';
import { getTimeframeForQuartersView } from 'ee/roadmap/utils/roadmap_utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeQuarters } from 'ee_spec/roadmap/mock_data';
import { mockTimeframeInitialDate } from 'ee_spec/roadmap/mock_data';
const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
const createComponent = ({
currentDate = mockTimeframeQuarters[0].range[1],
......@@ -25,6 +28,22 @@ describe('QuartersHeaderSubItemComponent', () => {
});
describe('computed', () => {
describe('quarterBeginDate', () => {
it('returns first month from the `timeframeItem.range`', () => {
vm = createComponent({});
expect(vm.quarterBeginDate).toBe(mockTimeframeQuarters[0].range[0]);
});
});
describe('quarterEndDate', () => {
it('returns first month from the `timeframeItem.range`', () => {
vm = createComponent({});
expect(vm.quarterEndDate).toBe(mockTimeframeQuarters[0].range[2]);
});
});
describe('headerSubItems', () => {
it('returns array of dates containing Months from timeframeItem', () => {
vm = createComponent({});
......@@ -46,7 +65,7 @@ describe('QuartersHeaderSubItemComponent', () => {
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
timeframeItem: mockTimeframeQuarters[0], // 2018 Apr May Jun
});
expect(vm.hasToday).toBe(false);
......
import Vue from 'vue';
import WeeksHeaderItemComponent from 'ee/roadmap/components/preset_weeks/weeks_header_item.vue';
import { getTimeframeForWeeksView } from 'ee/roadmap/utils/roadmap_utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeWeeks, mockShellWidth, mockItemWidth } from 'ee_spec/roadmap/mock_data';
import { mockTimeframeInitialDate, mockShellWidth, mockItemWidth } from 'ee_spec/roadmap/mock_data';
const mockTimeframeIndex = 0;
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
const createComponent = ({
timeframeIndex = mockTimeframeIndex,
......@@ -38,9 +40,6 @@ describe('WeeksHeaderItemComponent', () => {
const currentDate = new Date();
expect(vm.currentDate.getDate()).toBe(currentDate.getDate());
expect(vm.lastDayOfCurrentWeek.getDate()).toBe(
mockTimeframeWeeks[mockTimeframeIndex].getDate() + 7,
);
});
});
......@@ -53,20 +52,37 @@ describe('WeeksHeaderItemComponent', () => {
});
});
describe('lastDayOfCurrentWeek', () => {
it('returns date object representing last day of the week as set in `timeframeItem`', () => {
expect(vm.lastDayOfCurrentWeek.getDate()).toBe(
mockTimeframeWeeks[mockTimeframeIndex].getDate() + 7,
);
});
});
describe('timelineHeaderLabel', () => {
it('returns string containing Year, Month and Date for current timeline header item', () => {
it('returns string containing Year, Month and Date for first timeframe item of the entire timeframe', () => {
vm = createComponent({});
expect(vm.timelineHeaderLabel).toBe('2017 Dec 24');
expect(vm.timelineHeaderLabel).toBe('2017 Dec 17');
});
it('returns string containing Year, Month and Date for timeframe item when it is first week of the year', () => {
vm = createComponent({
timeframeIndex: 3,
timeframeItem: new Date(2019, 0, 6),
});
expect(vm.timelineHeaderLabel).toBe('2019 Jan 6');
});
it('returns string containing only Month and Date for current timeline header item when previous header contained Year', () => {
it('returns string containing only Month and Date timeframe item when it is somewhere in the middle of timeframe', () => {
vm = createComponent({
timeframeIndex: mockTimeframeIndex + 1,
timeframeItem: mockTimeframeWeeks[mockTimeframeIndex + 1],
});
expect(vm.timelineHeaderLabel).toBe('Dec 31');
expect(vm.timelineHeaderLabel).toBe('Dec 24');
});
});
......@@ -112,7 +128,7 @@ describe('WeeksHeaderItemComponent', () => {
const itemLabelEl = vm.$el.querySelector('.item-label');
expect(itemLabelEl).not.toBeNull();
expect(itemLabelEl.innerText.trim()).toBe('2017 Dec 24');
expect(itemLabelEl.innerText.trim()).toBe('2017 Dec 17');
});
});
});
import Vue from 'vue';
import WeeksHeaderSubItemComponent from 'ee/roadmap/components/preset_weeks/weeks_header_sub_item.vue';
import { getTimeframeForWeeksView } from 'ee/roadmap/utils/roadmap_utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeWeeks } from 'ee_spec/roadmap/mock_data';
import { mockTimeframeInitialDate } from 'ee_spec/roadmap/mock_data';
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
const createComponent = ({
currentDate = mockTimeframeWeeks[0],
......@@ -24,19 +27,19 @@ describe('MonthsHeaderSubItemComponent', () => {
vm.$destroy();
});
describe('data', () => {
it('sets prop `headerSubItems` with array of dates containing days of week from timeframeItem', () => {
vm = createComponent({});
describe('computed', () => {
describe('headerSubItems', () => {
it('returns `headerSubItems` 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);
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({});
......
......@@ -2,11 +2,14 @@ import Vue from 'vue';
import roadmapShellComponent from 'ee/roadmap/components/roadmap_shell.vue';
import eventHub from 'ee/roadmap/event_hub';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockEpic, mockTimeframeMonths, mockGroupId, mockScrollBarSize } from '../mock_data';
import { mockEpic, mockTimeframeInitialDate, mockGroupId, mockScrollBarSize } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = (
{ epics = [mockEpic], timeframe = mockTimeframeMonths, currentGroupId = mockGroupId },
......@@ -88,14 +91,29 @@ describe('RoadmapShellComponent', () => {
describe('handleScroll', () => {
beforeEach(() => {
spyOn(eventHub, '$emit');
document.body.innerHTML +=
'<div class="roadmap-container"><div id="roadmap-shell"></div></div>';
});
it('emits `epicsListScrolled` event via eventHub', () => {
vm.noScroll = false;
vm.handleScroll();
afterEach(() => {
document.querySelector('.roadmap-container').remove();
});
it('emits `epicsListScrolled` event via eventHub', done => {
const vmWithParentEl = createComponent({}, document.getElementById('roadmap-shell'));
spyOn(eventHub, '$emit');
Vue.nextTick()
.then(() => {
vmWithParentEl.noScroll = false;
vmWithParentEl.handleScroll();
expect(eventHub.$emit).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Object));
expect(eventHub.$emit).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Object));
vmWithParentEl.$destroy();
})
.then(done)
.catch(done.fail);
});
});
});
......
......@@ -2,11 +2,14 @@ import Vue from 'vue';
import roadmapTimelineSectionComponent from 'ee/roadmap/components/roadmap_timeline_section.vue';
import eventHub from 'ee/roadmap/event_hub';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockEpic, mockTimeframeMonths, mockShellWidth } from '../mock_data';
import { mockEpic, mockTimeframeInitialDate, mockShellWidth } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
......
......@@ -2,10 +2,14 @@ import Vue from 'vue';
import timelineTodayIndicatorComponent from 'ee/roadmap/components/timeline_today_indicator.vue';
import eventHub from 'ee/roadmap/event_hub';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeMonths } from '../mock_data';
import { mockTimeframeInitialDate } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const mockCurrentDate = new Date(
mockTimeframeMonths[0].getFullYear(),
......@@ -53,29 +57,33 @@ describe('TimelineTodayIndicatorComponent', () => {
const stylesObj = vm.todayBarStyles;
expect(stylesObj.height).toBe('120px');
expect(stylesObj.left).toBe('48%');
expect(stylesObj.left).toBe('50%');
expect(vm.todayBarReady).toBe(true);
});
});
});
describe('mounted', () => {
it('binds `epicsListRendered` event listener via eventHub', () => {
it('binds `epicsListRendered`, `epicsListScrolled` and `refreshTimeline` event listeners via eventHub', () => {
spyOn(eventHub, '$on');
const vmX = createComponent({});
expect(eventHub.$on).toHaveBeenCalledWith('epicsListRendered', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('refreshTimeline', jasmine.any(Function));
vmX.$destroy();
});
});
describe('beforeDestroy', () => {
it('unbinds `epicsListRendered` event listener via eventHub', () => {
it('unbinds `epicsListRendered`, `epicsListScrolled` and `refreshTimeline` event listeners via eventHub', () => {
spyOn(eventHub, '$off');
const vmX = createComponent({});
vmX.$destroy();
expect(eventHub.$off).toHaveBeenCalledWith('epicsListRendered', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('refreshTimeline', jasmine.any(Function));
});
});
......
import Vue from 'vue';
import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeMonths, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
import { mockTimeframeInitialDate, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
......@@ -114,17 +118,6 @@ describe('MonthsPresetMixin', () => {
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, {
......@@ -132,7 +125,7 @@ describe('MonthsPresetMixin', () => {
}),
});
expect(vm.getTimelineBarStartOffsetForMonths()).toContain('left: 48');
expect(vm.getTimelineBarStartOffsetForMonths()).toContain('left: 50%');
});
});
......@@ -147,7 +140,7 @@ describe('MonthsPresetMixin', () => {
}),
});
expect(Math.floor(vm.getTimelineBarWidthForMonths())).toBe(492);
expect(Math.floor(vm.getTimelineBarWidthForMonths())).toBe(637);
});
});
});
......
import Vue from 'vue';
import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import { getTimeframeForQuartersView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeQuarters, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
import { mockTimeframeInitialDate, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
const createComponent = ({
presetType = PRESET_TYPES.QUARTERS,
......@@ -114,17 +118,6 @@ describe('QuartersPresetMixin', () => {
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, {
......@@ -147,7 +140,7 @@ describe('QuartersPresetMixin', () => {
}),
});
expect(Math.floor(vm.getTimelineBarWidthForQuarters())).toBe(282);
expect(Math.floor(vm.getTimelineBarWidthForQuarters())).toBe(240);
});
});
});
......
import Vue from 'vue';
import roadmapTimelineSectionComponent from 'ee/roadmap/components/roadmap_timeline_section.vue';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockEpic, mockTimeframeMonths, mockShellWidth, mockScrollBarSize } from '../mock_data';
import {
mockEpic,
mockTimeframeInitialDate,
mockShellWidth,
mockScrollBarSize,
} from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
......@@ -52,7 +60,7 @@ describe('SectionMixin', () => {
describe('sectionItemWidth', () => {
it('returns calculated item width based on sectionShellWidth and timeframe size', () => {
expect(vm.sectionItemWidth).toBe(240);
expect(vm.sectionItemWidth).toBe(210);
});
});
......
import Vue from 'vue';
import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import { getTimeframeForWeeksView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeWeeks, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
import { mockTimeframeInitialDate, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
const createComponent = ({
presetType = PRESET_TYPES.WEEKS,
......@@ -59,7 +63,7 @@ describe('WeeksPresetMixin', () => {
vm = createComponent({});
const lastDayOfWeek = vm.getLastDayOfWeek(mockTimeframeWeeks[0]);
expect(lastDayOfWeek.getDate()).toBe(30);
expect(lastDayOfWeek.getDate()).toBe(23);
expect(lastDayOfWeek.getMonth()).toBe(11);
expect(lastDayOfWeek.getFullYear()).toBe(2017);
});
......@@ -95,14 +99,6 @@ describe('WeeksPresetMixin', () => {
});
});
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({
......@@ -133,19 +129,6 @@ describe('WeeksPresetMixin', () => {
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, {
......@@ -153,7 +136,7 @@ describe('WeeksPresetMixin', () => {
}),
});
expect(vm.getTimelineBarStartOffsetForWeeks()).toContain('left: 60');
expect(vm.getTimelineBarStartOffsetForWeeks()).toContain('left: 51');
});
});
......@@ -168,7 +151,7 @@ describe('WeeksPresetMixin', () => {
}),
});
expect(Math.floor(vm.getTimelineBarWidthForWeeks())).toBe(1600);
expect(Math.floor(vm.getTimelineBarWidthForWeeks())).toBe(1611);
});
});
});
......
import {
getTimeframeForQuartersView,
getTimeframeForMonthsView,
getTimeframeForWeeksView,
} from 'ee/roadmap/utils/roadmap_utils';
export const mockScrollBarSize = 15;
export const mockGroupId = 2;
......@@ -12,15 +6,85 @@ export const mockShellWidth = 2000;
export const mockItemWidth = 180;
export const mockSortedBy = 'start_date_asc';
export const basePath = '/groups/gitlab-org/-/epics.json';
export const epicsPath = '/groups/gitlab-org/-/epics.json?start_date=2017-11-1&end_date=2018-4-30';
export const mockNewEpicEndpoint = '/groups/gitlab-org/-/epics';
export const mockSvgPath = '/foo/bar.svg';
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 mockTimeframeInitialDate = new Date(2018, 0, 1);
export const mockTimeframeQuartersPrepend = [
{
year: 2016,
quarterSequence: 4,
range: [new Date(2016, 9, 1), new Date(2016, 10, 1), new Date(2016, 11, 31)],
},
{
year: 2017,
quarterSequence: 1,
range: [new Date(2017, 0, 1), new Date(2017, 1, 1), new Date(2017, 2, 31)],
},
{
year: 2017,
quarterSequence: 2,
range: [new Date(2017, 3, 1), new Date(2017, 4, 1), new Date(2017, 5, 30)],
},
];
export const mockTimeframeQuartersAppend = [
{
year: 2019,
quarterSequence: 2,
range: [new Date(2019, 3, 1), new Date(2019, 4, 1), new Date(2019, 5, 30)],
},
{
year: 2019,
quarterSequence: 3,
range: [new Date(2019, 6, 1), new Date(2019, 7, 1), new Date(2019, 8, 30)],
},
{
year: 2019,
quarterSequence: 4,
range: [new Date(2019, 9, 1), new Date(2019, 10, 1), new Date(2019, 11, 31)],
},
];
export const mockTimeframeMonthsPrepend = [
new Date(2017, 4, 1),
new Date(2017, 5, 1),
new Date(2017, 6, 1),
new Date(2017, 7, 1),
new Date(2017, 8, 1),
new Date(2017, 9, 1),
];
export const mockTimeframeMonthsAppend = [
new Date(2018, 6, 1),
new Date(2018, 7, 1),
new Date(2018, 8, 1),
new Date(2018, 9, 1),
new Date(2018, 10, 30),
];
export const mockTimeframeWeeksPrepend = [
new Date(2017, 10, 5),
new Date(2017, 10, 12),
new Date(2017, 10, 19),
new Date(2017, 10, 26),
new Date(2017, 11, 3),
new Date(2017, 11, 10),
];
export const mockTimeframeWeeksAppend = [
new Date(2018, 0, 28),
new Date(2018, 1, 4),
new Date(2018, 1, 11),
new Date(2018, 1, 18),
new Date(2018, 1, 25),
new Date(2018, 2, 4),
];
export const mockEpic = {
id: 1,
......@@ -188,3 +252,22 @@ export const rawEpics = [
web_url: '/groups/gitlab-org/marketing/-/epics/22',
},
];
export const mockUnsortedEpics = [
{
startDate: new Date(2017, 2, 12),
endDate: new Date(2017, 7, 20),
},
{
startDate: new Date(2015, 5, 8),
endDate: new Date(2016, 3, 1),
},
{
startDate: new Date(2019, 4, 12),
endDate: new Date(2019, 7, 30),
},
{
startDate: new Date(2014, 3, 17),
endDate: new Date(2015, 7, 15),
},
];
import axios from '~/lib/utils/axios_utils';
import RoadmapService from 'ee/roadmap/service/roadmap_service';
import { epicsPath } from '../mock_data';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import { basePath, epicsPath, mockTimeframeInitialDate } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
describe('RoadmapService', () => {
let service;
beforeEach(() => {
service = new RoadmapService(epicsPath);
service = new RoadmapService({
initialEpicsPath: epicsPath,
epicsState: 'all',
filterQueryString: '',
basePath,
});
});
describe('getEpics', () => {
......@@ -15,7 +25,30 @@ describe('RoadmapService', () => {
spyOn(axios, 'get').and.stub();
service.getEpics();
expect(axios.get).toHaveBeenCalledWith(service.epicsPath);
expect(axios.get).toHaveBeenCalledWith(
'/groups/gitlab-org/-/epics.json?start_date=2017-11-1&end_date=2018-4-30',
);
});
});
describe('getEpicsForTimeframe', () => {
it('calls `getEpicsPathForPreset` to construct epics path', () => {
const getEpicsPathSpy = spyOnDependency(RoadmapService, 'getEpicsPathForPreset');
spyOn(axios, 'get').and.stub();
const presetType = PRESET_TYPES.MONTHS;
service.getEpicsForTimeframe(presetType, mockTimeframeMonths);
expect(getEpicsPathSpy).toHaveBeenCalledWith(
jasmine.objectContaining({
timeframe: mockTimeframeMonths,
epicsState: 'all',
filterQueryString: '',
basePath,
presetType,
}),
);
});
});
});
import RoadmapStore from 'ee/roadmap/store/roadmap_store';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import { mockGroupId, mockTimeframeMonths, rawEpics } from '../mock_data';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES, EXTEND_AS } from 'ee/roadmap/constants';
import { mockGroupId, mockTimeframeInitialDate, rawEpics, mockSortedBy } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
describe('RoadmapStore', () => {
let store;
beforeEach(() => {
store = new RoadmapStore(mockGroupId, mockTimeframeMonths, PRESET_TYPES.MONTHS);
store = new RoadmapStore({
groupId: mockGroupId,
timeframe: mockTimeframeMonths,
presetType: PRESET_TYPES.MONTHS,
sortedBy: mockSortedBy,
});
});
describe('constructor', () => {
it('initializes default state', () => {
expect(store.state).toBeDefined();
expect(Array.isArray(store.state.epics)).toBe(true);
expect(Array.isArray(store.state.epicIds)).toBe(true);
expect(store.state.currentGroupId).toBe(mockGroupId);
expect(store.state.timeframe).toBe(mockTimeframeMonths);
expect(store.presetType).toBe(PRESET_TYPES.MONTHS);
expect(store.sortedBy).toBe(mockSortedBy);
expect(store.timeframeStartDate).toBeDefined();
expect(store.timeframeEndDate).toBeDefined();
});
......@@ -23,9 +34,52 @@ describe('RoadmapStore', () => {
describe('setEpics', () => {
it('sets Epics list to state while filtering out Epics with invalid dates', () => {
spyOn(RoadmapStore, 'filterInvalidEpics').and.callThrough();
store.setEpics(rawEpics);
expect(store.getEpics().length).toBe(rawEpics.length - 2); // 2 epics have invalid dates
expect(RoadmapStore.filterInvalidEpics).toHaveBeenCalledWith(
jasmine.objectContaining({
timeframeStartDate: store.timeframeStartDate,
timeframeEndDate: store.timeframeEndDate,
state: store.state,
epics: rawEpics,
}),
);
expect(store.getEpics().length).toBe(rawEpics.length - 1); // There is only 1 invalid epic
});
});
describe('addEpics', () => {
beforeEach(() => {
store.setEpics(rawEpics);
});
it('adds new Epics to the epics list within state while filtering out existing epics or epics with invalid dates and sorts the list based on `sortedBy` value', () => {
spyOn(RoadmapStore, 'filterInvalidEpics');
const sortEpicsSpy = spyOnDependency(RoadmapStore, 'sortEpics').and.stub();
const newEpic = Object.assign({}, rawEpics[0], {
id: 999,
});
store.addEpics([newEpic]);
expect(RoadmapStore.filterInvalidEpics).toHaveBeenCalledWith(
jasmine.objectContaining({
timeframeStartDate: store.timeframeStartDate,
timeframeEndDate: store.timeframeEndDate,
state: store.state,
epics: [newEpic],
newEpic: true,
}),
);
expect(sortEpicsSpy).toHaveBeenCalledWith(jasmine.any(Object), mockSortedBy);
// rawEpics contain 2 invalid epics but now that we added 1
// new epic, length will be `rawEpics.length - 1 (invalid) + 1 (new epic)`
expect(store.getEpics().length).toBe(rawEpics.length);
});
});
......@@ -41,12 +95,98 @@ describe('RoadmapStore', () => {
});
});
describe('extendTimeframe', () => {
beforeEach(() => {
store.setEpics(rawEpics);
store.state.timeframe = [];
});
it('calls `extendTimeframeForPreset` and prepends items to the timeframe when called with `extendAs` param as `prepend`', () => {
const extendTimeframeSpy = spyOnDependency(
RoadmapStore,
'extendTimeframeForPreset',
).and.returnValue([]);
spyOn(store.state.timeframe, 'unshift');
spyOn(store, 'initTimeframeThreshold');
spyOn(RoadmapStore, 'processEpicDates');
const itemCount = store.extendTimeframe(EXTEND_AS.PREPEND).length;
expect(extendTimeframeSpy).toHaveBeenCalledWith(jasmine.any(Object));
expect(store.state.timeframe.unshift).toHaveBeenCalled();
expect(store.initTimeframeThreshold).toHaveBeenCalled();
expect(RoadmapStore.processEpicDates).toHaveBeenCalled();
expect(itemCount).toBe(0);
});
it('calls `extendTimeframeForPreset` and appends items to the timeframe when called with `extendAs` param as `append`', () => {
const extendTimeframeSpy = spyOnDependency(
RoadmapStore,
'extendTimeframeForPreset',
).and.returnValue([]);
spyOn(store.state.timeframe, 'push');
spyOn(store, 'initTimeframeThreshold');
spyOn(RoadmapStore, 'processEpicDates');
const itemCount = store.extendTimeframe(EXTEND_AS.APPEND).length;
expect(extendTimeframeSpy).toHaveBeenCalledWith(jasmine.any(Object));
expect(store.state.timeframe.push).toHaveBeenCalled();
expect(store.initTimeframeThreshold).toHaveBeenCalled();
expect(RoadmapStore.processEpicDates).toHaveBeenCalled();
expect(itemCount).toBe(0);
});
});
describe('filterInvalidEpics', () => {
it('returns formatted epics list by filtering out epics with invalid dates', () => {
spyOn(RoadmapStore, 'formatEpicDetails').and.callThrough();
const epicsList = RoadmapStore.filterInvalidEpics({
epics: rawEpics,
timeframeStartDate: store.timeframeStartDate,
timeframeEndDate: store.timeframeEndDate,
state: store.state,
});
expect(RoadmapStore.formatEpicDetails).toHaveBeenCalled();
expect(epicsList.length).toBe(rawEpics.length - 1); // There are is only 1 invalid epic
});
it('returns formatted epics list by filtering out existing epics', () => {
store.setEpics(rawEpics);
spyOn(RoadmapStore, 'formatEpicDetails').and.callThrough();
const newEpic = Object.assign({}, rawEpics[0]);
const epicsList = RoadmapStore.filterInvalidEpics({
epics: [newEpic],
timeframeStartDate: store.timeframeStartDate,
timeframeEndDate: store.timeframeEndDate,
state: store.state,
});
expect(RoadmapStore.formatEpicDetails).toHaveBeenCalled();
expect(epicsList.length).toBe(0); // No epics are eligible to be added
});
});
describe('formatEpicDetails', () => {
const rawEpic = rawEpics[0];
it('returns formatted Epic object from raw Epic object', () => {
const epic = RoadmapStore.formatEpicDetails(rawEpic);
spyOn(RoadmapStore, 'processEpicDates');
const epic = RoadmapStore.formatEpicDetails(
rawEpic,
store.timeframeStartDate,
store.timeframeEndDate,
);
expect(RoadmapStore.processEpicDates).toHaveBeenCalled();
expect(epic.id).toBe(rawEpic.id);
expect(epic.name).toBe(rawEpic.name);
expect(epic.groupId).toBe(rawEpic.group_id);
......
import {
getTimeframeForQuartersView,
extendTimeframeForQuartersView,
getTimeframeForMonthsView,
extendTimeframeForMonthsView,
getTimeframeForWeeksView,
extendTimeframeForWeeksView,
extendTimeframeForAvailableWidth,
getEpicsPathForPreset,
sortEpics,
} from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import {
mockTimeframeInitialDate,
mockTimeframeQuartersPrepend,
mockTimeframeQuartersAppend,
mockTimeframeMonthsPrepend,
mockTimeframeMonthsAppend,
mockTimeframeWeeksPrepend,
mockTimeframeWeeksAppend,
mockUnsortedEpics,
} from '../mock_data';
const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
describe('getTimeframeForQuartersView', () => {
let timeframe;
......@@ -14,8 +34,8 @@ describe('getTimeframeForQuartersView', () => {
timeframe = getTimeframeForQuartersView(new Date(2018, 0, 1));
});
it('returns timeframe with total of 6 quarters', () => {
expect(timeframe.length).toBe(6);
it('returns timeframe with total of 7 quarters', () => {
expect(timeframe.length).toBe(7);
});
it('each timeframe item has `quarterSequence`, `year` and `range` present', () => {
......@@ -26,15 +46,15 @@ describe('getTimeframeForQuartersView', () => {
expect(Array.isArray(timeframeItem.range)).toBe(true);
});
it('first timeframe item refers to quarter prior to current quarter', () => {
it('first timeframe item refers to 2 quarters 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
0: { month: 6, date: 1 }, // 1 Jul 2017
1: { month: 7, date: 1 }, // 1 Aug 2017
2: { month: 8, date: 30 }, // 30 Sep 2017
};
expect(timeframeItem.quarterSequence).toEqual(4);
expect(timeframeItem.quarterSequence).toEqual(3);
expect(timeframeItem.year).toEqual(2017);
timeframeItem.range.forEach((month, index) => {
expect(month.getFullYear()).toBe(2017);
......@@ -61,6 +81,44 @@ describe('getTimeframeForQuartersView', () => {
});
});
describe('extendTimeframeForQuartersView', () => {
it('returns extended timeframe into the past from current timeframe startDate', () => {
const initialDate = mockTimeframeQuarters[0].range[0];
const extendedTimeframe = extendTimeframeForQuartersView(initialDate, -9);
expect(extendedTimeframe.length).toBe(mockTimeframeQuartersPrepend.length);
extendedTimeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.year).toBe(mockTimeframeQuartersPrepend[index].year);
expect(timeframeItem.quarterSequence).toBe(
mockTimeframeQuartersPrepend[index].quarterSequence,
);
timeframeItem.range.forEach((rangeItem, j) => {
expect(rangeItem.getTime()).toBe(mockTimeframeQuartersPrepend[index].range[j].getTime());
});
});
});
it('returns extended timeframe into the future from current timeframe endDate', () => {
const initialDate = mockTimeframeQuarters[mockTimeframeQuarters.length - 1].range[2];
const extendedTimeframe = extendTimeframeForQuartersView(initialDate, 9);
expect(extendedTimeframe.length).toBe(mockTimeframeQuartersAppend.length);
extendedTimeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.year).toBe(mockTimeframeQuartersAppend[index].year);
expect(timeframeItem.quarterSequence).toBe(
mockTimeframeQuartersAppend[index].quarterSequence,
);
timeframeItem.range.forEach((rangeItem, j) => {
expect(rangeItem.getTime()).toBe(mockTimeframeQuartersAppend[index].range[j].getTime());
});
});
});
});
describe('getTimeframeForMonthsView', () => {
let timeframe;
......@@ -68,15 +126,15 @@ describe('getTimeframeForMonthsView', () => {
timeframe = getTimeframeForMonthsView(new Date(2018, 0, 1));
});
it('returns timeframe with total of 7 months', () => {
expect(timeframe.length).toBe(7);
it('returns timeframe with total of 8 months', () => {
expect(timeframe.length).toBe(8);
});
it('first timeframe item refers to month prior to current month', () => {
it('first timeframe item refers to 2 months prior to current month', () => {
const timeframeItem = timeframe[0];
const expectedMonth = {
year: 2017,
month: 11,
month: 10,
date: 1,
};
......@@ -99,6 +157,28 @@ describe('getTimeframeForMonthsView', () => {
});
});
describe('extendTimeframeForMonthsView', () => {
it('returns extended timeframe into the past from current timeframe startDate', () => {
const initialDate = mockTimeframeMonths[0];
const extendedTimeframe = extendTimeframeForMonthsView(initialDate, -6);
expect(extendedTimeframe.length).toBe(mockTimeframeMonthsPrepend.length);
extendedTimeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.getTime()).toBe(mockTimeframeMonthsPrepend[index].getTime());
});
});
it('returns extended timeframe into the future from current timeframe endDate', () => {
const initialDate = mockTimeframeMonths[mockTimeframeMonths.length - 1];
const extendedTimeframe = extendTimeframeForMonthsView(initialDate, 7);
expect(extendedTimeframe.length).toBe(mockTimeframeMonthsAppend.length);
extendedTimeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.getTime()).toBe(mockTimeframeMonthsAppend[index].getTime());
});
});
});
describe('getTimeframeForWeeksView', () => {
let timeframe;
......@@ -106,16 +186,16 @@ describe('getTimeframeForWeeksView', () => {
timeframe = getTimeframeForWeeksView(new Date(2018, 0, 1));
});
it('returns timeframe with total of 6 weeks', () => {
expect(timeframe.length).toBe(6);
it('returns timeframe with total of 7 weeks', () => {
expect(timeframe.length).toBe(7);
});
it('first timeframe item refers to week prior to current week', () => {
it('first timeframe item refers to 2 weeks prior to current week', () => {
const timeframeItem = timeframe[0];
const expectedMonth = {
year: 2017,
month: 11,
date: 24,
date: 17,
};
expect(timeframeItem.getFullYear()).toBe(expectedMonth.year);
......@@ -137,6 +217,67 @@ describe('getTimeframeForWeeksView', () => {
});
});
describe('extendTimeframeForWeeksView', () => {
it('returns extended timeframe into the past from current timeframe startDate', () => {
const extendedTimeframe = extendTimeframeForWeeksView(mockTimeframeWeeks[0], -6); // initialDate: 17 Dec 2017
expect(extendedTimeframe.length).toBe(mockTimeframeWeeksPrepend.length);
extendedTimeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.getTime()).toBe(mockTimeframeWeeksPrepend[index].getTime());
});
});
it('returns extended timeframe into the future from current timeframe endDate', () => {
const extendedTimeframe = extendTimeframeForWeeksView(
mockTimeframeWeeks[mockTimeframeWeeks.length - 1], // initialDate: 28 Jan 2018
6,
);
expect(extendedTimeframe.length).toBe(mockTimeframeWeeksAppend.length);
extendedTimeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.getTime()).toBe(mockTimeframeWeeksAppend[index].getTime());
});
});
});
describe('extendTimeframeForAvailableWidth', () => {
let timeframe;
let timeframeStart;
let timeframeEnd;
beforeEach(() => {
timeframe = mockTimeframeMonths.slice();
[timeframeStart] = timeframe;
timeframeEnd = timeframe[timeframe.length - 1];
});
it('should not extend `timeframe` when availableTimeframeWidth is small enough to force horizontal scrollbar to show up', () => {
extendTimeframeForAvailableWidth({
availableTimeframeWidth: 100,
presetType: PRESET_TYPES.MONTHS,
timeframe,
timeframeStart,
timeframeEnd,
});
expect(timeframe.length).toBe(mockTimeframeMonths.length);
});
it('should extend `timeframe` when availableTimeframeWidth is large enough that it can fit more timeframe items to show up horizontal scrollbar', () => {
extendTimeframeForAvailableWidth({
availableTimeframeWidth: 2000,
presetType: PRESET_TYPES.MONTHS,
timeframe,
timeframeStart,
timeframeEnd,
});
expect(timeframe.length).toBe(16);
expect(timeframe[0].getTime()).toBe(1498867200000); // 1 July 2017
expect(timeframe[timeframe.length - 1].getTime()).toBe(1540944000000); // 31 Oct 2018
});
});
describe('getEpicsPathForPreset', () => {
const basePath = '/groups/gitlab-org/-/epics.json';
const filterQueryString = 'scope=all&utf8=✓&state=opened&label_name[]=Bug';
......@@ -149,7 +290,7 @@ describe('getEpicsPathForPreset', () => {
presetType: PRESET_TYPES.QUARTERS,
});
expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-10-1&end_date=2019-3-31`);
expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-7-1&end_date=2019-3-31`);
});
it('returns epics path string based on provided basePath and timeframe for Months', () => {
......@@ -160,7 +301,7 @@ describe('getEpicsPathForPreset', () => {
presetType: PRESET_TYPES.MONTHS,
});
expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-12-1&end_date=2018-6-30`);
expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-11-1&end_date=2018-6-30`);
});
it('returns epics path string based on provided basePath and timeframe for Weeks', () => {
......@@ -171,7 +312,7 @@ describe('getEpicsPathForPreset', () => {
presetType: PRESET_TYPES.WEEKS,
});
expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-12-24&end_date=2018-2-3`);
expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-12-17&end_date=2018-2-3`);
});
it('returns epics path string while preserving filterQueryString', () => {
......@@ -184,7 +325,94 @@ describe('getEpicsPathForPreset', () => {
});
expect(epicsPath).toBe(
`${basePath}?state=all&start_date=2017-12-1&end_date=2018-6-30&scope=all&utf8=✓&state=opened&label_name[]=Bug`,
`${basePath}?state=all&start_date=2017-11-1&end_date=2018-6-30&scope=all&utf8=✓&state=opened&label_name[]=Bug`,
);
});
it('returns epics path string containing epicsState', () => {
const epicsState = 'opened';
const timeframe = getTimeframeForMonthsView(new Date(2018, 0, 1));
const epicsPath = getEpicsPathForPreset({
presetType: PRESET_TYPES.MONTHS,
basePath,
timeframe,
epicsState,
});
expect(epicsPath).toContain(`state=${epicsState}`);
});
});
describe('sortEpics', () => {
it('sorts epics list by startDate in ascending order when `sortedBy` param is `start_date_asc`', () => {
const epics = mockUnsortedEpics.slice();
const sortedOrder = [
new Date(2014, 3, 17),
new Date(2015, 5, 8),
new Date(2017, 2, 12),
new Date(2019, 4, 12),
];
sortEpics(epics, 'start_date_asc');
expect(epics.length).toBe(mockUnsortedEpics.length);
epics.forEach((epic, index) => {
expect(epic.startDate.getTime()).toBe(sortedOrder[index].getTime());
});
});
it('sorts epics list by startDate in descending order when `sortedBy` param is `start_date_desc`', () => {
const epics = mockUnsortedEpics.slice();
const sortedOrder = [
new Date(2019, 4, 12),
new Date(2017, 2, 12),
new Date(2015, 5, 8),
new Date(2014, 3, 17),
];
sortEpics(epics, 'start_date_desc');
expect(epics.length).toBe(mockUnsortedEpics.length);
epics.forEach((epic, index) => {
expect(epic.startDate.getTime()).toBe(sortedOrder[index].getTime());
});
});
it('sorts epics list by endDate in ascending order when `sortedBy` param is `end_date_asc`', () => {
const epics = mockUnsortedEpics.slice();
const sortedOrder = [
new Date(2015, 7, 15),
new Date(2016, 3, 1),
new Date(2017, 7, 20),
new Date(2019, 7, 30),
];
sortEpics(epics, 'end_date_asc');
expect(epics.length).toBe(mockUnsortedEpics.length);
epics.forEach((epic, index) => {
expect(epic.endDate.getTime()).toBe(sortedOrder[index].getTime());
});
});
it('sorts epics list by endDate in descending order when `sortedBy` param is `end_date_desc`', () => {
const epics = mockUnsortedEpics.slice();
const sortedOrder = [
new Date(2019, 7, 30),
new Date(2017, 7, 20),
new Date(2016, 3, 1),
new Date(2015, 7, 15),
];
sortEpics(epics, 'end_date_desc');
expect(epics.length).toBe(mockUnsortedEpics.length);
epics.forEach((epic, index) => {
expect(epic.endDate.getTime()).toBe(sortedOrder[index].getTime());
});
});
});
......@@ -4538,22 +4538,10 @@ msgstr ""
msgid "GroupRoadmap|The roadmap shows the progress of your epics along a timeline"
msgstr ""
msgid "GroupRoadmap|To view the roadmap, add a start or due 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}."
msgid "GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups; from %{startDate} to %{endDate}."
msgstr ""
msgid "GroupRoadmap|To view the roadmap, add a start or due 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}."
msgstr ""
msgid "GroupRoadmap|To view the roadmap, add a start or due 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}."
msgstr ""
msgid "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}."
msgstr ""
msgid "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}."
msgstr ""
msgid "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}."
msgid "GroupRoadmap|To widen your search, change or remove filters; from %{startDate} to %{endDate}."
msgstr ""
msgid "GroupRoadmap|Until %{dateWord}"
......
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