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'; ...@@ -6,6 +6,9 @@ import { s__ } from '~/locale';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import epicsListEmpty from './epics_list_empty.vue'; import epicsListEmpty from './epics_list_empty.vue';
import roadmapShell from './roadmap_shell.vue'; import roadmapShell from './roadmap_shell.vue';
import eventHub from '../event_hub';
import { EXTEND_AS } from '../constants';
export default { export default {
components: { components: {
...@@ -96,6 +99,28 @@ export default { ...@@ -96,6 +99,28 @@ export default {
Flash(s__('GroupRoadmap|Something went wrong while fetching epics')); 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 * Roadmap view works with absolute sizing and positioning
* of following child components of RoadmapShell; * of following child components of RoadmapShell;
...@@ -117,6 +142,49 @@ export default { ...@@ -117,6 +142,49 @@ export default {
this.isLoading = false; this.isLoading = false;
}, 200)(); }, 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> </script>
...@@ -135,6 +203,8 @@ export default { ...@@ -135,6 +203,8 @@ export default {
:epics="epics" :epics="epics"
:timeframe="timeframe" :timeframe="timeframe"
:current-group-id="currentGroupId" :current-group-id="currentGroupId"
@onScrollToStart="handleScrollToExtend"
@onScrollToEnd="handleScrollToExtend"
/> />
<epics-list-empty <epics-list-empty
v-if="isEpicsListEmpty" v-if="isEpicsListEmpty"
......
<script> <script>
import _ from 'underscore';
import epicItemDetails from './epic_item_details.vue'; import epicItemDetails from './epic_item_details.vue';
import epicItemTimeline from './epic_item_timeline.vue'; import epicItemTimeline from './epic_item_timeline.vue';
import { EPIC_HIGHLIGHT_REMOVE_AFTER } from '../constants';
export default { export default {
components: { components: {
epicItemDetails, epicItemDetails,
...@@ -33,11 +37,36 @@ export default { ...@@ -33,11 +37,36 @@ export default {
required: true, 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> </script>
<template> <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-details :epic="epic" :current-group-id="currentGroupId" />
<epic-item-timeline <epic-item-timeline
v-for="(timeframeItem, index) in timeframe" v-for="(timeframeItem, index) in timeframe"
......
...@@ -5,13 +5,9 @@ import QuartersPresetMixin from '../mixins/quarters_preset_mixin'; ...@@ -5,13 +5,9 @@ import QuartersPresetMixin from '../mixins/quarters_preset_mixin';
import MonthsPresetMixin from '../mixins/months_preset_mixin'; import MonthsPresetMixin from '../mixins/months_preset_mixin';
import WeeksPresetMixin from '../mixins/weeks_preset_mixin'; import WeeksPresetMixin from '../mixins/weeks_preset_mixin';
import { import eventHub from '../event_hub';
EPIC_DETAILS_CELL_WIDTH,
TIMELINE_CELL_MIN_WIDTH, import { EPIC_DETAILS_CELL_WIDTH, TIMELINE_CELL_MIN_WIDTH, PRESET_TYPES } from '../constants';
TIMELINE_END_OFFSET_FULL,
TIMELINE_END_OFFSET_HALF,
PRESET_TYPES,
} from '../constants';
export default { export default {
directives: { directives: {
...@@ -66,6 +62,12 @@ export default { ...@@ -66,6 +62,12 @@ export default {
this.renderTimelineBar(); this.renderTimelineBar();
}, },
}, },
mounted() {
eventHub.$on('refreshTimeline', this.renderTimelineBar);
},
beforeDestroy() {
eventHub.$off('refreshTimeline', this.renderTimelineBar);
},
methods: { methods: {
/** /**
* Gets cell width based on total number months for * Gets cell width based on total number months for
...@@ -89,49 +91,6 @@ export default { ...@@ -89,49 +91,6 @@ export default {
} }
return false; 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 * Renders timeline bar only if current
* timeframe item has startDate for the epic. * timeframe item has startDate for the epic.
...@@ -160,9 +119,7 @@ export default { ...@@ -160,9 +119,7 @@ export default {
:href="epic.webUrl" :href="epic.webUrl"
:class="{ :class="{
'start-date-undefined': epic.startDateUndefined, 'start-date-undefined': epic.startDateUndefined,
'start-date-outside': epic.startDateOutOfRange,
'end-date-undefined': epic.endDateUndefined, 'end-date-undefined': epic.endDateUndefined,
'end-date-outside': epic.endDateOutOfRange,
}" }"
:style="timelineBarStyles" :style="timelineBarStyles"
class="timeline-bar" class="timeline-bar"
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility'; 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'; import NewEpic from '../../epics/new_epic/components/new_epic.vue';
...@@ -82,12 +82,12 @@ export default { ...@@ -82,12 +82,12 @@ export default {
}, },
subMessage() { subMessage() {
if (this.hasFiltersApplied) { if (this.hasFiltersApplied) {
return sprintf(PRESET_DEFAULTS[this.presetType].emptyStateWithFilters, { return sprintf(emptyStateWithFilters, {
startDate: this.timeframeRange.startDate, startDate: this.timeframeRange.startDate,
endDate: this.timeframeRange.endDate, endDate: this.timeframeRange.endDate,
}); });
} }
return sprintf(PRESET_DEFAULTS[this.presetType].emptyStateDefault, { return sprintf(emptyStateDefault, {
startDate: this.timeframeRange.startDate, startDate: this.timeframeRange.startDate,
endDate: this.timeframeRange.endDate, endDate: this.timeframeRange.endDate,
}); });
......
...@@ -3,6 +3,8 @@ import eventHub from '../event_hub'; ...@@ -3,6 +3,8 @@ import eventHub from '../event_hub';
import SectionMixin from '../mixins/section_mixin'; import SectionMixin from '../mixins/section_mixin';
import { TIMELINE_CELL_MIN_WIDTH } from '../constants';
import epicItem from './epic_item.vue'; import epicItem from './epic_item.vue';
export default { export default {
...@@ -72,12 +74,18 @@ export default { ...@@ -72,12 +74,18 @@ export default {
}, },
mounted() { mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll); eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$on('refreshTimeline', () => {
this.initEmptyRow(false);
});
this.$nextTick(() => { this.$nextTick(() => {
this.initMounted(); this.initMounted();
}); });
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll); eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$off('refreshTimeline', () => {
this.initEmptyRow(false);
});
}, },
methods: { methods: {
initMounted() { initMounted() {
...@@ -104,7 +112,7 @@ export default { ...@@ -104,7 +112,7 @@ export default {
* based on height of available list items and sets it to component * based on height of available list items and sets it to component
* props. * props.
*/ */
initEmptyRow() { initEmptyRow(showEmptyRow = false) {
const children = this.$children; const children = this.$children;
let approxChildrenHeight = children[0].$el.clientHeight * this.epics.length; let approxChildrenHeight = children[0].$el.clientHeight * this.epics.length;
...@@ -122,18 +130,16 @@ export default { ...@@ -122,18 +130,16 @@ export default {
this.emptyRowHeight = this.shellHeight - approxChildrenHeight; this.emptyRowHeight = this.shellHeight - approxChildrenHeight;
this.showEmptyRow = true; this.showEmptyRow = true;
} else { } else {
this.showEmptyRow = showEmptyRow;
this.showBottomShadow = true; this.showBottomShadow = true;
} }
}, },
/** /**
* `clientWidth` is full width of list section, and we need to * Scroll timeframe to the right of the timeline
* scroll up to 60% of the view where today indicator is present. * by half the column size
*
* Reason for 60% is that "today" always falls in the middle of timeframe range.
*/ */
scrollToTodayIndicator() { scrollToTodayIndicator() {
const uptoTodayIndicator = Math.ceil((this.$el.clientWidth * 60) / 100); this.$el.parentElement.scrollBy(TIMELINE_CELL_MIN_WIDTH / 2, 0);
this.$el.scrollTo(uptoTodayIndicator, 0);
}, },
handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) { handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) {
this.showBottomShadow = Math.ceil(scrollTop) + clientHeight < scrollHeight; this.showBottomShadow = Math.ceil(scrollTop) + clientHeight < scrollHeight;
......
...@@ -29,8 +29,6 @@ export default { ...@@ -29,8 +29,6 @@ export default {
return { return {
currentDate, currentDate,
quarterBeginDate: this.timeframeItem.range[0],
quarterEndDate: this.timeframeItem.range[2],
}; };
}, },
computed: { computed: {
...@@ -39,6 +37,12 @@ export default { ...@@ -39,6 +37,12 @@ export default {
width: `${this.itemWidth}px`, width: `${this.itemWidth}px`,
}; };
}, },
quarterBeginDate() {
return this.timeframeItem.range[0];
},
quarterEndDate() {
return this.timeframeItem.range[2];
},
timelineHeaderLabel() { timelineHeaderLabel() {
const { quarterSequence } = this.timeframeItem; const { quarterSequence } = this.timeframeItem;
if (quarterSequence === 1 || (this.timeframeIndex === 0 && quarterSequence !== 1)) { if (quarterSequence === 1 || (this.timeframeIndex === 0 && quarterSequence !== 1)) {
......
...@@ -20,13 +20,13 @@ export default { ...@@ -20,13 +20,13 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
quarterBeginDate: this.timeframeItem.range[0],
quarterEndDate: this.timeframeItem.range[2],
};
},
computed: { computed: {
quarterBeginDate() {
return this.timeframeItem.range[0];
},
quarterEndDate() {
return this.timeframeItem.range[2];
},
headerSubItems() { headerSubItems() {
return this.timeframeItem.range; return this.timeframeItem.range;
}, },
......
...@@ -29,12 +29,8 @@ export default { ...@@ -29,12 +29,8 @@ export default {
const currentDate = new Date(); const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0); currentDate.setHours(0, 0, 0, 0);
const lastDayOfCurrentWeek = new Date(this.timeframeItem.getTime());
lastDayOfCurrentWeek.setDate(lastDayOfCurrentWeek.getDate() + 7);
return { return {
currentDate, currentDate,
lastDayOfCurrentWeek,
}; };
}, },
computed: { computed: {
...@@ -43,19 +39,35 @@ export default { ...@@ -43,19 +39,35 @@ export default {
width: `${this.itemWidth}px`, width: `${this.itemWidth}px`,
}; };
}, },
lastDayOfCurrentWeek() {
const lastDayOfCurrentWeek = new Date(this.timeframeItem.getTime());
lastDayOfCurrentWeek.setDate(lastDayOfCurrentWeek.getDate() + 7);
return lastDayOfCurrentWeek;
},
timelineHeaderLabel() { 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( return `${this.timeframeItem.getFullYear()} ${monthInWords(
this.timeframeItem, this.timeframeItem,
true, true,
)} ${this.timeframeItem.getDate()}`; )} ${timeframeItemDate}`;
} }
return `${monthInWords(this.timeframeItem, true)} ${this.timeframeItem.getDate()}`;
return `${monthInWords(this.timeframeItem, true)} ${timeframeItemDate}`;
}, },
timelineHeaderClass() { 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'; return 'label-dark label-bold';
} else if (this.currentDate < this.lastDayOfCurrentWeek) { } else if (currentDateTime < lastDayOfCurrentWeekTime) {
return 'label-dark'; return 'label-dark';
} }
return ''; return '';
......
...@@ -18,7 +18,8 @@ export default { ...@@ -18,7 +18,8 @@ export default {
required: true, required: true,
}, },
}, },
data() { computed: {
headerSubItems() {
const timeframeItem = new Date(this.timeframeItem.getTime()); const timeframeItem = new Date(this.timeframeItem.getTime());
const headerSubItems = new Array(7) const headerSubItems = new Array(7)
.fill() .fill()
...@@ -31,15 +32,12 @@ export default { ...@@ -31,15 +32,12 @@ export default {
), ),
); );
return { return headerSubItems;
headerSubItems,
};
}, },
computed: {
hasToday() { hasToday() {
return ( return (
this.currentDate >= this.headerSubItems[0] && this.currentDate.getTime() >= this.headerSubItems[0].getTime() &&
this.currentDate <= this.headerSubItems[this.headerSubItems.length - 1] this.currentDate.getTime() <= this.headerSubItems[this.headerSubItems.length - 1].getTime()
); );
}, },
}, },
......
<script> <script>
import bp from '~/breakpoints'; 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 eventHub from '../event_hub';
import epicsListSection from './epics_list_section.vue'; import epicsListSection from './epics_list_section.vue';
...@@ -34,6 +35,7 @@ export default { ...@@ -34,6 +35,7 @@ export default {
shellWidth: 0, shellWidth: 0,
shellHeight: 0, shellHeight: 0,
noScroll: false, noScroll: false,
timeframeStartOffset: 0,
}; };
}, },
computed: { computed: {
...@@ -61,6 +63,11 @@ export default { ...@@ -61,6 +63,11 @@ export default {
this.shellHeight = window.innerHeight - this.$el.offsetTop; this.shellHeight = window.innerHeight - this.$el.offsetTop;
this.noScroll = this.shellHeight > EPIC_ITEM_HEIGHT * (this.epics.length + 1); this.noScroll = this.shellHeight > EPIC_ITEM_HEIGHT * (this.epics.length + 1);
this.shellWidth = this.$el.parentElement.clientWidth + this.getWidthOffset(); 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 { ...@@ -70,6 +77,22 @@ export default {
}, },
handleScroll() { handleScroll() {
const { scrollTop, scrollLeft, clientHeight, scrollHeight } = this.$el; 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 }); eventHub.$emit('epicsListScrolled', { scrollTop, scrollLeft, clientHeight, scrollHeight });
}, },
}, },
...@@ -84,6 +107,7 @@ export default { ...@@ -84,6 +107,7 @@ export default {
@scroll="handleScroll" @scroll="handleScroll"
> >
<roadmap-timeline-section <roadmap-timeline-section
ref="roadmapTimeline"
:preset-type="presetType" :preset-type="presetType"
:epics="epics" :epics="epics"
:timeframe="timeframe" :timeframe="timeframe"
......
...@@ -29,10 +29,12 @@ export default { ...@@ -29,10 +29,12 @@ export default {
mounted() { mounted() {
eventHub.$on('epicsListRendered', this.handleEpicsListRender); eventHub.$on('epicsListRendered', this.handleEpicsListRender);
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll); eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$on('refreshTimeline', this.handleEpicsListRender);
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('epicsListRendered', this.handleEpicsListRender); eventHub.$off('epicsListRendered', this.handleEpicsListRender);
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll); eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$off('refreshTimeline', this.handleEpicsListRender);
}, },
methods: { methods: {
/** /**
...@@ -40,7 +42,7 @@ export default { ...@@ -40,7 +42,7 @@ export default {
* and renders vertical line over the area where * and renders vertical line over the area where
* today falls in current timeline * today falls in current timeline
*/ */
handleEpicsListRender({ height }) { handleEpicsListRender({ height, todayBarReady }) {
let left = 0; let left = 0;
// Get total days of current timeframe Item and then // Get total days of current timeframe Item and then
...@@ -68,7 +70,7 @@ export default { ...@@ -68,7 +70,7 @@ export default {
height: `${height + 20}px`, height: `${height + 20}px`,
left: `${left}%`, left: `${left}%`,
}; };
this.todayBarReady = true; this.todayBarReady = todayBarReady === undefined ? true : todayBarReady;
}, },
handleEpicsListScroll() { handleEpicsListScroll() {
const indicatorX = this.$el.getBoundingClientRect().x; const indicatorX = this.$el.getBoundingClientRect().x;
......
import { s__ } from '~/locale'; import { s__ } from '~/locale';
export const TIMEFRAME_LENGTH = 6;
export const EPIC_DETAILS_CELL_WIDTH = 320; export const EPIC_DETAILS_CELL_WIDTH = 320;
export const EPIC_ITEM_HEIGHT = 50; export const EPIC_ITEM_HEIGHT = 50;
...@@ -12,9 +10,7 @@ export const SHELL_MIN_WIDTH = 1620; ...@@ -12,9 +10,7 @@ export const SHELL_MIN_WIDTH = 1620;
export const SCROLL_BAR_SIZE = 15; export const SCROLL_BAR_SIZE = 15;
export const TIMELINE_END_OFFSET_HALF = 8; export const EPIC_HIGHLIGHT_REMOVE_AFTER = 3000;
export const TIMELINE_END_OFFSET_FULL = 16;
export const PRESET_TYPES = { export const PRESET_TYPES = {
QUARTERS: 'QUARTERS', QUARTERS: 'QUARTERS',
...@@ -22,32 +18,27 @@ export const PRESET_TYPES = { ...@@ -22,32 +18,27 @@ export const PRESET_TYPES = {
WEEKS: 'WEEKS', 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 = { export const PRESET_DEFAULTS = {
QUARTERS: { QUARTERS: {
TIMEFRAME_LENGTH: 18, TIMEFRAME_LENGTH: 21,
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}.',
),
}, },
MONTHS: { MONTHS: {
TIMEFRAME_LENGTH: 7, TIMEFRAME_LENGTH: 8,
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}.',
),
}, },
WEEKS: { WEEKS: {
TIMEFRAME_LENGTH: 42, 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 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}.',
),
}, },
}; };
...@@ -5,7 +5,7 @@ import Translate from '~/vue_shared/translate'; ...@@ -5,7 +5,7 @@ import Translate from '~/vue_shared/translate';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import { visitUrl, mergeUrlParams } from '~/lib/utils/url_utility'; 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'; import { getTimeframeForPreset, getEpicsPathForPreset } from './utils/roadmap_utils';
...@@ -48,23 +48,38 @@ export default () => { ...@@ -48,23 +48,38 @@ export default () => {
? dataset.presetType ? dataset.presetType
: PRESET_TYPES.MONTHS; : PRESET_TYPES.MONTHS;
const filterQueryString = window.location.search.substring(1); const filterQueryString = window.location.search.substring(1);
const timeframe = getTimeframeForPreset(presetType); const timeframe = getTimeframeForPreset(
const epicsPath = getEpicsPathForPreset({ presetType,
window.innerWidth - el.offsetLeft - EPIC_DETAILS_CELL_WIDTH,
);
const initialEpicsPath = getEpicsPathForPreset({
basePath: dataset.epicsPath, basePath: dataset.epicsPath,
epicsState: dataset.epicsState,
filterQueryString, filterQueryString,
presetType, presetType,
timeframe, timeframe,
state: dataset.epicsState,
}); });
const store = new RoadmapStore(parseInt(dataset.groupId, 0), timeframe, presetType); const store = new RoadmapStore({
const service = new RoadmapService(epicsPath); groupId: parseInt(dataset.groupId, 0),
sortedBy: dataset.sortedBy,
timeframe,
presetType,
});
const service = new RoadmapService({
initialEpicsPath,
filterQueryString,
basePath: dataset.epicsPath,
epicsState: dataset.epicsState,
});
return { return {
store, store,
service, service,
presetType, presetType,
hasFiltersApplied, hasFiltersApplied,
epicsState: dataset.epicsState,
newEpicEndpoint: dataset.newEpicEndpoint, newEpicEndpoint: dataset.newEpicEndpoint,
emptyStateIllustrationPath: dataset.emptyStateIllustration, emptyStateIllustrationPath: dataset.emptyStateIllustration,
}; };
...@@ -76,6 +91,7 @@ export default () => { ...@@ -76,6 +91,7 @@ export default () => {
service: this.service, service: this.service,
presetType: this.presetType, presetType: this.presetType,
hasFiltersApplied: this.hasFiltersApplied, hasFiltersApplied: this.hasFiltersApplied,
epicsState: this.epicsState,
newEpicEndpoint: this.newEpicEndpoint, newEpicEndpoint: this.newEpicEndpoint,
emptyStateIllustrationPath: this.emptyStateIllustrationPath, emptyStateIllustrationPath: this.emptyStateIllustrationPath,
}, },
......
import { totalDaysInMonth } from '~/lib/utils/datetime_utility'; import { totalDaysInMonth } from '~/lib/utils/datetime_utility';
import { TIMELINE_END_OFFSET_HALF } from '../constants';
export default { export default {
methods: { methods: {
/** /**
...@@ -17,10 +15,10 @@ export default { ...@@ -17,10 +15,10 @@ export default {
* Check if current epic ends within current month (timeline cell) * Check if current epic ends within current month (timeline cell)
*/ */
isTimeframeUnderEndDateForMonth(timeframeItem, epicEndDate) { isTimeframeUnderEndDateForMonth(timeframeItem, epicEndDate) {
return ( if (epicEndDate.getFullYear() <= timeframeItem.getFullYear()) {
timeframeItem.getYear() <= epicEndDate.getYear() && return epicEndDate.getMonth() === timeframeItem.getMonth();
timeframeItem.getMonth() === epicEndDate.getMonth() }
); return epicEndDate.getTime() < timeframeItem.getTime();
}, },
/** /**
* Return timeline bar width for current month (timeline cell) based on * Return timeline bar width for current month (timeline cell) based on
...@@ -61,16 +59,6 @@ export default { ...@@ -61,16 +59,6 @@ export default {
return 'left: 0;'; return 'left: 0;';
} }
// If Epic end date is out of range
const lastTimeframeItem = this.timeframe[this.timeframe.length - 1];
// Check if Epic start date falls within last month of the timeframe
if (
this.epic.startDate.getMonth() === lastTimeframeItem.getMonth() &&
this.epic.startDate.getFullYear() === lastTimeframeItem.getFullYear()
) {
// Compensate for triangle size
return `right: ${TIMELINE_END_OFFSET_HALF}px;`;
}
// Calculate proportional offset based on startDate and total days in // Calculate proportional offset based on startDate and total days in
// current month. // current month.
return `left: ${(startDate / daysInMonth) * 100}%;`; return `left: ${(startDate / daysInMonth) * 100}%;`;
...@@ -99,7 +87,6 @@ export default { ...@@ -99,7 +87,6 @@ export default {
const indexOfCurrentMonth = this.timeframe.indexOf(this.timeframeItem); const indexOfCurrentMonth = this.timeframe.indexOf(this.timeframeItem);
const cellWidth = this.getCellWidth(); const cellWidth = this.getCellWidth();
const offsetEnd = this.getTimelineBarEndOffset();
const epicStartDate = this.epic.startDate; const epicStartDate = this.epic.startDate;
const epicEndDate = this.epic.endDate; const epicEndDate = this.epic.endDate;
...@@ -149,8 +136,7 @@ export default { ...@@ -149,8 +136,7 @@ export default {
} }
} }
// Reduce any offset from total width and round it off. return timelineBarWidth;
return timelineBarWidth - offsetEnd;
}, },
}, },
}; };
import { totalDaysInQuarter, dayInQuarter } from '~/lib/utils/datetime_utility'; import { totalDaysInQuarter, dayInQuarter } from '~/lib/utils/datetime_utility';
import { TIMELINE_END_OFFSET_HALF } from '../constants';
export default { export default {
methods: { methods: {
/** /**
...@@ -11,7 +9,10 @@ export default { ...@@ -11,7 +9,10 @@ export default {
const quarterStart = this.timeframeItem.range[0]; const quarterStart = this.timeframeItem.range[0];
const quarterEnd = this.timeframeItem.range[2]; 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) * Check if current epic ends within current quarter (timeline cell)
...@@ -19,7 +20,7 @@ export default { ...@@ -19,7 +20,7 @@ export default {
isTimeframeUnderEndDateForQuarter(timeframeItem, epicEndDate) { isTimeframeUnderEndDateForQuarter(timeframeItem, epicEndDate) {
const quarterEnd = timeframeItem.range[2]; const quarterEnd = timeframeItem.range[2];
return epicEndDate <= quarterEnd; return epicEndDate.getTime() <= quarterEnd.getTime();
}, },
/** /**
* Return timeline bar width for current quarter (timeline cell) based on * Return timeline bar width for current quarter (timeline cell) based on
...@@ -57,14 +58,6 @@ export default { ...@@ -57,14 +58,6 @@ export default {
return 'left: 0;'; 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}%;`; return `left: ${(startDay / daysInQuarter) * 100}%;`;
}, },
/** /**
...@@ -95,7 +88,6 @@ export default { ...@@ -95,7 +88,6 @@ export default {
const indexOfCurrentQuarter = this.timeframe.indexOf(this.timeframeItem); const indexOfCurrentQuarter = this.timeframe.indexOf(this.timeframeItem);
const cellWidth = this.getCellWidth(); const cellWidth = this.getCellWidth();
const offsetEnd = this.getTimelineBarEndOffset();
const epicStartDate = this.epic.startDate; const epicStartDate = this.epic.startDate;
const epicEndDate = this.epic.endDate; const epicEndDate = this.epic.endDate;
...@@ -140,7 +132,7 @@ export default { ...@@ -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 { export default {
methods: { methods: {
...@@ -7,16 +7,19 @@ export default { ...@@ -7,16 +7,19 @@ export default {
*/ */
hasStartDateForWeek() { hasStartDateForWeek() {
const firstDayOfWeek = this.timeframeItem; const firstDayOfWeek = this.timeframeItem;
const lastDayOfWeek = new Date(this.timeframeItem.getTime()); const lastDayOfWeek = newDate(this.timeframeItem);
lastDayOfWeek.setDate(lastDayOfWeek.getDate() + 6); 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 * Return last date of the week from provided timeframeItem
*/ */
getLastDayOfWeek(timeframeItem) { getLastDayOfWeek(timeframeItem) {
const lastDayOfWeek = new Date(timeframeItem.getTime()); const lastDayOfWeek = newDate(timeframeItem);
lastDayOfWeek.setDate(lastDayOfWeek.getDate() + 6); lastDayOfWeek.setDate(lastDayOfWeek.getDate() + 6);
return lastDayOfWeek; return lastDayOfWeek;
}, },
...@@ -25,7 +28,7 @@ export default { ...@@ -25,7 +28,7 @@ export default {
*/ */
isTimeframeUnderEndDateForWeek(timeframeItem, epicEndDate) { isTimeframeUnderEndDateForWeek(timeframeItem, epicEndDate) {
const lastDayOfWeek = this.getLastDayOfWeek(timeframeItem); const lastDayOfWeek = this.getLastDayOfWeek(timeframeItem);
return epicEndDate <= lastDayOfWeek; return epicEndDate.getTime() <= lastDayOfWeek.getTime();
}, },
/** /**
* Return timeline bar width for current week (timeline cell) based on * Return timeline bar width for current week (timeline cell) based on
...@@ -37,14 +40,6 @@ export default { ...@@ -37,14 +40,6 @@ export default {
return Math.min(cellWidth, barWidth); 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 * In case startDate for any epic is undefined or is out of range
* for current timeframe, we have to provide specific offset while * for current timeframe, we have to provide specific offset while
...@@ -73,15 +68,6 @@ export default { ...@@ -73,15 +68,6 @@ export default {
return 'left: 0;'; 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;`; return `left: ${startDate * dayWidth - dayWidth / 2}px;`;
}, },
/** /**
...@@ -111,7 +97,6 @@ export default { ...@@ -111,7 +97,6 @@ export default {
const indexOfCurrentWeek = this.timeframe.indexOf(this.timeframeItem); const indexOfCurrentWeek = this.timeframe.indexOf(this.timeframeItem);
const cellWidth = this.getCellWidth(); const cellWidth = this.getCellWidth();
const offsetEnd = this.getTimelineBarEndOffset();
const epicStartDate = this.epic.startDate; const epicStartDate = this.epic.startDate;
const epicEndDate = this.epic.endDate; const epicEndDate = this.epic.endDate;
...@@ -135,7 +120,7 @@ export default { ...@@ -135,7 +120,7 @@ export default {
} }
} }
return timelineBarWidth - offsetEnd; return timelineBarWidth;
}, },
}, },
}; };
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { getEpicsPathForPreset } from '../utils/roadmap_utils';
export default class RoadmapService { export default class RoadmapService {
constructor(epicsPath) { constructor({ basePath, epicsState, filterQueryString, initialEpicsPath }) {
this.epicsPath = epicsPath; this.basePath = basePath;
this.epicsState = epicsState;
this.filterQueryString = filterQueryString;
this.initialEpicsPath = initialEpicsPath;
} }
getEpics() { 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 { 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 { export default class RoadmapStore {
constructor(groupId, timeframe, presetType) { constructor({ groupId, timeframe, presetType, sortedBy }) {
this.state = {}; this.state = {};
this.state.epics = []; this.state.epics = [];
this.state.epicIds = [];
this.state.currentGroupId = groupId; this.state.currentGroupId = groupId;
this.state.timeframe = timeframe; this.state.timeframe = timeframe;
this.presetType = presetType; this.presetType = presetType;
this.sortedBy = sortedBy;
this.initTimeframeThreshold(); this.initTimeframeThreshold();
} }
...@@ -27,24 +30,33 @@ export default class RoadmapStore { ...@@ -27,24 +30,33 @@ export default class RoadmapStore {
this.timeframeEndDate = this.state.timeframe[lastTimeframeIndex]; this.timeframeEndDate = this.state.timeframe[lastTimeframeIndex];
} else if (this.presetType === PRESET_TYPES.WEEKS) { } else if (this.presetType === PRESET_TYPES.WEEKS) {
this.timeframeStartDate = startFrame; 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); this.timeframeEndDate.setDate(this.timeframeEndDate.getDate() + 7);
} }
} }
setEpics(epics) { setEpics(epics) {
this.state.epics = epics.reduce((filteredEpics, epic) => { this.state.epicIds = [];
const formattedEpic = RoadmapStore.formatEpicDetails( this.state.epics = RoadmapStore.filterInvalidEpics({
epic, timeframeStartDate: this.timeframeStartDate,
this.timeframeStartDate, timeframeEndDate: this.timeframeEndDate,
this.timeframeEndDate, state: this.state,
); epics,
// Exclude any Epic that has invalid dates });
if (formattedEpic.startDate <= formattedEpic.endDate) {
filteredEpics.push(formattedEpic);
} }
return filteredEpics;
}, []); 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() { getEpics() {
...@@ -59,6 +71,57 @@ export default class RoadmapStore { ...@@ -59,6 +71,57 @@ export default class RoadmapStore {
return this.state.timeframe; 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 * This method constructs Epic object and assigns proxy dates
* in case start or end dates are unavailable. * in case start or end dates are unavailable.
...@@ -73,44 +136,75 @@ export default class RoadmapStore { ...@@ -73,44 +136,75 @@ export default class RoadmapStore {
if (rawEpic.start_date) { if (rawEpic.start_date) {
// If startDate is present // If startDate is present
const startDate = parsePikadayDate(rawEpic.start_date); 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 { } else {
// Start date is not available // startDate is not available
epicItem.startDateUndefined = true; 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 (rawEpic.end_date) {
// If endDate is present
const endDate = parsePikadayDate(rawEpic.end_date); 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 { } else {
// endDate is not available
epicItem.endDateUndefined = true; epicItem.endDateUndefined = true;
epicItem.endDate = new Date(timeframeEndDate.getTime());
} }
RoadmapStore.processEpicDates(epicItem, timeframeStartDate, timeframeEndDate);
return epicItem; 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 * This method returns array of Objects representing Quarters based on provided initialDate
* *
* For eg; If initialDate is 15th Jan 2018 * For eg; If initialDate is 15th Jan 2018
* Then as per Roadmap specs, we need to show * 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 * 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; * So returned array from this method will be;
* [ * [
...@@ -47,17 +55,11 @@ import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants'; ...@@ -47,17 +55,11 @@ import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants';
* *
* @param {Date} initialDate * @param {Date} initialDate
*/ */
export const getTimeframeForQuartersView = (initialDate = new Date()) => { export const getTimeframeForQuartersView = (initialDate = new Date(), timeframe = []) => {
const startDate = initialDate; const startDate = newDate(initialDate);
startDate.setHours(0, 0, 0, 0); startDate.setHours(0, 0, 0, 0);
const monthsForQuarters = { if (!timeframe.length) {
1: [0, 1, 2],
2: [3, 4, 5],
3: [6, 7, 8],
4: [9, 10, 11],
};
// Get current quarter for current month // Get current quarter for current month
const currentQuarter = Math.floor((startDate.getMonth() + 3) / 3); const currentQuarter = Math.floor((startDate.getMonth() + 3) / 3);
// Get index of current month in current quarter // Get index of current month in current quarter
...@@ -66,18 +68,19 @@ export const getTimeframeForQuartersView = (initialDate = new Date()) => { ...@@ -66,18 +68,19 @@ export const getTimeframeForQuartersView = (initialDate = new Date()) => {
startDate.getMonth(), startDate.getMonth(),
); );
// To move start back to first month of previous quarter // To move start back to first month of 2 quarters prior by
// Adding quarter size (3) to month order will give us // adding quarter size (3 + 3) to month order will give us
// exact number of months we need to go back in time // exact number of months we need to go back in time
const startMonth = currentMonthInCurrentQuarter + 3; const startMonth = currentMonthInCurrentQuarter + 6;
const quartersTimeframe = [];
// Move startDate to first month of previous quarter // Move startDate to first month of previous quarter
startDate.setMonth(startDate.getMonth() - startMonth); startDate.setMonth(startDate.getMonth() - startMonth);
// Get timeframe for the length we determined for this preset // Get timeframe for the length we determined for this preset
// start from the startDate // start from the startDate
const timeframe = getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH); timeframe.push(...getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH));
}
const quartersTimeframe = [];
// Iterate over the timeframe and break it down // Iterate over the timeframe and break it down
// in chunks of quarters // in chunks of quarters
for (let i = 0; i < timeframe.length; i += 3) { for (let i = 0; i < timeframe.length; i += 3) {
...@@ -100,83 +103,246 @@ export const getTimeframeForQuartersView = (initialDate = new Date()) => { ...@@ -100,83 +103,246 @@ export const getTimeframeForQuartersView = (initialDate = new Date()) => {
return quartersTimeframe; 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 * This method returns array of Dates respresenting Months based on provided initialDate
* *
* For eg; If initialDate is 15th Jan 2018 * For eg; If initialDate is 15th Jan 2018
* Then as per Roadmap specs, we need to show * 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 * 5 months after current month
* thus, total of 7 months. * thus, total of 8 months.
* *
* So returned array from this method will be; * So returned array from this method will be;
* [ * [
* 1 Dec 2017, 1 Jan 2018, 1 Feb 2018, 1 Mar 2018, * 1 Nov 2017, 1 Dec 2017, 1 Jan 2018, 1 Feb 2018,
* 1 Apr 2018, 1 May 2018, 30 Jun 2018 * 1 Mar 2018, 1 Apr 2018, 1 May 2018, 30 Jun 2018
* ] * ]
* *
* @param {Date} initialDate * @param {Date} initialDate
*/ */
export const getTimeframeForMonthsView = (initialDate = new Date()) => { export const getTimeframeForMonthsView = (initialDate = new Date()) => {
const startDate = initialDate; const startDate = newDate(initialDate);
startDate.setHours(0, 0, 0, 0);
// Move startDate to a month prior to current month // 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); 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 * This method returns array of Dates respresenting Months based on provided initialDate
* *
* For eg; If initialDate is 15th Jan 2018 * For eg; If initialDate is 15th Jan 2018
* Then as per Roadmap specs, we need to show * 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 * 4 weeks after current week
* thus, total of 6 weeks. * thus, total of 7 weeks.
* Note that week starts on Sunday * Note that week starts on Sunday
* *
* So returned array from this method will be; * 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 * 28 Jan 2018, 4 Mar 2018, 11 Mar 2018
* ] * ]
* *
* @param {Date} initialDate * @param {Date} initialDate
*/ */
export const getTimeframeForWeeksView = (initialDate = new Date()) => { export const getTimeframeForWeeksView = (initialDate = new Date()) => {
const startDate = initialDate; const startDate = newDate(initialDate);
startDate.setHours(0, 0, 0, 0); startDate.setHours(0, 0, 0, 0);
const dayOfWeek = startDate.getDay(); const dayOfWeek = startDate.getDay();
const daysToFirstDayOfPrevWeek = dayOfWeek + 7; const daysToFirstDayOfPrevWeek = dayOfWeek + 14;
const timeframe = []; 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); startDate.setDate(startDate.getDate() - daysToFirstDayOfPrevWeek);
// Iterate for the length of this preset // Iterate for the length of this preset
for (let i = 0; i < PRESET_DEFAULTS.WEEKS.TIMEFRAME_LENGTH; i += 1) { for (let i = 0; i < PRESET_DEFAULTS.WEEKS.TIMEFRAME_LENGTH; i += 1) {
// Push date to timeframe only when day is // Push date to timeframe only when day is
// first day (Sunday) of the week1 // first day (Sunday) of the week
if (startDate.getDay() === 0) { timeframe.push(newDate(startDate));
timeframe.push(new Date(startDate.getTime()));
} // Move date next Sunday
// Move date one day further startDate.setDate(startDate.getDate() + 7);
startDate.setDate(startDate.getDate() + 1);
} }
return timeframe; 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) { 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) { } 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 = ({ export const getEpicsPathForPreset = ({
...@@ -184,7 +350,7 @@ export const getEpicsPathForPreset = ({ ...@@ -184,7 +350,7 @@ export const getEpicsPathForPreset = ({
filterQueryString = '', filterQueryString = '',
presetType = '', presetType = '',
timeframe = [], timeframe = [],
state = 'all', epicsState = 'all',
}) => { }) => {
let start; let start;
let end; let end;
...@@ -208,13 +374,13 @@ export const getEpicsPathForPreset = ({ ...@@ -208,13 +374,13 @@ export const getEpicsPathForPreset = ({
end = lastTimeframe; end = lastTimeframe;
} else if (presetType === PRESET_TYPES.WEEKS) { } else if (presetType === PRESET_TYPES.WEEKS) {
start = firstTimeframe; start = firstTimeframe;
end = new Date(lastTimeframe.getTime()); end = newDate(lastTimeframe);
end.setDate(end.getDate() + 6); end.setDate(end.getDate() + 6);
} }
const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`; const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`;
const endDate = `${end.getFullYear()}-${end.getMonth() + 1}-${end.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) { if (filterQueryString) {
epicsPath += `&${filterQueryString}`; epicsPath += `&${filterQueryString}`;
...@@ -222,3 +388,43 @@ export const getEpicsPathForPreset = ({ ...@@ -222,3 +388,43 @@ export const getEpicsPathForPreset = ({
return epicsPath; 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( ...@@ -20,6 +20,36 @@ $column-right-gradient: linear-gradient(
$roadmap-gradient-gray 100% $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 { @mixin roadmap-scroll-mixin {
height: $grid-size; height: $grid-size;
width: $details-cell-width; width: $details-cell-width;
...@@ -229,6 +259,12 @@ $column-right-gradient: linear-gradient( ...@@ -229,6 +259,12 @@ $column-right-gradient: linear-gradient(
} }
} }
&.newly-added-epic {
.epic-details-cell {
animation: colorTransitionDetailsCell 3s;
}
}
.epic-details-cell, .epic-details-cell,
.epic-timeline-cell { .epic-timeline-cell {
box-sizing: border-box; box-sizing: border-box;
...@@ -251,6 +287,11 @@ $column-right-gradient: linear-gradient( ...@@ -251,6 +287,11 @@ $column-right-gradient: linear-gradient(
height: $item-height; height: $item-height;
} }
.epic-title,
.epic-group-timeframe {
animation: fadeInDetails 1s;
}
.epic-title { .epic-title {
display: table; display: table;
table-layout: fixed; table-layout: fixed;
...@@ -294,25 +335,12 @@ $column-right-gradient: linear-gradient( ...@@ -294,25 +335,12 @@ $column-right-gradient: linear-gradient(
background-color: $blue-500; background-color: $blue-500;
border-radius: $border-radius-default; border-radius: $border-radius-default;
opacity: 0.75; opacity: 0.75;
animation: fadeinTimelineBar 1s;
&:hover { &:hover {
opacity: 1; 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 { &.start-date-undefined {
background: linear-gradient( background: linear-gradient(
to right, to right,
...@@ -330,31 +358,6 @@ $column-right-gradient: linear-gradient( ...@@ -330,31 +358,6 @@ $column-right-gradient: linear-gradient(
$roadmap-gradient-gray 100% $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 { &:last-child {
......
...@@ -6,25 +6,42 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -6,25 +6,42 @@ import axios from '~/lib/utils/axios_utils';
import appComponent from 'ee/roadmap/components/app.vue'; import appComponent from 'ee/roadmap/components/app.vue';
import RoadmapStore from 'ee/roadmap/store/roadmap_store'; import RoadmapStore from 'ee/roadmap/store/roadmap_store';
import RoadmapService from 'ee/roadmap/service/roadmap_service'; 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 mountComponent from 'spec/helpers/vue_mount_component_helper';
import { import {
mockTimeframeMonths, mockTimeframeInitialDate,
mockGroupId, mockGroupId,
basePath,
epicsPath, epicsPath,
mockNewEpicEndpoint, mockNewEpicEndpoint,
rawEpics, rawEpics,
mockSvgPath, mockSvgPath,
mockSortedBy,
} from '../mock_data'; } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = () => { const createComponent = () => {
const Component = Vue.extend(appComponent); const Component = Vue.extend(appComponent);
const timeframe = mockTimeframeMonths; const timeframe = mockTimeframeMonths;
const store = new RoadmapStore(mockGroupId, timeframe); const store = new RoadmapStore({
const service = new RoadmapService(epicsPath); groupId: mockGroupId,
presetType: PRESET_TYPES.MONTHS,
sortedBy: mockSortedBy,
timeframe,
});
const service = new RoadmapService({
initialEpicsPath: epicsPath,
epicsState: 'all',
basePath,
});
return mountComponent(Component, { return mountComponent(Component, {
store, store,
...@@ -173,6 +190,193 @@ describe('AppComponent', () => { ...@@ -173,6 +190,193 @@ describe('AppComponent', () => {
}, 0); }, 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', () => { describe('mounted', () => {
......
import Vue from 'vue'; import Vue from 'vue';
import _ from 'underscore';
import epicItemComponent from 'ee/roadmap/components/epic_item.vue'; 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 { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { import {
mockTimeframeMonths, mockTimeframeInitialDate,
mockEpic, mockEpic,
mockGroupId, mockGroupId,
mockShellWidth, mockShellWidth,
mockItemWidth, mockItemWidth,
} from '../mock_data'; } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ({ const createComponent = ({
presetType = PRESET_TYPES.MONTHS, presetType = PRESET_TYPES.MONTHS,
epic = mockEpic, epic = mockEpic,
...@@ -44,6 +50,25 @@ describe('EpicItemComponent', () => { ...@@ -44,6 +50,25 @@ describe('EpicItemComponent', () => {
vm.$destroy(); 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', () => { describe('template', () => {
it('renders component container element class `epics-list-item`', () => { it('renders component container element class `epics-list-item`', () => {
expect(vm.$el.classList.contains('epics-list-item')).toBeTruthy(); expect(vm.$el.classList.contains('epics-list-item')).toBeTruthy();
......
import Vue from 'vue'; import Vue from 'vue';
import epicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue'; import epicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import { import eventHub from 'ee/roadmap/event_hub';
TIMELINE_END_OFFSET_FULL, import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
TIMELINE_END_OFFSET_HALF,
TIMELINE_CELL_MIN_WIDTH, import { TIMELINE_CELL_MIN_WIDTH, PRESET_TYPES } from 'ee/roadmap/constants';
PRESET_TYPES,
} from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; 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 = ({ const createComponent = ({
presetType = PRESET_TYPES.MONTHS, presetType = PRESET_TYPES.MONTHS,
...@@ -62,7 +62,7 @@ describe('EpicItemTimelineComponent', () => { ...@@ -62,7 +62,7 @@ describe('EpicItemTimelineComponent', () => {
it('returns proportionate width based on timeframe length and shellWidth', () => { it('returns proportionate width based on timeframe length and shellWidth', () => {
vm = createComponent({}); 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', () => { it('returns minimum fixed width when proportionate width available lower than minimum fixed width defined', () => {
...@@ -74,46 +74,6 @@ describe('EpicItemTimelineComponent', () => { ...@@ -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', () => { describe('renderTimelineBar', () => {
it('sets `timelineBarStyles` & `timelineBarReady` when timeframeItem has Epic.startDate', () => { it('sets `timelineBarStyles` & `timelineBarReady` when timeframeItem has Epic.startDate', () => {
vm = createComponent({ vm = createComponent({
...@@ -122,7 +82,7 @@ describe('EpicItemTimelineComponent', () => { ...@@ -122,7 +82,7 @@ describe('EpicItemTimelineComponent', () => {
}); });
vm.renderTimelineBar(); vm.renderTimelineBar();
expect(vm.timelineBarStyles).toBe('width: 1216px; left: 0;'); expect(vm.timelineBarStyles).toBe('width: 1274px; left: 0;');
expect(vm.timelineBarReady).toBe(true); expect(vm.timelineBarReady).toBe(true);
}); });
...@@ -139,6 +99,26 @@ describe('EpicItemTimelineComponent', () => { ...@@ -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', () => { describe('template', () => {
it('renders component container element with class `epic-timeline-cell`', () => { it('renders component container element with class `epic-timeline-cell`', () => {
vm = createComponent({}); vm = createComponent({});
...@@ -172,7 +152,7 @@ describe('EpicItemTimelineComponent', () => { ...@@ -172,7 +152,7 @@ describe('EpicItemTimelineComponent', () => {
vm.renderTimelineBar(); vm.renderTimelineBar();
vm.$nextTick(() => { vm.$nextTick(() => {
expect(timelineBarEl.getAttribute('style')).toBe('width: 608.571px; left: 0px;'); expect(timelineBarEl.getAttribute('style')).toBe('width: 742.5px; left: 0px;');
done(); done();
}); });
}); });
...@@ -193,23 +173,6 @@ describe('EpicItemTimelineComponent', () => { ...@@ -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 => { it('renders timeline bar with `end-date-undefined` class when Epic endDate is undefined', done => {
vm = createComponent({ vm = createComponent({
epic: Object.assign({}, mockEpic, { epic: Object.assign({}, mockEpic, {
...@@ -226,23 +189,5 @@ describe('EpicItemTimelineComponent', () => { ...@@ -226,23 +189,5 @@ describe('EpicItemTimelineComponent', () => {
done(); 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'; ...@@ -2,16 +2,20 @@ import Vue from 'vue';
import epicsListEmptyComponent from 'ee/roadmap/components/epics_list_empty.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 { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { import { mockTimeframeInitialDate, mockSvgPath, mockNewEpicEndpoint } from '../mock_data';
mockTimeframeQuarters,
mockTimeframeMonths, const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
mockTimeframeWeeks, const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
mockSvgPath, const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
mockNewEpicEndpoint,
} from '../mock_data';
const createComponent = ({ const createComponent = ({
hasFiltersApplied = false, hasFiltersApplied = false,
...@@ -71,7 +75,7 @@ describe('EpicsListEmptyComponent', () => { ...@@ -71,7 +75,7 @@ describe('EpicsListEmptyComponent', () => {
Vue.nextTick() Vue.nextTick()
.then(() => { .then(() => {
expect(vm.subMessage).toBe( 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) .then(done)
...@@ -83,7 +87,7 @@ describe('EpicsListEmptyComponent', () => { ...@@ -83,7 +87,7 @@ describe('EpicsListEmptyComponent', () => {
Vue.nextTick() Vue.nextTick()
.then(() => { .then(() => {
expect(vm.subMessage).toBe( 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) .then(done)
...@@ -100,7 +104,7 @@ describe('EpicsListEmptyComponent', () => { ...@@ -100,7 +104,7 @@ describe('EpicsListEmptyComponent', () => {
Vue.nextTick() Vue.nextTick()
.then(() => { .then(() => {
expect(vm.subMessage).toBe( 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) .then(done)
...@@ -112,7 +116,7 @@ describe('EpicsListEmptyComponent', () => { ...@@ -112,7 +116,7 @@ describe('EpicsListEmptyComponent', () => {
Vue.nextTick() Vue.nextTick()
.then(() => { .then(() => {
expect(vm.subMessage).toBe( 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) .then(done)
...@@ -134,7 +138,7 @@ describe('EpicsListEmptyComponent', () => { ...@@ -134,7 +138,7 @@ describe('EpicsListEmptyComponent', () => {
Vue.nextTick() Vue.nextTick()
.then(() => { .then(() => {
expect(vm.subMessage).toBe( 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) .then(done)
...@@ -146,7 +150,7 @@ describe('EpicsListEmptyComponent', () => { ...@@ -146,7 +150,7 @@ describe('EpicsListEmptyComponent', () => {
Vue.nextTick() Vue.nextTick()
.then(() => { .then(() => {
expect(vm.subMessage).toBe( 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) .then(done)
...@@ -157,7 +161,7 @@ describe('EpicsListEmptyComponent', () => { ...@@ -157,7 +161,7 @@ describe('EpicsListEmptyComponent', () => {
describe('timeframeRange', () => { describe('timeframeRange', () => {
it('returns correct timeframe startDate and endDate in words', () => { 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'); expect(vm.timeframeRange.endDate).toBe('Jun 30, 2018');
}); });
}); });
......
...@@ -3,11 +3,26 @@ import Vue from 'vue'; ...@@ -3,11 +3,26 @@ import Vue from 'vue';
import epicsListSectionComponent from 'ee/roadmap/components/epics_list_section.vue'; import epicsListSectionComponent from 'ee/roadmap/components/epics_list_section.vue';
import RoadmapStore from 'ee/roadmap/store/roadmap_store'; import RoadmapStore from 'ee/roadmap/store/roadmap_store';
import eventHub from 'ee/roadmap/event_hub'; import eventHub from 'ee/roadmap/event_hub';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants'; import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; 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); store.setEpics(rawEpics);
const mockEpics = store.getEpics(); const mockEpics = store.getEpics();
...@@ -63,7 +78,7 @@ describe('EpicsListSectionComponent', () => { ...@@ -63,7 +78,7 @@ describe('EpicsListSectionComponent', () => {
describe('emptyRowCellStyles', () => { describe('emptyRowCellStyles', () => {
it('returns computed style object based on sectionItemWidth prop value', () => { 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', () => { ...@@ -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', () => { describe('template', () => {
......
import Vue from 'vue'; import Vue from 'vue';
import MonthsHeaderItemComponent from 'ee/roadmap/components/preset_months/months_header_item.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 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 mockTimeframeIndex = 0;
const createComponent = ({ const createComponent = ({
...@@ -56,7 +58,7 @@ describe('MonthsHeaderItemComponent', () => { ...@@ -56,7 +58,7 @@ describe('MonthsHeaderItemComponent', () => {
it('returns string containing Year and Month for current timeline header item', () => { it('returns string containing Year and Month for current timeline header item', () => {
vm = createComponent({}); 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', () => { it('returns string containing only Month for current timeline header item when previous header contained Year', () => {
...@@ -65,7 +67,7 @@ describe('MonthsHeaderItemComponent', () => { ...@@ -65,7 +67,7 @@ describe('MonthsHeaderItemComponent', () => {
timeframeItem: mockTimeframeMonths[mockTimeframeIndex + 1], timeframeItem: mockTimeframeMonths[mockTimeframeIndex + 1],
}); });
expect(vm.timelineHeaderLabel).toBe('2018 Jan'); expect(vm.timelineHeaderLabel).toBe('Dec');
}); });
}); });
...@@ -117,7 +119,7 @@ describe('MonthsHeaderItemComponent', () => { ...@@ -117,7 +119,7 @@ describe('MonthsHeaderItemComponent', () => {
const itemLabelEl = vm.$el.querySelector('.item-label'); const itemLabelEl = vm.$el.querySelector('.item-label');
expect(itemLabelEl).not.toBeNull(); expect(itemLabelEl).not.toBeNull();
expect(itemLabelEl.innerText.trim()).toBe('2017 Dec'); expect(itemLabelEl.innerText.trim()).toBe('2017 Nov');
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import MonthsHeaderSubItemComponent from 'ee/roadmap/components/preset_months/months_header_sub_item.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 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 = ({ const createComponent = ({
currentDate = mockTimeframeMonths[0], currentDate = mockTimeframeMonths[0],
......
import Vue from 'vue'; import Vue from 'vue';
import QuartersHeaderItemComponent from 'ee/roadmap/components/preset_quarters/quarters_header_item.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 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 mockTimeframeIndex = 0;
const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
const createComponent = ({ const createComponent = ({
timeframeIndex = mockTimeframeIndex, timeframeIndex = mockTimeframeIndex,
...@@ -38,8 +40,6 @@ describe('QuartersHeaderItemComponent', () => { ...@@ -38,8 +40,6 @@ describe('QuartersHeaderItemComponent', () => {
const currentDate = new Date(); const currentDate = new Date();
expect(vm.currentDate.getDate()).toBe(currentDate.getDate()); 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', () => { ...@@ -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', () => { describe('timelineHeaderLabel', () => {
it('returns string containing Year and Quarter for current timeline header item', () => { it('returns string containing Year and Quarter for current timeline header item', () => {
vm = createComponent({}); 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', () => { it('returns string containing only Quarter for current timeline header item when previous header contained Year', () => {
...@@ -65,7 +77,7 @@ describe('QuartersHeaderItemComponent', () => { ...@@ -65,7 +77,7 @@ describe('QuartersHeaderItemComponent', () => {
timeframeItem: mockTimeframeQuarters[mockTimeframeIndex + 2], timeframeItem: mockTimeframeQuarters[mockTimeframeIndex + 2],
}); });
expect(vm.timelineHeaderLabel).toBe('Q2'); expect(vm.timelineHeaderLabel).toBe('2018 Q1');
}); });
}); });
...@@ -118,7 +130,7 @@ describe('QuartersHeaderItemComponent', () => { ...@@ -118,7 +130,7 @@ describe('QuartersHeaderItemComponent', () => {
const itemLabelEl = vm.$el.querySelector('.item-label'); const itemLabelEl = vm.$el.querySelector('.item-label');
expect(itemLabelEl).not.toBeNull(); expect(itemLabelEl).not.toBeNull();
expect(itemLabelEl.innerText.trim()).toBe('2017 Q4'); expect(itemLabelEl.innerText.trim()).toBe('2017 Q3');
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import QuartersHeaderSubItemComponent from 'ee/roadmap/components/preset_quarters/quarters_header_sub_item.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 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 = ({ const createComponent = ({
currentDate = mockTimeframeQuarters[0].range[1], currentDate = mockTimeframeQuarters[0].range[1],
...@@ -25,6 +28,22 @@ describe('QuartersHeaderSubItemComponent', () => { ...@@ -25,6 +28,22 @@ describe('QuartersHeaderSubItemComponent', () => {
}); });
describe('computed', () => { 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', () => { describe('headerSubItems', () => {
it('returns array of dates containing Months from timeframeItem', () => { it('returns array of dates containing Months from timeframeItem', () => {
vm = createComponent({}); vm = createComponent({});
...@@ -46,7 +65,7 @@ describe('QuartersHeaderSubItemComponent', () => { ...@@ -46,7 +65,7 @@ describe('QuartersHeaderSubItemComponent', () => {
it('returns false when current quarter month is different from timeframe quarter', () => { it('returns false when current quarter month is different from timeframe quarter', () => {
vm = createComponent({ vm = createComponent({
currentDate: new Date(2017, 10, 1), // Nov 1, 2017 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); expect(vm.hasToday).toBe(false);
......
import Vue from 'vue'; import Vue from 'vue';
import WeeksHeaderItemComponent from 'ee/roadmap/components/preset_weeks/weeks_header_item.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 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 mockTimeframeIndex = 0;
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
const createComponent = ({ const createComponent = ({
timeframeIndex = mockTimeframeIndex, timeframeIndex = mockTimeframeIndex,
...@@ -38,9 +40,6 @@ describe('WeeksHeaderItemComponent', () => { ...@@ -38,9 +40,6 @@ describe('WeeksHeaderItemComponent', () => {
const currentDate = new Date(); const currentDate = new Date();
expect(vm.currentDate.getDate()).toBe(currentDate.getDate()); expect(vm.currentDate.getDate()).toBe(currentDate.getDate());
expect(vm.lastDayOfCurrentWeek.getDate()).toBe(
mockTimeframeWeeks[mockTimeframeIndex].getDate() + 7,
);
}); });
}); });
...@@ -53,20 +52,37 @@ describe('WeeksHeaderItemComponent', () => { ...@@ -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', () => { 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({}); 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({ vm = createComponent({
timeframeIndex: mockTimeframeIndex + 1, timeframeIndex: mockTimeframeIndex + 1,
timeframeItem: mockTimeframeWeeks[mockTimeframeIndex + 1], timeframeItem: mockTimeframeWeeks[mockTimeframeIndex + 1],
}); });
expect(vm.timelineHeaderLabel).toBe('Dec 31'); expect(vm.timelineHeaderLabel).toBe('Dec 24');
}); });
}); });
...@@ -112,7 +128,7 @@ describe('WeeksHeaderItemComponent', () => { ...@@ -112,7 +128,7 @@ describe('WeeksHeaderItemComponent', () => {
const itemLabelEl = vm.$el.querySelector('.item-label'); const itemLabelEl = vm.$el.querySelector('.item-label');
expect(itemLabelEl).not.toBeNull(); 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 Vue from 'vue';
import WeeksHeaderSubItemComponent from 'ee/roadmap/components/preset_weeks/weeks_header_sub_item.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 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 = ({ const createComponent = ({
currentDate = mockTimeframeWeeks[0], currentDate = mockTimeframeWeeks[0],
...@@ -24,8 +27,9 @@ describe('MonthsHeaderSubItemComponent', () => { ...@@ -24,8 +27,9 @@ describe('MonthsHeaderSubItemComponent', () => {
vm.$destroy(); vm.$destroy();
}); });
describe('data', () => { describe('computed', () => {
it('sets prop `headerSubItems` with array of dates containing days of week from timeframeItem', () => { describe('headerSubItems', () => {
it('returns `headerSubItems` array of dates containing days of week from timeframeItem', () => {
vm = createComponent({}); vm = createComponent({});
expect(Array.isArray(vm.headerSubItems)).toBe(true); expect(Array.isArray(vm.headerSubItems)).toBe(true);
...@@ -36,7 +40,6 @@ describe('MonthsHeaderSubItemComponent', () => { ...@@ -36,7 +40,6 @@ describe('MonthsHeaderSubItemComponent', () => {
}); });
}); });
describe('computed', () => {
describe('hasToday', () => { describe('hasToday', () => {
it('returns true when current week is same as timeframe week', () => { it('returns true when current week is same as timeframe week', () => {
vm = createComponent({}); vm = createComponent({});
......
...@@ -2,11 +2,14 @@ import Vue from 'vue'; ...@@ -2,11 +2,14 @@ import Vue from 'vue';
import roadmapShellComponent from 'ee/roadmap/components/roadmap_shell.vue'; import roadmapShellComponent from 'ee/roadmap/components/roadmap_shell.vue';
import eventHub from 'ee/roadmap/event_hub'; import eventHub from 'ee/roadmap/event_hub';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants'; import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; 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 = ( const createComponent = (
{ epics = [mockEpic], timeframe = mockTimeframeMonths, currentGroupId = mockGroupId }, { epics = [mockEpic], timeframe = mockTimeframeMonths, currentGroupId = mockGroupId },
...@@ -88,14 +91,29 @@ describe('RoadmapShellComponent', () => { ...@@ -88,14 +91,29 @@ describe('RoadmapShellComponent', () => {
describe('handleScroll', () => { describe('handleScroll', () => {
beforeEach(() => { beforeEach(() => {
spyOn(eventHub, '$emit'); document.body.innerHTML +=
'<div class="roadmap-container"><div id="roadmap-shell"></div></div>';
}); });
it('emits `epicsListScrolled` event via eventHub', () => { afterEach(() => {
vm.noScroll = false; document.querySelector('.roadmap-container').remove();
vm.handleScroll(); });
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'; ...@@ -2,11 +2,14 @@ import Vue from 'vue';
import roadmapTimelineSectionComponent from 'ee/roadmap/components/roadmap_timeline_section.vue'; import roadmapTimelineSectionComponent from 'ee/roadmap/components/roadmap_timeline_section.vue';
import eventHub from 'ee/roadmap/event_hub'; import eventHub from 'ee/roadmap/event_hub';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants'; import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; 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 = ({ const createComponent = ({
presetType = PRESET_TYPES.MONTHS, presetType = PRESET_TYPES.MONTHS,
......
...@@ -2,10 +2,14 @@ import Vue from 'vue'; ...@@ -2,10 +2,14 @@ import Vue from 'vue';
import timelineTodayIndicatorComponent from 'ee/roadmap/components/timeline_today_indicator.vue'; import timelineTodayIndicatorComponent from 'ee/roadmap/components/timeline_today_indicator.vue';
import eventHub from 'ee/roadmap/event_hub'; import eventHub from 'ee/roadmap/event_hub';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants'; import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; 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( const mockCurrentDate = new Date(
mockTimeframeMonths[0].getFullYear(), mockTimeframeMonths[0].getFullYear(),
...@@ -53,29 +57,33 @@ describe('TimelineTodayIndicatorComponent', () => { ...@@ -53,29 +57,33 @@ describe('TimelineTodayIndicatorComponent', () => {
const stylesObj = vm.todayBarStyles; const stylesObj = vm.todayBarStyles;
expect(stylesObj.height).toBe('120px'); expect(stylesObj.height).toBe('120px');
expect(stylesObj.left).toBe('48%'); expect(stylesObj.left).toBe('50%');
expect(vm.todayBarReady).toBe(true); expect(vm.todayBarReady).toBe(true);
}); });
}); });
}); });
describe('mounted', () => { describe('mounted', () => {
it('binds `epicsListRendered` event listener via eventHub', () => { it('binds `epicsListRendered`, `epicsListScrolled` and `refreshTimeline` event listeners via eventHub', () => {
spyOn(eventHub, '$on'); spyOn(eventHub, '$on');
const vmX = createComponent({}); const vmX = createComponent({});
expect(eventHub.$on).toHaveBeenCalledWith('epicsListRendered', jasmine.any(Function)); 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(); vmX.$destroy();
}); });
}); });
describe('beforeDestroy', () => { describe('beforeDestroy', () => {
it('unbinds `epicsListRendered` event listener via eventHub', () => { it('unbinds `epicsListRendered`, `epicsListScrolled` and `refreshTimeline` event listeners via eventHub', () => {
spyOn(eventHub, '$off'); spyOn(eventHub, '$off');
const vmX = createComponent({}); const vmX = createComponent({});
vmX.$destroy(); vmX.$destroy();
expect(eventHub.$off).toHaveBeenCalledWith('epicsListRendered', jasmine.any(Function)); 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 Vue from 'vue';
import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.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 { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; 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 = ({ const createComponent = ({
presetType = PRESET_TYPES.MONTHS, presetType = PRESET_TYPES.MONTHS,
...@@ -114,17 +118,6 @@ describe('MonthsPresetMixin', () => { ...@@ -114,17 +118,6 @@ describe('MonthsPresetMixin', () => {
expect(vm.getTimelineBarStartOffsetForMonths()).toBe('left: 0;'); 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', () => { it('returns proportional `left` value based on Epic startDate and days in the month', () => {
vm = createComponent({ vm = createComponent({
epic: Object.assign({}, mockEpic, { epic: Object.assign({}, mockEpic, {
...@@ -132,7 +125,7 @@ describe('MonthsPresetMixin', () => { ...@@ -132,7 +125,7 @@ describe('MonthsPresetMixin', () => {
}), }),
}); });
expect(vm.getTimelineBarStartOffsetForMonths()).toContain('left: 48'); expect(vm.getTimelineBarStartOffsetForMonths()).toContain('left: 50%');
}); });
}); });
...@@ -147,7 +140,7 @@ describe('MonthsPresetMixin', () => { ...@@ -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 Vue from 'vue';
import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.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 { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; 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 = ({ const createComponent = ({
presetType = PRESET_TYPES.QUARTERS, presetType = PRESET_TYPES.QUARTERS,
...@@ -114,17 +118,6 @@ describe('QuartersPresetMixin', () => { ...@@ -114,17 +118,6 @@ describe('QuartersPresetMixin', () => {
expect(vm.getTimelineBarStartOffsetForQuarters()).toBe('left: 0;'); expect(vm.getTimelineBarStartOffsetForQuarters()).toBe('left: 0;');
}); });
it('returns `right: 8px;` when Epic startDate is in last timeframe month and endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframeQuarters[mockTimeframeQuarters.length - 1].range[1],
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarStartOffsetForQuarters()).toBe('right: 8px;');
});
it('returns proportional `left` value based on Epic startDate and days in the quarter', () => { it('returns proportional `left` value based on Epic startDate and days in the quarter', () => {
vm = createComponent({ vm = createComponent({
epic: Object.assign({}, mockEpic, { epic: Object.assign({}, mockEpic, {
...@@ -147,7 +140,7 @@ describe('QuartersPresetMixin', () => { ...@@ -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 Vue from 'vue';
import roadmapTimelineSectionComponent from 'ee/roadmap/components/roadmap_timeline_section.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 { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; 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 = ({ const createComponent = ({
presetType = PRESET_TYPES.MONTHS, presetType = PRESET_TYPES.MONTHS,
...@@ -52,7 +60,7 @@ describe('SectionMixin', () => { ...@@ -52,7 +60,7 @@ describe('SectionMixin', () => {
describe('sectionItemWidth', () => { describe('sectionItemWidth', () => {
it('returns calculated item width based on sectionShellWidth and timeframe size', () => { 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 Vue from 'vue';
import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.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 { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; 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 = ({ const createComponent = ({
presetType = PRESET_TYPES.WEEKS, presetType = PRESET_TYPES.WEEKS,
...@@ -59,7 +63,7 @@ describe('WeeksPresetMixin', () => { ...@@ -59,7 +63,7 @@ describe('WeeksPresetMixin', () => {
vm = createComponent({}); vm = createComponent({});
const lastDayOfWeek = vm.getLastDayOfWeek(mockTimeframeWeeks[0]); const lastDayOfWeek = vm.getLastDayOfWeek(mockTimeframeWeeks[0]);
expect(lastDayOfWeek.getDate()).toBe(30); expect(lastDayOfWeek.getDate()).toBe(23);
expect(lastDayOfWeek.getMonth()).toBe(11); expect(lastDayOfWeek.getMonth()).toBe(11);
expect(lastDayOfWeek.getFullYear()).toBe(2017); expect(lastDayOfWeek.getFullYear()).toBe(2017);
}); });
...@@ -95,14 +99,6 @@ describe('WeeksPresetMixin', () => { ...@@ -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', () => { describe('getTimelineBarStartOffsetForWeeks', () => {
it('returns empty string when Epic startDate is out of range', () => { it('returns empty string when Epic startDate is out of range', () => {
vm = createComponent({ vm = createComponent({
...@@ -133,19 +129,6 @@ describe('WeeksPresetMixin', () => { ...@@ -133,19 +129,6 @@ describe('WeeksPresetMixin', () => {
expect(vm.getTimelineBarStartOffsetForWeeks()).toBe('left: 0;'); expect(vm.getTimelineBarStartOffsetForWeeks()).toBe('left: 0;');
}); });
it('returns `right: 8px;` when Epic startDate is in last timeframe month and endDate is out of range', () => {
const startDate = new Date(mockTimeframeWeeks[mockTimeframeWeeks.length - 1].getTime());
startDate.setDate(startDate.getDate() + 1);
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate,
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarStartOffsetForWeeks()).toBe('right: 8px;');
});
it('returns proportional `left` value based on Epic startDate and days in the month', () => { it('returns proportional `left` value based on Epic startDate and days in the month', () => {
vm = createComponent({ vm = createComponent({
epic: Object.assign({}, mockEpic, { epic: Object.assign({}, mockEpic, {
...@@ -153,7 +136,7 @@ describe('WeeksPresetMixin', () => { ...@@ -153,7 +136,7 @@ describe('WeeksPresetMixin', () => {
}), }),
}); });
expect(vm.getTimelineBarStartOffsetForWeeks()).toContain('left: 60'); expect(vm.getTimelineBarStartOffsetForWeeks()).toContain('left: 51');
}); });
}); });
...@@ -168,7 +151,7 @@ describe('WeeksPresetMixin', () => { ...@@ -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 mockScrollBarSize = 15;
export const mockGroupId = 2; export const mockGroupId = 2;
...@@ -12,15 +6,85 @@ export const mockShellWidth = 2000; ...@@ -12,15 +6,85 @@ export const mockShellWidth = 2000;
export const mockItemWidth = 180; 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 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 mockNewEpicEndpoint = '/groups/gitlab-org/-/epics';
export const mockSvgPath = '/foo/bar.svg'; export const mockSvgPath = '/foo/bar.svg';
export const mockTimeframeQuarters = getTimeframeForQuartersView(new Date(2018, 0, 1)); export const mockTimeframeInitialDate = 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 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 = { export const mockEpic = {
id: 1, id: 1,
...@@ -188,3 +252,22 @@ export const rawEpics = [ ...@@ -188,3 +252,22 @@ export const rawEpics = [
web_url: '/groups/gitlab-org/marketing/-/epics/22', 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 axios from '~/lib/utils/axios_utils';
import RoadmapService from 'ee/roadmap/service/roadmap_service'; 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', () => { describe('RoadmapService', () => {
let service; let service;
beforeEach(() => { beforeEach(() => {
service = new RoadmapService(epicsPath); service = new RoadmapService({
initialEpicsPath: epicsPath,
epicsState: 'all',
filterQueryString: '',
basePath,
});
}); });
describe('getEpics', () => { describe('getEpics', () => {
...@@ -15,7 +25,30 @@ describe('RoadmapService', () => { ...@@ -15,7 +25,30 @@ describe('RoadmapService', () => {
spyOn(axios, 'get').and.stub(); spyOn(axios, 'get').and.stub();
service.getEpics(); 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 RoadmapStore from 'ee/roadmap/store/roadmap_store';
import { PRESET_TYPES } from 'ee/roadmap/constants'; import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { mockGroupId, mockTimeframeMonths, rawEpics } from '../mock_data';
import { PRESET_TYPES, EXTEND_AS } from 'ee/roadmap/constants';
import { mockGroupId, mockTimeframeInitialDate, rawEpics, mockSortedBy } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
describe('RoadmapStore', () => { describe('RoadmapStore', () => {
let store; let store;
beforeEach(() => { beforeEach(() => {
store = new RoadmapStore(mockGroupId, mockTimeframeMonths, PRESET_TYPES.MONTHS); store = new RoadmapStore({
groupId: mockGroupId,
timeframe: mockTimeframeMonths,
presetType: PRESET_TYPES.MONTHS,
sortedBy: mockSortedBy,
});
}); });
describe('constructor', () => { describe('constructor', () => {
it('initializes default state', () => { it('initializes default state', () => {
expect(store.state).toBeDefined(); expect(store.state).toBeDefined();
expect(Array.isArray(store.state.epics)).toBe(true); 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.currentGroupId).toBe(mockGroupId);
expect(store.state.timeframe).toBe(mockTimeframeMonths); expect(store.state.timeframe).toBe(mockTimeframeMonths);
expect(store.presetType).toBe(PRESET_TYPES.MONTHS); expect(store.presetType).toBe(PRESET_TYPES.MONTHS);
expect(store.sortedBy).toBe(mockSortedBy);
expect(store.timeframeStartDate).toBeDefined(); expect(store.timeframeStartDate).toBeDefined();
expect(store.timeframeEndDate).toBeDefined(); expect(store.timeframeEndDate).toBeDefined();
}); });
...@@ -23,9 +34,52 @@ describe('RoadmapStore', () => { ...@@ -23,9 +34,52 @@ describe('RoadmapStore', () => {
describe('setEpics', () => { describe('setEpics', () => {
it('sets Epics list to state while filtering out Epics with invalid dates', () => { it('sets Epics list to state while filtering out Epics with invalid dates', () => {
spyOn(RoadmapStore, 'filterInvalidEpics').and.callThrough();
store.setEpics(rawEpics); 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', () => { ...@@ -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', () => { describe('formatEpicDetails', () => {
const rawEpic = rawEpics[0]; const rawEpic = rawEpics[0];
it('returns formatted Epic object from raw Epic object', () => { 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.id).toBe(rawEpic.id);
expect(epic.name).toBe(rawEpic.name); expect(epic.name).toBe(rawEpic.name);
expect(epic.groupId).toBe(rawEpic.group_id); expect(epic.groupId).toBe(rawEpic.group_id);
......
import { import {
getTimeframeForQuartersView, getTimeframeForQuartersView,
extendTimeframeForQuartersView,
getTimeframeForMonthsView, getTimeframeForMonthsView,
extendTimeframeForMonthsView,
getTimeframeForWeeksView, getTimeframeForWeeksView,
extendTimeframeForWeeksView,
extendTimeframeForAvailableWidth,
getEpicsPathForPreset, getEpicsPathForPreset,
sortEpics,
} from 'ee/roadmap/utils/roadmap_utils'; } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants'; 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', () => { describe('getTimeframeForQuartersView', () => {
let timeframe; let timeframe;
...@@ -14,8 +34,8 @@ describe('getTimeframeForQuartersView', () => { ...@@ -14,8 +34,8 @@ describe('getTimeframeForQuartersView', () => {
timeframe = getTimeframeForQuartersView(new Date(2018, 0, 1)); timeframe = getTimeframeForQuartersView(new Date(2018, 0, 1));
}); });
it('returns timeframe with total of 6 quarters', () => { it('returns timeframe with total of 7 quarters', () => {
expect(timeframe.length).toBe(6); expect(timeframe.length).toBe(7);
}); });
it('each timeframe item has `quarterSequence`, `year` and `range` present', () => { it('each timeframe item has `quarterSequence`, `year` and `range` present', () => {
...@@ -26,15 +46,15 @@ describe('getTimeframeForQuartersView', () => { ...@@ -26,15 +46,15 @@ describe('getTimeframeForQuartersView', () => {
expect(Array.isArray(timeframeItem.range)).toBe(true); 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 timeframeItem = timeframe[0];
const expectedQuarter = { const expectedQuarter = {
0: { month: 9, date: 1 }, // 1 Oct 2017 0: { month: 6, date: 1 }, // 1 Jul 2017
1: { month: 10, date: 1 }, // 1 Nov 2017 1: { month: 7, date: 1 }, // 1 Aug 2017
2: { month: 11, date: 31 }, // 31 Dec 2017 2: { month: 8, date: 30 }, // 30 Sep 2017
}; };
expect(timeframeItem.quarterSequence).toEqual(4); expect(timeframeItem.quarterSequence).toEqual(3);
expect(timeframeItem.year).toEqual(2017); expect(timeframeItem.year).toEqual(2017);
timeframeItem.range.forEach((month, index) => { timeframeItem.range.forEach((month, index) => {
expect(month.getFullYear()).toBe(2017); expect(month.getFullYear()).toBe(2017);
...@@ -61,6 +81,44 @@ describe('getTimeframeForQuartersView', () => { ...@@ -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', () => { describe('getTimeframeForMonthsView', () => {
let timeframe; let timeframe;
...@@ -68,15 +126,15 @@ describe('getTimeframeForMonthsView', () => { ...@@ -68,15 +126,15 @@ describe('getTimeframeForMonthsView', () => {
timeframe = getTimeframeForMonthsView(new Date(2018, 0, 1)); timeframe = getTimeframeForMonthsView(new Date(2018, 0, 1));
}); });
it('returns timeframe with total of 7 months', () => { it('returns timeframe with total of 8 months', () => {
expect(timeframe.length).toBe(7); 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 timeframeItem = timeframe[0];
const expectedMonth = { const expectedMonth = {
year: 2017, year: 2017,
month: 11, month: 10,
date: 1, date: 1,
}; };
...@@ -99,6 +157,28 @@ describe('getTimeframeForMonthsView', () => { ...@@ -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', () => { describe('getTimeframeForWeeksView', () => {
let timeframe; let timeframe;
...@@ -106,16 +186,16 @@ describe('getTimeframeForWeeksView', () => { ...@@ -106,16 +186,16 @@ describe('getTimeframeForWeeksView', () => {
timeframe = getTimeframeForWeeksView(new Date(2018, 0, 1)); timeframe = getTimeframeForWeeksView(new Date(2018, 0, 1));
}); });
it('returns timeframe with total of 6 weeks', () => { it('returns timeframe with total of 7 weeks', () => {
expect(timeframe.length).toBe(6); 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 timeframeItem = timeframe[0];
const expectedMonth = { const expectedMonth = {
year: 2017, year: 2017,
month: 11, month: 11,
date: 24, date: 17,
}; };
expect(timeframeItem.getFullYear()).toBe(expectedMonth.year); expect(timeframeItem.getFullYear()).toBe(expectedMonth.year);
...@@ -137,6 +217,67 @@ describe('getTimeframeForWeeksView', () => { ...@@ -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', () => { describe('getEpicsPathForPreset', () => {
const basePath = '/groups/gitlab-org/-/epics.json'; const basePath = '/groups/gitlab-org/-/epics.json';
const filterQueryString = 'scope=all&utf8=✓&state=opened&label_name[]=Bug'; const filterQueryString = 'scope=all&utf8=✓&state=opened&label_name[]=Bug';
...@@ -149,7 +290,7 @@ describe('getEpicsPathForPreset', () => { ...@@ -149,7 +290,7 @@ describe('getEpicsPathForPreset', () => {
presetType: PRESET_TYPES.QUARTERS, 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', () => { it('returns epics path string based on provided basePath and timeframe for Months', () => {
...@@ -160,7 +301,7 @@ describe('getEpicsPathForPreset', () => { ...@@ -160,7 +301,7 @@ describe('getEpicsPathForPreset', () => {
presetType: PRESET_TYPES.MONTHS, 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', () => { it('returns epics path string based on provided basePath and timeframe for Weeks', () => {
...@@ -171,7 +312,7 @@ describe('getEpicsPathForPreset', () => { ...@@ -171,7 +312,7 @@ describe('getEpicsPathForPreset', () => {
presetType: PRESET_TYPES.WEEKS, 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', () => { it('returns epics path string while preserving filterQueryString', () => {
...@@ -184,7 +325,94 @@ describe('getEpicsPathForPreset', () => { ...@@ -184,7 +325,94 @@ describe('getEpicsPathForPreset', () => {
}); });
expect(epicsPath).toBe( 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 "" ...@@ -4538,22 +4538,10 @@ msgstr ""
msgid "GroupRoadmap|The roadmap shows the progress of your epics along a timeline" msgid "GroupRoadmap|The roadmap shows the progress of your epics along a timeline"
msgstr "" 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 "" 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}." msgid "GroupRoadmap|To widen your search, change or remove filters; 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}."
msgstr "" msgstr ""
msgid "GroupRoadmap|Until %{dateWord}" 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