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