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,28 +18,26 @@ export default { ...@@ -18,28 +18,26 @@ export default {
required: true, required: true,
}, },
}, },
data() {
const timeframeItem = new Date(this.timeframeItem.getTime());
const headerSubItems = new Array(7)
.fill()
.map(
(val, i) =>
new Date(
timeframeItem.getFullYear(),
timeframeItem.getMonth(),
timeframeItem.getDate() + i,
),
);
return {
headerSubItems,
};
},
computed: { computed: {
headerSubItems() {
const timeframeItem = new Date(this.timeframeItem.getTime());
const headerSubItems = new Array(7)
.fill()
.map(
(val, i) =>
new Date(
timeframeItem.getFullYear(),
timeframeItem.getMonth(),
timeframeItem.getDate() + i,
),
);
return headerSubItems;
},
hasToday() { 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);
} addEpics(epics) {
return filteredEpics; 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);
epicItem.startDate = startDate;
if (startDate <= timeframeStartDate) { epicItem.originalStartDate = startDate;
// 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;
}
} 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.endDate = endDate;
epicItem.endDateOutOfRange = true; epicItem.originalEndDate = endDate;
epicItem.originalEndDate = endDate;
epicItem.endDate = new Date(timeframeEndDate.getTime());
} else {
epicItem.endDate = 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),
});
}
}
} }
...@@ -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,19 +27,19 @@ describe('MonthsHeaderSubItemComponent', () => { ...@@ -24,19 +27,19 @@ 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', () => {
vm = createComponent({}); it('returns `headerSubItems` array of dates containing days of week from timeframeItem', () => {
vm = createComponent({});
expect(Array.isArray(vm.headerSubItems)).toBe(true); expect(Array.isArray(vm.headerSubItems)).toBe(true);
expect(vm.headerSubItems.length).toBe(7); expect(vm.headerSubItems.length).toBe(7);
vm.headerSubItems.forEach(subItem => { vm.headerSubItems.forEach(subItem => {
expect(subItem instanceof Date).toBe(true); expect(subItem instanceof Date).toBe(true);
});
}); });
}); });
});
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);
......
...@@ -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