Commit 71677b3e authored by Fatih Acet's avatar Fatih Acet

Merge branch '7325-roadmap-infinite-scrolling' into 'master'

Add support for auto-expanding Roadmap timeline on horizontal scroll

Closes #9119 and #7325

See merge request gitlab-org/gitlab-ee!9018
parents e5b90202 33acd91e
...@@ -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),
});
}
}
} }
import { getTimeframeWindowFrom, totalDaysInMonth } from '~/lib/utils/datetime_utility'; import { newDate, getTimeframeWindowFrom, totalDaysInMonth } from '~/lib/utils/datetime_utility';
import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants'; import { PRESET_TYPES, PRESET_DEFAULTS, EXTEND_AS, TIMELINE_CELL_MIN_WIDTH } from '../constants';
const monthsForQuarters = {
1: [0, 1, 2],
2: [3, 4, 5],
3: [6, 7, 8],
4: [9, 10, 11],
};
/** /**
* This method returns array of Objects representing Quarters based on provided initialDate * This method returns array of Objects representing Quarters based on provided initialDate
* *
* For eg; If initialDate is 15th Jan 2018 * For eg; If initialDate is 15th Jan 2018
* Then as per Roadmap specs, we need to show * Then as per Roadmap specs, we need to show
* 1 quarter before current quarter AND * 2 quarters before current quarters
* current quarter AND
* 4 quarters after current quarter * 4 quarters after current quarter
* thus, total of 6 quarters. * thus, total of 7 quarters (21 Months).
* *
* So returned array from this method will be; * So returned array from this method will be;
* [ * [
...@@ -47,37 +55,32 @@ import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants'; ...@@ -47,37 +55,32 @@ import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants';
* *
* @param {Date} initialDate * @param {Date} initialDate
*/ */
export const getTimeframeForQuartersView = (initialDate = new Date()) => { export const getTimeframeForQuartersView = (initialDate = new Date(), timeframe = []) => {
const startDate = initialDate; const startDate = newDate(initialDate);
startDate.setHours(0, 0, 0, 0); startDate.setHours(0, 0, 0, 0);
const monthsForQuarters = { if (!timeframe.length) {
1: [0, 1, 2], // Get current quarter for current month
2: [3, 4, 5], const currentQuarter = Math.floor((startDate.getMonth() + 3) / 3);
3: [6, 7, 8], // Get index of current month in current quarter
4: [9, 10, 11], // It could be 0, 1, 2 (i.e. first, second or third)
}; const currentMonthInCurrentQuarter = monthsForQuarters[currentQuarter].indexOf(
startDate.getMonth(),
// Get current quarter for current month );
const currentQuarter = Math.floor((startDate.getMonth() + 3) / 3);
// Get index of current month in current quarter
// It could be 0, 1, 2 (i.e. first, second or third)
const currentMonthInCurrentQuarter = monthsForQuarters[currentQuarter].indexOf(
startDate.getMonth(),
);
// To move start back to first month of previous quarter // To move start back to first month of 2 quarters prior by
// Adding quarter size (3) to month order will give us // adding quarter size (3 + 3) to month order will give us
// exact number of months we need to go back in time // exact number of months we need to go back in time
const startMonth = currentMonthInCurrentQuarter + 3; const startMonth = currentMonthInCurrentQuarter + 6;
const quartersTimeframe = []; // Move startDate to first month of previous quarter
// Move startDate to first month of previous quarter startDate.setMonth(startDate.getMonth() - startMonth);
startDate.setMonth(startDate.getMonth() - startMonth);
// Get timeframe for the length we determined for this preset // Get timeframe for the length we determined for this preset
// start from the startDate // start from the startDate
const timeframe = getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH); timeframe.push(...getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH));
}
const quartersTimeframe = [];
// Iterate over the timeframe and break it down // Iterate over the timeframe and break it down
// in chunks of quarters // in chunks of quarters
for (let i = 0; i < timeframe.length; i += 3) { for (let i = 0; i < timeframe.length; i += 3) {
...@@ -100,83 +103,246 @@ export const getTimeframeForQuartersView = (initialDate = new Date()) => { ...@@ -100,83 +103,246 @@ export const getTimeframeForQuartersView = (initialDate = new Date()) => {
return quartersTimeframe; return quartersTimeframe;
}; };
export const extendTimeframeForQuartersView = (initialDate = new Date(), length) => {
const startDate = newDate(initialDate);
startDate.setDate(1);
startDate.setMonth(startDate.getMonth() + (length > 0 ? 1 : -1));
const timeframe = getTimeframeWindowFrom(startDate, length);
return getTimeframeForQuartersView(startDate, length > 0 ? timeframe : timeframe.reverse());
};
/** /**
* This method returns array of Dates respresenting Months based on provided initialDate * This method returns array of Dates respresenting Months based on provided initialDate
* *
* For eg; If initialDate is 15th Jan 2018 * For eg; If initialDate is 15th Jan 2018
* Then as per Roadmap specs, we need to show * Then as per Roadmap specs, we need to show
* 1 month before current month AND * 2 months before current month,
* current month AND
* 5 months after current month * 5 months after current month
* thus, total of 7 months. * thus, total of 8 months.
* *
* So returned array from this method will be; * So returned array from this method will be;
* [ * [
* 1 Dec 2017, 1 Jan 2018, 1 Feb 2018, 1 Mar 2018, * 1 Nov 2017, 1 Dec 2017, 1 Jan 2018, 1 Feb 2018,
* 1 Apr 2018, 1 May 2018, 30 Jun 2018 * 1 Mar 2018, 1 Apr 2018, 1 May 2018, 30 Jun 2018
* ] * ]
* *
* @param {Date} initialDate * @param {Date} initialDate
*/ */
export const getTimeframeForMonthsView = (initialDate = new Date()) => { export const getTimeframeForMonthsView = (initialDate = new Date()) => {
const startDate = initialDate; const startDate = newDate(initialDate);
startDate.setHours(0, 0, 0, 0);
// Move startDate to a month prior to current month // Move startDate to a month prior to current month
startDate.setMonth(startDate.getMonth() - 1); startDate.setMonth(startDate.getMonth() - 2);
return getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.MONTHS.TIMEFRAME_LENGTH); return getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.MONTHS.TIMEFRAME_LENGTH);
}; };
export const extendTimeframeForMonthsView = (initialDate = new Date(), length) => {
const startDate = newDate(initialDate);
// When length is positive (which means extension is of type APPEND)
// Set initial date as first day of the month.
if (length > 0) {
startDate.setDate(1);
}
const timeframe = getTimeframeWindowFrom(startDate, length - 1).slice(1);
return length > 0 ? timeframe : timeframe.reverse();
};
/** /**
* This method returns array of Dates respresenting Months based on provided initialDate * This method returns array of Dates respresenting Months based on provided initialDate
* *
* For eg; If initialDate is 15th Jan 2018 * For eg; If initialDate is 15th Jan 2018
* Then as per Roadmap specs, we need to show * Then as per Roadmap specs, we need to show
* 1 week before current week AND * 2 weeks before current week,
* current week AND
* 4 weeks after current week * 4 weeks after current week
* thus, total of 6 weeks. * thus, total of 7 weeks.
* Note that week starts on Sunday * Note that week starts on Sunday
* *
* So returned array from this method will be; * So returned array from this method will be;
* [ * [
* 7 Jan 2018, 14 Jan 2018, 21 Jan 2018, * 31 Dec 2017, 7 Jan 2018, 14 Jan 2018, 21 Jan 2018,
* 28 Jan 2018, 4 Mar 2018, 11 Mar 2018 * 28 Jan 2018, 4 Mar 2018, 11 Mar 2018
* ] * ]
* *
* @param {Date} initialDate * @param {Date} initialDate
*/ */
export const getTimeframeForWeeksView = (initialDate = new Date()) => { export const getTimeframeForWeeksView = (initialDate = new Date()) => {
const startDate = initialDate; const startDate = newDate(initialDate);
startDate.setHours(0, 0, 0, 0); startDate.setHours(0, 0, 0, 0);
const dayOfWeek = startDate.getDay(); const dayOfWeek = startDate.getDay();
const daysToFirstDayOfPrevWeek = dayOfWeek + 7; const daysToFirstDayOfPrevWeek = dayOfWeek + 14;
const timeframe = []; const timeframe = [];
// Move startDate to first day (Sunday) of previous week // Move startDate to first day (Sunday) of 2 weeks prior
startDate.setDate(startDate.getDate() - daysToFirstDayOfPrevWeek); startDate.setDate(startDate.getDate() - daysToFirstDayOfPrevWeek);
// Iterate for the length of this preset // Iterate for the length of this preset
for (let i = 0; i < PRESET_DEFAULTS.WEEKS.TIMEFRAME_LENGTH; i += 1) { for (let i = 0; i < PRESET_DEFAULTS.WEEKS.TIMEFRAME_LENGTH; i += 1) {
// Push date to timeframe only when day is // Push date to timeframe only when day is
// first day (Sunday) of the week1 // first day (Sunday) of the week
if (startDate.getDay() === 0) { timeframe.push(newDate(startDate));
timeframe.push(new Date(startDate.getTime()));
} // Move date next Sunday
// Move date one day further startDate.setDate(startDate.getDate() + 7);
startDate.setDate(startDate.getDate() + 1);
} }
return timeframe; return timeframe;
}; };
export const getTimeframeForPreset = (presetType = PRESET_TYPES.MONTHS) => { export const extendTimeframeForWeeksView = (initialDate = new Date(), length) => {
const startDate = newDate(initialDate);
if (length < 0) {
startDate.setDate(startDate.getDate() + (length + 1) * 7);
} else {
startDate.setDate(startDate.getDate() + 7);
}
const timeframe = getTimeframeForWeeksView(startDate, length + 1);
return timeframe.slice(1);
};
export const extendTimeframeForPreset = ({
presetType = PRESET_TYPES.MONTHS,
extendAs = EXTEND_AS.PREPEND,
extendByLength = 0,
initialDate,
}) => {
if (presetType === PRESET_TYPES.QUARTERS) { if (presetType === PRESET_TYPES.QUARTERS) {
return getTimeframeForQuartersView(); const length = extendByLength || PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH;
return extendTimeframeForQuartersView(
initialDate,
extendAs === EXTEND_AS.PREPEND ? -length : length,
);
} else if (presetType === PRESET_TYPES.MONTHS) { } else if (presetType === PRESET_TYPES.MONTHS) {
return getTimeframeForMonthsView(); const length = extendByLength || PRESET_DEFAULTS.MONTHS.TIMEFRAME_LENGTH;
return extendTimeframeForMonthsView(
initialDate,
extendAs === EXTEND_AS.PREPEND ? -length : length,
);
} }
return getTimeframeForWeeksView();
const length = extendByLength || PRESET_DEFAULTS.WEEKS.TIMEFRAME_LENGTH;
return extendTimeframeForWeeksView(
initialDate,
extendAs === EXTEND_AS.PREPEND ? -length : length,
);
};
export const extendTimeframeForAvailableWidth = ({
timeframe,
timeframeStart,
timeframeEnd,
availableTimeframeWidth,
presetType,
}) => {
let timeframeLength = timeframe.length;
// Estimate how many more timeframe columns are needed
// to fill in extra screen space so that timeline becomes
// horizontally scrollable.
while (availableTimeframeWidth / timeframeLength > TIMELINE_CELL_MIN_WIDTH) {
timeframeLength += 1;
}
// We double the increaseLengthBy to make sure there's enough room
// to perform horizontal scroll without triggering timeframe extension
// on initial page load.
const increaseLengthBy = (timeframeLength - timeframe.length) * 2;
// If there are timeframe items to be added
// to make timeline scrollable, do as follows.
if (increaseLengthBy > 0) {
// Split length in 2 parts and get
// count for both prepend and append.
const prependBy = Math.floor(increaseLengthBy / 2);
const appendBy = Math.ceil(increaseLengthBy / 2);
if (prependBy) {
// Prepend the timeline with
// the count as given by prependBy
timeframe.unshift(
...extendTimeframeForPreset({
extendAs: EXTEND_AS.PREPEND,
initialDate: timeframeStart,
// In case of presetType `quarters`, length would represent
// number of months for total quarters, hence we do `* 3`.
extendByLength: presetType === PRESET_TYPES.QUARTERS ? prependBy * 3 : prependBy,
presetType,
}),
);
}
if (appendBy) {
// Append the timeline with
// the count as given by appendBy
timeframe.push(
...extendTimeframeForPreset({
extendAs: EXTEND_AS.APPEND,
initialDate: timeframeEnd,
// In case of presetType `quarters`, length would represent
// number of months for total quarters, hence we do `* 3`.
//
// For other preset types, we add `2` to appendBy to compensate for
// last item of original timeframe (month or week)
extendByLength: presetType === PRESET_TYPES.QUARTERS ? appendBy * 3 : appendBy + 2,
presetType,
}),
);
}
}
};
export const getTimeframeForPreset = (
presetType = PRESET_TYPES.MONTHS,
availableTimeframeWidth = 0,
) => {
let timeframe;
let timeframeStart;
let timeframeEnd;
// Get timeframe based on presetType and
// extract timeframeStart and timeframeEnd
// date objects
if (presetType === PRESET_TYPES.QUARTERS) {
timeframe = getTimeframeForQuartersView();
[timeframeStart] = timeframe[0].range;
// eslint-disable-next-line prefer-destructuring
timeframeEnd = timeframe[timeframe.length - 1].range[2];
} else if (presetType === PRESET_TYPES.MONTHS) {
timeframe = getTimeframeForMonthsView();
[timeframeStart] = timeframe;
timeframeEnd = timeframe[timeframe.length - 1];
} else {
timeframe = getTimeframeForWeeksView();
timeframeStart = newDate(timeframe[0]);
timeframeEnd = newDate(timeframe[timeframe.length - 1]);
timeframeStart.setDate(timeframeStart.getDate() - 7); // Move date back by a week
timeframeEnd.setDate(timeframeEnd.getDate() + 7); // Move date ahead by a week
}
// Extend timeframe on initial load to ensure
// timeline is horizontally scrollable in all
// screen sizes.
extendTimeframeForAvailableWidth({
timeframe,
timeframeStart,
timeframeEnd,
availableTimeframeWidth,
presetType,
});
return timeframe;
}; };
export const getEpicsPathForPreset = ({ export const getEpicsPathForPreset = ({
...@@ -184,7 +350,7 @@ export const getEpicsPathForPreset = ({ ...@@ -184,7 +350,7 @@ export const getEpicsPathForPreset = ({
filterQueryString = '', filterQueryString = '',
presetType = '', presetType = '',
timeframe = [], timeframe = [],
state = 'all', epicsState = 'all',
}) => { }) => {
let start; let start;
let end; let end;
...@@ -208,13 +374,13 @@ export const getEpicsPathForPreset = ({ ...@@ -208,13 +374,13 @@ export const getEpicsPathForPreset = ({
end = lastTimeframe; end = lastTimeframe;
} else if (presetType === PRESET_TYPES.WEEKS) { } else if (presetType === PRESET_TYPES.WEEKS) {
start = firstTimeframe; start = firstTimeframe;
end = new Date(lastTimeframe.getTime()); end = newDate(lastTimeframe);
end.setDate(end.getDate() + 6); end.setDate(end.getDate() + 6);
} }
const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`; const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`;
const endDate = `${end.getFullYear()}-${end.getMonth() + 1}-${end.getDate()}`; const endDate = `${end.getFullYear()}-${end.getMonth() + 1}-${end.getDate()}`;
epicsPath += `?state=${state}&start_date=${startDate}&end_date=${endDate}`; epicsPath += `?state=${epicsState}&start_date=${startDate}&end_date=${endDate}`;
if (filterQueryString) { if (filterQueryString) {
epicsPath += `&${filterQueryString}`; epicsPath += `&${filterQueryString}`;
...@@ -222,3 +388,43 @@ export const getEpicsPathForPreset = ({ ...@@ -222,3 +388,43 @@ export const getEpicsPathForPreset = ({
return epicsPath; return epicsPath;
}; };
export const sortEpics = (epics, sortedBy) => {
const sortByStartDate = sortedBy.indexOf('start_date') > -1;
const sortOrderAsc = sortedBy.indexOf('asc') > -1;
epics.sort((a, b) => {
let aDate;
let bDate;
if (sortByStartDate) {
aDate = a.startDate;
if (a.startDateOutOfRange) {
aDate = a.originalStartDate;
}
bDate = b.startDate;
if (b.startDateOutOfRange) {
bDate = b.originalStartDate;
}
} else {
aDate = a.endDate;
if (a.endDateOutOfRange) {
aDate = a.originalEndDate;
}
bDate = b.endDate;
if (b.endDateOutOfRange) {
bDate = b.originalEndDate;
}
}
// Sort in ascending or descending order
if (aDate.getTime() < bDate.getTime()) {
return sortOrderAsc ? -1 : 1;
} else if (aDate.getTime() > bDate.getTime()) {
return sortOrderAsc ? 1 : -1;
}
return 0;
});
};
...@@ -20,6 +20,36 @@ $column-right-gradient: linear-gradient( ...@@ -20,6 +20,36 @@ $column-right-gradient: linear-gradient(
$roadmap-gradient-gray 100% $roadmap-gradient-gray 100%
); );
@keyframes colorTransitionDetailsCell {
from {
background-color: $blue-100;
}
to {
background-color: $white-light;
}
}
@keyframes fadeInDetails {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeinTimelineBar {
from {
opacity: 0;
}
to {
opacity: 0.75;
}
}
@mixin roadmap-scroll-mixin { @mixin roadmap-scroll-mixin {
height: $grid-size; height: $grid-size;
width: $details-cell-width; width: $details-cell-width;
...@@ -229,6 +259,12 @@ $column-right-gradient: linear-gradient( ...@@ -229,6 +259,12 @@ $column-right-gradient: linear-gradient(
} }
} }
&.newly-added-epic {
.epic-details-cell {
animation: colorTransitionDetailsCell 3s;
}
}
.epic-details-cell, .epic-details-cell,
.epic-timeline-cell { .epic-timeline-cell {
box-sizing: border-box; box-sizing: border-box;
...@@ -251,6 +287,11 @@ $column-right-gradient: linear-gradient( ...@@ -251,6 +287,11 @@ $column-right-gradient: linear-gradient(
height: $item-height; height: $item-height;
} }
.epic-title,
.epic-group-timeframe {
animation: fadeInDetails 1s;
}
.epic-title { .epic-title {
display: table; display: table;
table-layout: fixed; table-layout: fixed;
...@@ -294,25 +335,12 @@ $column-right-gradient: linear-gradient( ...@@ -294,25 +335,12 @@ $column-right-gradient: linear-gradient(
background-color: $blue-500; background-color: $blue-500;
border-radius: $border-radius-default; border-radius: $border-radius-default;
opacity: 0.75; opacity: 0.75;
animation: fadeinTimelineBar 1s;
&:hover { &:hover {
opacity: 1; opacity: 1;
} }
&.start-date-outside::before,
&.end-date-outside::after {
content: "";
position: absolute;
top: 0;
height: 100%;
}
&.start-date-outside::before,
&.end-date-outside::after {
border-top: 12px solid transparent;
border-bottom: 12px solid transparent;
}
&.start-date-undefined { &.start-date-undefined {
background: linear-gradient( background: linear-gradient(
to right, to right,
...@@ -330,31 +358,6 @@ $column-right-gradient: linear-gradient( ...@@ -330,31 +358,6 @@ $column-right-gradient: linear-gradient(
$roadmap-gradient-gray 100% $roadmap-gradient-gray 100%
); );
} }
&.start-date-outside {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
&::before {
left: -$grid-size;
border-right: $grid-size solid $blue-500;
}
}
&.end-date-outside {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
&::after {
right: -$grid-size;
border-left: $grid-size solid $blue-500;
}
}
&.start-date-outside,
&.start-date-undefined.end-date-outside {
left: $grid-size;
}
} }
&:last-child { &:last-child {
......
...@@ -7,8 +7,8 @@ ...@@ -7,8 +7,8 @@
- has_filters_applied = params[:label_name].present? || params[:author_username].present? || params[:search].present? - has_filters_applied = params[:label_name].present? || params[:author_username].present? || params[:search].present?
- if @epics_count != 0 - if @epics_count != 0
= render 'shared/epic/search_bar', type: :epics, show_roadmap_presets: true = render 'shared/epic/search_bar', type: :epics, show_roadmap_presets: true, hide_extra_sort_options: true
#js-roadmap{ data: { epics_path: group_epics_path(@group, format: :json), group_id: @group.id, empty_state_illustration: image_path('illustrations/epics/roadmap.svg'), has_filters_applied: "#{has_filters_applied}", new_epic_endpoint: group_epics_path(@group), preset_type: roadmap_layout, epics_state: @epics_state } } #js-roadmap{ data: { epics_path: group_epics_path(@group, format: :json), group_id: @group.id, empty_state_illustration: image_path('illustrations/epics/roadmap.svg'), has_filters_applied: "#{has_filters_applied}", new_epic_endpoint: group_epics_path(@group), preset_type: roadmap_layout, epics_state: @epics_state, sorted_by: @sort } }
- else - else
= render 'shared/empty_states/roadmap' = render 'shared/empty_states/roadmap'
- type = local_assigns.fetch(:type) - type = local_assigns.fetch(:type)
- hide_sort_dropdown = local_assigns.fetch(:hide_sort_dropdown, false) - hide_sort_dropdown = local_assigns.fetch(:hide_sort_dropdown, false)
- show_roadmap_presets = local_assigns.fetch(:show_roadmap_presets, false) - show_roadmap_presets = local_assigns.fetch(:show_roadmap_presets, false)
- hide_extra_sort_options = local_assigns.fetch(:hide_extra_sort_options, false)
- preset_layout = roadmap_layout - preset_layout = roadmap_layout
- is_quarters = preset_layout == "QUARTERS" - is_quarters = preset_layout == "QUARTERS"
- is_months = preset_layout == "MONTHS" - is_months = preset_layout == "MONTHS"
...@@ -82,4 +83,4 @@ ...@@ -82,4 +83,4 @@
= icon('times') = icon('times')
- unless hide_sort_dropdown - unless hide_sort_dropdown
.filter-dropdown-container .filter-dropdown-container
= render 'shared/epic/sort_dropdown' = render 'shared/epic/sort_dropdown', hide_extra_sort_options: hide_extra_sort_options
- hide_extra_sort_options = local_assigns.fetch(:hide_extra_sort_options, false)
- sorted_by = epics_sort_options_hash[@sort] - sorted_by = epics_sort_options_hash[@sort]
.dropdown.inline.prepend-left-10 .dropdown.inline.prepend-left-10
...@@ -7,8 +8,9 @@ ...@@ -7,8 +8,9 @@
= icon('chevron-down') = icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li %li
= sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_recently_created), sorted_by) - if !hide_extra_sort_options
= sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated), sorted_by) = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_recently_created), sorted_by)
= sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated), sorted_by)
= sortable_item(sort_title_start_date, page_filter_path(sort: sort_value_start_date_soon), sorted_by) = sortable_item(sort_title_start_date, page_filter_path(sort: sort_value_start_date_soon), sorted_by)
= sortable_item(sort_title_end_date, page_filter_path(sort: sort_value_end_date), sorted_by) = sortable_item(sort_title_end_date, page_filter_path(sort: sort_value_end_date), sorted_by)
= sort_order_button(@sort) = sort_order_button(@sort)
---
title: Add support for auto-expanding Roadmap timeline on horizontal scroll
merge_request: 9018
author:
type: added
...@@ -55,9 +55,7 @@ describe 'group epic roadmap', :js do ...@@ -55,9 +55,7 @@ describe 'group epic roadmap', :js do
expect(page).to have_css('.filter-dropdown-container') expect(page).to have_css('.filter-dropdown-container')
find('.dropdown-toggle').click find('.dropdown-toggle').click
page.within('.dropdown-menu') do page.within('.dropdown-menu') do
expect(page).to have_selector('li a', count: 4) expect(page).to have_selector('li a', count: 2)
expect(page).to have_content('Created date')
expect(page).to have_content('Last updated')
expect(page).to have_content('Start date') expect(page).to have_content('Start date')
expect(page).to have_content('Due date') expect(page).to have_content('Due date')
end end
......
...@@ -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);
......
import { import {
getTimeframeForQuartersView, getTimeframeForQuartersView,
extendTimeframeForQuartersView,
getTimeframeForMonthsView, getTimeframeForMonthsView,
extendTimeframeForMonthsView,
getTimeframeForWeeksView, getTimeframeForWeeksView,
extendTimeframeForWeeksView,
extendTimeframeForAvailableWidth,
getEpicsPathForPreset, getEpicsPathForPreset,
sortEpics,
} from 'ee/roadmap/utils/roadmap_utils'; } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants'; import { PRESET_TYPES } from 'ee/roadmap/constants';
import {
mockTimeframeInitialDate,
mockTimeframeQuartersPrepend,
mockTimeframeQuartersAppend,
mockTimeframeMonthsPrepend,
mockTimeframeMonthsAppend,
mockTimeframeWeeksPrepend,
mockTimeframeWeeksAppend,
mockUnsortedEpics,
} from '../mock_data';
const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
describe('getTimeframeForQuartersView', () => { describe('getTimeframeForQuartersView', () => {
let timeframe; let timeframe;
...@@ -14,8 +34,8 @@ describe('getTimeframeForQuartersView', () => { ...@@ -14,8 +34,8 @@ describe('getTimeframeForQuartersView', () => {
timeframe = getTimeframeForQuartersView(new Date(2018, 0, 1)); timeframe = getTimeframeForQuartersView(new Date(2018, 0, 1));
}); });
it('returns timeframe with total of 6 quarters', () => { it('returns timeframe with total of 7 quarters', () => {
expect(timeframe.length).toBe(6); expect(timeframe.length).toBe(7);
}); });
it('each timeframe item has `quarterSequence`, `year` and `range` present', () => { it('each timeframe item has `quarterSequence`, `year` and `range` present', () => {
...@@ -26,15 +46,15 @@ describe('getTimeframeForQuartersView', () => { ...@@ -26,15 +46,15 @@ describe('getTimeframeForQuartersView', () => {
expect(Array.isArray(timeframeItem.range)).toBe(true); expect(Array.isArray(timeframeItem.range)).toBe(true);
}); });
it('first timeframe item refers to quarter prior to current quarter', () => { it('first timeframe item refers to 2 quarters prior to current quarter', () => {
const timeframeItem = timeframe[0]; const timeframeItem = timeframe[0];
const expectedQuarter = { const expectedQuarter = {
0: { month: 9, date: 1 }, // 1 Oct 2017 0: { month: 6, date: 1 }, // 1 Jul 2017
1: { month: 10, date: 1 }, // 1 Nov 2017 1: { month: 7, date: 1 }, // 1 Aug 2017
2: { month: 11, date: 31 }, // 31 Dec 2017 2: { month: 8, date: 30 }, // 30 Sep 2017
}; };
expect(timeframeItem.quarterSequence).toEqual(4); expect(timeframeItem.quarterSequence).toEqual(3);
expect(timeframeItem.year).toEqual(2017); expect(timeframeItem.year).toEqual(2017);
timeframeItem.range.forEach((month, index) => { timeframeItem.range.forEach((month, index) => {
expect(month.getFullYear()).toBe(2017); expect(month.getFullYear()).toBe(2017);
...@@ -61,6 +81,44 @@ describe('getTimeframeForQuartersView', () => { ...@@ -61,6 +81,44 @@ describe('getTimeframeForQuartersView', () => {
}); });
}); });
describe('extendTimeframeForQuartersView', () => {
it('returns extended timeframe into the past from current timeframe startDate', () => {
const initialDate = mockTimeframeQuarters[0].range[0];
const extendedTimeframe = extendTimeframeForQuartersView(initialDate, -9);
expect(extendedTimeframe.length).toBe(mockTimeframeQuartersPrepend.length);
extendedTimeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.year).toBe(mockTimeframeQuartersPrepend[index].year);
expect(timeframeItem.quarterSequence).toBe(
mockTimeframeQuartersPrepend[index].quarterSequence,
);
timeframeItem.range.forEach((rangeItem, j) => {
expect(rangeItem.getTime()).toBe(mockTimeframeQuartersPrepend[index].range[j].getTime());
});
});
});
it('returns extended timeframe into the future from current timeframe endDate', () => {
const initialDate = mockTimeframeQuarters[mockTimeframeQuarters.length - 1].range[2];
const extendedTimeframe = extendTimeframeForQuartersView(initialDate, 9);
expect(extendedTimeframe.length).toBe(mockTimeframeQuartersAppend.length);
extendedTimeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.year).toBe(mockTimeframeQuartersAppend[index].year);
expect(timeframeItem.quarterSequence).toBe(
mockTimeframeQuartersAppend[index].quarterSequence,
);
timeframeItem.range.forEach((rangeItem, j) => {
expect(rangeItem.getTime()).toBe(mockTimeframeQuartersAppend[index].range[j].getTime());
});
});
});
});
describe('getTimeframeForMonthsView', () => { describe('getTimeframeForMonthsView', () => {
let timeframe; let timeframe;
...@@ -68,15 +126,15 @@ describe('getTimeframeForMonthsView', () => { ...@@ -68,15 +126,15 @@ describe('getTimeframeForMonthsView', () => {
timeframe = getTimeframeForMonthsView(new Date(2018, 0, 1)); timeframe = getTimeframeForMonthsView(new Date(2018, 0, 1));
}); });
it('returns timeframe with total of 7 months', () => { it('returns timeframe with total of 8 months', () => {
expect(timeframe.length).toBe(7); expect(timeframe.length).toBe(8);
}); });
it('first timeframe item refers to month prior to current month', () => { it('first timeframe item refers to 2 months prior to current month', () => {
const timeframeItem = timeframe[0]; const timeframeItem = timeframe[0];
const expectedMonth = { const expectedMonth = {
year: 2017, year: 2017,
month: 11, month: 10,
date: 1, date: 1,
}; };
...@@ -99,6 +157,28 @@ describe('getTimeframeForMonthsView', () => { ...@@ -99,6 +157,28 @@ describe('getTimeframeForMonthsView', () => {
}); });
}); });
describe('extendTimeframeForMonthsView', () => {
it('returns extended timeframe into the past from current timeframe startDate', () => {
const initialDate = mockTimeframeMonths[0];
const extendedTimeframe = extendTimeframeForMonthsView(initialDate, -6);
expect(extendedTimeframe.length).toBe(mockTimeframeMonthsPrepend.length);
extendedTimeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.getTime()).toBe(mockTimeframeMonthsPrepend[index].getTime());
});
});
it('returns extended timeframe into the future from current timeframe endDate', () => {
const initialDate = mockTimeframeMonths[mockTimeframeMonths.length - 1];
const extendedTimeframe = extendTimeframeForMonthsView(initialDate, 7);
expect(extendedTimeframe.length).toBe(mockTimeframeMonthsAppend.length);
extendedTimeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.getTime()).toBe(mockTimeframeMonthsAppend[index].getTime());
});
});
});
describe('getTimeframeForWeeksView', () => { describe('getTimeframeForWeeksView', () => {
let timeframe; let timeframe;
...@@ -106,16 +186,16 @@ describe('getTimeframeForWeeksView', () => { ...@@ -106,16 +186,16 @@ describe('getTimeframeForWeeksView', () => {
timeframe = getTimeframeForWeeksView(new Date(2018, 0, 1)); timeframe = getTimeframeForWeeksView(new Date(2018, 0, 1));
}); });
it('returns timeframe with total of 6 weeks', () => { it('returns timeframe with total of 7 weeks', () => {
expect(timeframe.length).toBe(6); expect(timeframe.length).toBe(7);
}); });
it('first timeframe item refers to week prior to current week', () => { it('first timeframe item refers to 2 weeks prior to current week', () => {
const timeframeItem = timeframe[0]; const timeframeItem = timeframe[0];
const expectedMonth = { const expectedMonth = {
year: 2017, year: 2017,
month: 11, month: 11,
date: 24, date: 17,
}; };
expect(timeframeItem.getFullYear()).toBe(expectedMonth.year); expect(timeframeItem.getFullYear()).toBe(expectedMonth.year);
...@@ -137,6 +217,67 @@ describe('getTimeframeForWeeksView', () => { ...@@ -137,6 +217,67 @@ describe('getTimeframeForWeeksView', () => {
}); });
}); });
describe('extendTimeframeForWeeksView', () => {
it('returns extended timeframe into the past from current timeframe startDate', () => {
const extendedTimeframe = extendTimeframeForWeeksView(mockTimeframeWeeks[0], -6); // initialDate: 17 Dec 2017
expect(extendedTimeframe.length).toBe(mockTimeframeWeeksPrepend.length);
extendedTimeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.getTime()).toBe(mockTimeframeWeeksPrepend[index].getTime());
});
});
it('returns extended timeframe into the future from current timeframe endDate', () => {
const extendedTimeframe = extendTimeframeForWeeksView(
mockTimeframeWeeks[mockTimeframeWeeks.length - 1], // initialDate: 28 Jan 2018
6,
);
expect(extendedTimeframe.length).toBe(mockTimeframeWeeksAppend.length);
extendedTimeframe.forEach((timeframeItem, index) => {
expect(timeframeItem.getTime()).toBe(mockTimeframeWeeksAppend[index].getTime());
});
});
});
describe('extendTimeframeForAvailableWidth', () => {
let timeframe;
let timeframeStart;
let timeframeEnd;
beforeEach(() => {
timeframe = mockTimeframeMonths.slice();
[timeframeStart] = timeframe;
timeframeEnd = timeframe[timeframe.length - 1];
});
it('should not extend `timeframe` when availableTimeframeWidth is small enough to force horizontal scrollbar to show up', () => {
extendTimeframeForAvailableWidth({
availableTimeframeWidth: 100,
presetType: PRESET_TYPES.MONTHS,
timeframe,
timeframeStart,
timeframeEnd,
});
expect(timeframe.length).toBe(mockTimeframeMonths.length);
});
it('should extend `timeframe` when availableTimeframeWidth is large enough that it can fit more timeframe items to show up horizontal scrollbar', () => {
extendTimeframeForAvailableWidth({
availableTimeframeWidth: 2000,
presetType: PRESET_TYPES.MONTHS,
timeframe,
timeframeStart,
timeframeEnd,
});
expect(timeframe.length).toBe(16);
expect(timeframe[0].getTime()).toBe(1498867200000); // 1 July 2017
expect(timeframe[timeframe.length - 1].getTime()).toBe(1540944000000); // 31 Oct 2018
});
});
describe('getEpicsPathForPreset', () => { describe('getEpicsPathForPreset', () => {
const basePath = '/groups/gitlab-org/-/epics.json'; const basePath = '/groups/gitlab-org/-/epics.json';
const filterQueryString = 'scope=all&utf8=✓&state=opened&label_name[]=Bug'; const filterQueryString = 'scope=all&utf8=✓&state=opened&label_name[]=Bug';
...@@ -149,7 +290,7 @@ describe('getEpicsPathForPreset', () => { ...@@ -149,7 +290,7 @@ describe('getEpicsPathForPreset', () => {
presetType: PRESET_TYPES.QUARTERS, presetType: PRESET_TYPES.QUARTERS,
}); });
expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-10-1&end_date=2019-3-31`); expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-7-1&end_date=2019-3-31`);
}); });
it('returns epics path string based on provided basePath and timeframe for Months', () => { it('returns epics path string based on provided basePath and timeframe for Months', () => {
...@@ -160,7 +301,7 @@ describe('getEpicsPathForPreset', () => { ...@@ -160,7 +301,7 @@ describe('getEpicsPathForPreset', () => {
presetType: PRESET_TYPES.MONTHS, presetType: PRESET_TYPES.MONTHS,
}); });
expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-12-1&end_date=2018-6-30`); expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-11-1&end_date=2018-6-30`);
}); });
it('returns epics path string based on provided basePath and timeframe for Weeks', () => { it('returns epics path string based on provided basePath and timeframe for Weeks', () => {
...@@ -171,7 +312,7 @@ describe('getEpicsPathForPreset', () => { ...@@ -171,7 +312,7 @@ describe('getEpicsPathForPreset', () => {
presetType: PRESET_TYPES.WEEKS, presetType: PRESET_TYPES.WEEKS,
}); });
expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-12-24&end_date=2018-2-3`); expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-12-17&end_date=2018-2-3`);
}); });
it('returns epics path string while preserving filterQueryString', () => { it('returns epics path string while preserving filterQueryString', () => {
...@@ -184,7 +325,94 @@ describe('getEpicsPathForPreset', () => { ...@@ -184,7 +325,94 @@ describe('getEpicsPathForPreset', () => {
}); });
expect(epicsPath).toBe( expect(epicsPath).toBe(
`${basePath}?state=all&start_date=2017-12-1&end_date=2018-6-30&scope=all&utf8=✓&state=opened&label_name[]=Bug`, `${basePath}?state=all&start_date=2017-11-1&end_date=2018-6-30&scope=all&utf8=✓&state=opened&label_name[]=Bug`,
); );
}); });
it('returns epics path string containing epicsState', () => {
const epicsState = 'opened';
const timeframe = getTimeframeForMonthsView(new Date(2018, 0, 1));
const epicsPath = getEpicsPathForPreset({
presetType: PRESET_TYPES.MONTHS,
basePath,
timeframe,
epicsState,
});
expect(epicsPath).toContain(`state=${epicsState}`);
});
});
describe('sortEpics', () => {
it('sorts epics list by startDate in ascending order when `sortedBy` param is `start_date_asc`', () => {
const epics = mockUnsortedEpics.slice();
const sortedOrder = [
new Date(2014, 3, 17),
new Date(2015, 5, 8),
new Date(2017, 2, 12),
new Date(2019, 4, 12),
];
sortEpics(epics, 'start_date_asc');
expect(epics.length).toBe(mockUnsortedEpics.length);
epics.forEach((epic, index) => {
expect(epic.startDate.getTime()).toBe(sortedOrder[index].getTime());
});
});
it('sorts epics list by startDate in descending order when `sortedBy` param is `start_date_desc`', () => {
const epics = mockUnsortedEpics.slice();
const sortedOrder = [
new Date(2019, 4, 12),
new Date(2017, 2, 12),
new Date(2015, 5, 8),
new Date(2014, 3, 17),
];
sortEpics(epics, 'start_date_desc');
expect(epics.length).toBe(mockUnsortedEpics.length);
epics.forEach((epic, index) => {
expect(epic.startDate.getTime()).toBe(sortedOrder[index].getTime());
});
});
it('sorts epics list by endDate in ascending order when `sortedBy` param is `end_date_asc`', () => {
const epics = mockUnsortedEpics.slice();
const sortedOrder = [
new Date(2015, 7, 15),
new Date(2016, 3, 1),
new Date(2017, 7, 20),
new Date(2019, 7, 30),
];
sortEpics(epics, 'end_date_asc');
expect(epics.length).toBe(mockUnsortedEpics.length);
epics.forEach((epic, index) => {
expect(epic.endDate.getTime()).toBe(sortedOrder[index].getTime());
});
});
it('sorts epics list by endDate in descending order when `sortedBy` param is `end_date_desc`', () => {
const epics = mockUnsortedEpics.slice();
const sortedOrder = [
new Date(2019, 7, 30),
new Date(2017, 7, 20),
new Date(2016, 3, 1),
new Date(2015, 7, 15),
];
sortEpics(epics, 'end_date_desc');
expect(epics.length).toBe(mockUnsortedEpics.length);
epics.forEach((epic, index) => {
expect(epic.endDate.getTime()).toBe(sortedOrder[index].getTime());
});
});
}); });
...@@ -4541,22 +4541,10 @@ msgstr "" ...@@ -4541,22 +4541,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