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

Add support for auto-expanding Roadmap timeline on horizontal scroll

Makes Roadmap timeline dynamic to be always
scrollable on any screen size.
Fetches new epics as user scrolls into past or future and renders it.
parent 989b363e
......@@ -6,6 +6,9 @@ import { s__ } from '~/locale';
import { GlLoadingIcon } from '@gitlab/ui';
import epicsListEmpty from './epics_list_empty.vue';
import roadmapShell from './roadmap_shell.vue';
import eventHub from '../event_hub';
import { EXTEND_AS } from '../constants';
export default {
components: {
......@@ -96,6 +99,28 @@ export default {
Flash(s__('GroupRoadmap|Something went wrong while fetching epics'));
});
},
fetchEpicsForTimeframe({ timeframe, roadmapTimelineEl, extendType }) {
this.hasError = false;
this.service
.getEpicsForTimeframe(this.presetType, timeframe)
.then(res => res.data)
.then(epics => {
if (epics.length) {
this.store.addEpics(epics);
this.$nextTick(() => {
// Re-render timeline bars with updated timeline
eventHub.$emit('refreshTimeline', {
height: window.innerHeight - roadmapTimelineEl.offsetTop,
todayBarReady: extendType === EXTEND_AS.PREPEND,
});
});
}
})
.catch(() => {
this.hasError = true;
Flash(s__('GroupRoadmap|Something went wrong while fetching epics'));
});
},
/**
* Roadmap view works with absolute sizing and positioning
* of following child components of RoadmapShell;
......@@ -117,6 +142,49 @@ export default {
this.isLoading = false;
}, 200)();
},
/**
* Once timeline is expanded (either with prepend or append)
* We need performing following actions;
*
* 1. Reset start and end edges of the timeline for
* infinite scrolling to continue further.
* 2. Re-render timeline bars to account for
* updated timeframe.
* 3. In case of prepending timeframe,
* reset scroll-position (due to DOM prepend).
*/
processExtendedTimeline({ extendType = EXTEND_AS.PREPEND, roadmapTimelineEl, itemsCount = 0 }) {
// Re-render timeline bars with updated timeline
eventHub.$emit('refreshTimeline', {
height: window.innerHeight - roadmapTimelineEl.offsetTop,
todayBarReady: extendType === EXTEND_AS.PREPEND,
});
if (extendType === EXTEND_AS.PREPEND) {
// When DOM is prepended with elements
// we compensate the scrolling for added elements' width
roadmapTimelineEl.parentElement.scrollBy(
roadmapTimelineEl.querySelector('.timeline-header-item').clientWidth * itemsCount,
0,
);
}
},
handleScrollToExtend(roadmapTimelineEl, extendType = EXTEND_AS.PREPEND) {
const timeframe = this.store.extendTimeframe(extendType);
this.$nextTick(() => {
this.processExtendedTimeline({
itemsCount: timeframe ? timeframe.length : 0,
extendType,
roadmapTimelineEl,
});
this.fetchEpicsForTimeframe({
timeframe,
roadmapTimelineEl,
extendType,
});
});
},
},
};
</script>
......@@ -135,6 +203,8 @@ export default {
:epics="epics"
:timeframe="timeframe"
:current-group-id="currentGroupId"
@onScrollToStart="handleScrollToExtend"
@onScrollToEnd="handleScrollToExtend"
/>
<epics-list-empty
v-if="isEpicsListEmpty"
......
<script>
import _ from 'underscore';
import epicItemDetails from './epic_item_details.vue';
import epicItemTimeline from './epic_item_timeline.vue';
import { EPIC_HIGHLIGHT_REMOVE_AFTER } from '../constants';
export default {
components: {
epicItemDetails,
......@@ -33,11 +37,36 @@ export default {
required: true,
},
},
updated() {
this.removeHighlight();
},
methods: {
/**
* When new epics are added to the list on
* timeline scroll, we set `newEpic` flag
* as true and then use it in template
* to set `newly-added-epic` class for
* highlighting epic using CSS animations
*
* Once animation is complete, we need to
* remove the flag so that animation is not
* replayed when list is re-rendered.
*/
removeHighlight() {
if (this.epic.newEpic) {
this.$nextTick(() => {
_.delay(() => {
this.epic.newEpic = false;
}, EPIC_HIGHLIGHT_REMOVE_AFTER);
});
}
},
},
};
</script>
<template>
<div class="epics-list-item clearfix">
<div :class="{ 'newly-added-epic': epic.newEpic }" class="epics-list-item clearfix">
<epic-item-details :epic="epic" :current-group-id="currentGroupId" />
<epic-item-timeline
v-for="(timeframeItem, index) in timeframe"
......
......@@ -5,13 +5,9 @@ import QuartersPresetMixin from '../mixins/quarters_preset_mixin';
import MonthsPresetMixin from '../mixins/months_preset_mixin';
import WeeksPresetMixin from '../mixins/weeks_preset_mixin';
import {
EPIC_DETAILS_CELL_WIDTH,
TIMELINE_CELL_MIN_WIDTH,
TIMELINE_END_OFFSET_FULL,
TIMELINE_END_OFFSET_HALF,
PRESET_TYPES,
} from '../constants';
import eventHub from '../event_hub';
import { EPIC_DETAILS_CELL_WIDTH, TIMELINE_CELL_MIN_WIDTH, PRESET_TYPES } from '../constants';
export default {
directives: {
......@@ -66,6 +62,12 @@ export default {
this.renderTimelineBar();
},
},
mounted() {
eventHub.$on('refreshTimeline', this.renderTimelineBar);
},
beforeDestroy() {
eventHub.$off('refreshTimeline', this.renderTimelineBar);
},
methods: {
/**
* Gets cell width based on total number months for
......@@ -89,49 +91,6 @@ export default {
}
return false;
},
getTimelineBarEndOffsetHalf() {
if (this.presetType === PRESET_TYPES.QUARTERS) {
return TIMELINE_END_OFFSET_HALF;
} else if (this.presetType === PRESET_TYPES.MONTHS) {
return TIMELINE_END_OFFSET_HALF;
} else if (this.presetType === PRESET_TYPES.WEEKS) {
return this.getTimelineBarEndOffsetHalfForWeek();
}
return 0;
},
/**
* In case startDate or endDate for any epic is undefined or is out of range
* for current timeframe, we have to provide specific offset while
* setting width to ensure that;
*
* 1. Timeline bar ends at correct position based on end date.
* 2. A "triangle" shape is shown at the end of timeline bar
* when endDate is out of range.
*/
getTimelineBarEndOffset() {
let offset = 0;
if (
(this.epic.startDateOutOfRange && this.epic.endDateOutOfRange) ||
(this.epic.startDateUndefined && this.epic.endDateOutOfRange)
) {
// If Epic startDate is undefined or out of range
// AND
// endDate is out of range
// Reduce offset size from the width to compensate for fadeout of timelinebar
// and/or showing triangle at the end and beginning
offset = TIMELINE_END_OFFSET_FULL;
} else if (this.epic.endDateOutOfRange) {
// If Epic end date is out of range
// Reduce offset size from the width to compensate for triangle (which is sized at 8px)
offset = this.getTimelineBarEndOffsetHalf();
} else {
// No offset needed if all dates are defined.
offset = 0;
}
return offset;
},
/**
* Renders timeline bar only if current
* timeframe item has startDate for the epic.
......@@ -160,9 +119,7 @@ export default {
:href="epic.webUrl"
:class="{
'start-date-undefined': epic.startDateUndefined,
'start-date-outside': epic.startDateOutOfRange,
'end-date-undefined': epic.endDateUndefined,
'end-date-outside': epic.endDateOutOfRange,
}"
:style="timelineBarStyles"
class="timeline-bar"
......
......@@ -2,7 +2,7 @@
import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility';
import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants';
import { PRESET_TYPES, emptyStateDefault, emptyStateWithFilters } from '../constants';
import NewEpic from '../../epics/new_epic/components/new_epic.vue';
......@@ -82,12 +82,12 @@ export default {
},
subMessage() {
if (this.hasFiltersApplied) {
return sprintf(PRESET_DEFAULTS[this.presetType].emptyStateWithFilters, {
return sprintf(emptyStateWithFilters, {
startDate: this.timeframeRange.startDate,
endDate: this.timeframeRange.endDate,
});
}
return sprintf(PRESET_DEFAULTS[this.presetType].emptyStateDefault, {
return sprintf(emptyStateDefault, {
startDate: this.timeframeRange.startDate,
endDate: this.timeframeRange.endDate,
});
......
......@@ -3,6 +3,8 @@ import eventHub from '../event_hub';
import SectionMixin from '../mixins/section_mixin';
import { TIMELINE_CELL_MIN_WIDTH } from '../constants';
import epicItem from './epic_item.vue';
export default {
......@@ -72,12 +74,18 @@ export default {
},
mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$on('refreshTimeline', () => {
this.initEmptyRow(false);
});
this.$nextTick(() => {
this.initMounted();
});
},
beforeDestroy() {
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$off('refreshTimeline', () => {
this.initEmptyRow(false);
});
},
methods: {
initMounted() {
......@@ -104,7 +112,7 @@ export default {
* based on height of available list items and sets it to component
* props.
*/
initEmptyRow() {
initEmptyRow(showEmptyRow = false) {
const children = this.$children;
let approxChildrenHeight = children[0].$el.clientHeight * this.epics.length;
......@@ -122,18 +130,16 @@ export default {
this.emptyRowHeight = this.shellHeight - approxChildrenHeight;
this.showEmptyRow = true;
} else {
this.showEmptyRow = showEmptyRow;
this.showBottomShadow = true;
}
},
/**
* `clientWidth` is full width of list section, and we need to
* scroll up to 60% of the view where today indicator is present.
*
* Reason for 60% is that "today" always falls in the middle of timeframe range.
* Scroll timeframe to the right of the timeline
* by half the column size
*/
scrollToTodayIndicator() {
const uptoTodayIndicator = Math.ceil((this.$el.clientWidth * 60) / 100);
this.$el.scrollTo(uptoTodayIndicator, 0);
this.$el.parentElement.scrollBy(TIMELINE_CELL_MIN_WIDTH / 2, 0);
},
handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) {
this.showBottomShadow = Math.ceil(scrollTop) + clientHeight < scrollHeight;
......
......@@ -29,8 +29,6 @@ export default {
return {
currentDate,
quarterBeginDate: this.timeframeItem.range[0],
quarterEndDate: this.timeframeItem.range[2],
};
},
computed: {
......@@ -39,6 +37,12 @@ export default {
width: `${this.itemWidth}px`,
};
},
quarterBeginDate() {
return this.timeframeItem.range[0];
},
quarterEndDate() {
return this.timeframeItem.range[2];
},
timelineHeaderLabel() {
const { quarterSequence } = this.timeframeItem;
if (quarterSequence === 1 || (this.timeframeIndex === 0 && quarterSequence !== 1)) {
......
......@@ -20,13 +20,13 @@ export default {
required: true,
},
},
data() {
return {
quarterBeginDate: this.timeframeItem.range[0],
quarterEndDate: this.timeframeItem.range[2],
};
},
computed: {
quarterBeginDate() {
return this.timeframeItem.range[0];
},
quarterEndDate() {
return this.timeframeItem.range[2];
},
headerSubItems() {
return this.timeframeItem.range;
},
......
......@@ -29,12 +29,8 @@ export default {
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
const lastDayOfCurrentWeek = new Date(this.timeframeItem.getTime());
lastDayOfCurrentWeek.setDate(lastDayOfCurrentWeek.getDate() + 7);
return {
currentDate,
lastDayOfCurrentWeek,
};
},
computed: {
......@@ -43,19 +39,35 @@ export default {
width: `${this.itemWidth}px`,
};
},
lastDayOfCurrentWeek() {
const lastDayOfCurrentWeek = new Date(this.timeframeItem.getTime());
lastDayOfCurrentWeek.setDate(lastDayOfCurrentWeek.getDate() + 7);
return lastDayOfCurrentWeek;
},
timelineHeaderLabel() {
if (this.timeframeIndex === 0) {
const timeframeItemMonth = this.timeframeItem.getMonth();
const timeframeItemDate = this.timeframeItem.getDate();
if (this.timeframeIndex === 0 || (timeframeItemMonth === 0 && timeframeItemDate <= 7)) {
return `${this.timeframeItem.getFullYear()} ${monthInWords(
this.timeframeItem,
true,
)} ${this.timeframeItem.getDate()}`;
)} ${timeframeItemDate}`;
}
return `${monthInWords(this.timeframeItem, true)} ${this.timeframeItem.getDate()}`;
return `${monthInWords(this.timeframeItem, true)} ${timeframeItemDate}`;
},
timelineHeaderClass() {
if (this.currentDate >= this.timeframeItem && this.currentDate <= this.lastDayOfCurrentWeek) {
const currentDateTime = this.currentDate.getTime();
const lastDayOfCurrentWeekTime = this.lastDayOfCurrentWeek.getTime();
if (
currentDateTime >= this.timeframeItem.getTime() &&
currentDateTime <= lastDayOfCurrentWeekTime
) {
return 'label-dark label-bold';
} else if (this.currentDate < this.lastDayOfCurrentWeek) {
} else if (currentDateTime < lastDayOfCurrentWeekTime) {
return 'label-dark';
}
return '';
......
......@@ -18,7 +18,8 @@ export default {
required: true,
},
},
data() {
computed: {
headerSubItems() {
const timeframeItem = new Date(this.timeframeItem.getTime());
const headerSubItems = new Array(7)
.fill()
......@@ -31,15 +32,12 @@ export default {
),
);
return {
headerSubItems,
};
return headerSubItems;
},
computed: {
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);
this.state.epicIds = [];
this.state.epics = RoadmapStore.filterInvalidEpics({
timeframeStartDate: this.timeframeStartDate,
timeframeEndDate: this.timeframeEndDate,
state: this.state,
epics,
});
}
return filteredEpics;
}, []);
addEpics(epics) {
this.state.epics = this.state.epics.concat(
RoadmapStore.filterInvalidEpics({
timeframeStartDate: this.timeframeStartDate,
timeframeEndDate: this.timeframeEndDate,
state: this.state,
newEpic: true,
epics,
}),
);
sortEpics(this.state.epics, this.sortedBy);
}
getEpics() {
......@@ -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.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.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 {
......
......@@ -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,8 +27,9 @@ describe('MonthsHeaderSubItemComponent', () => {
vm.$destroy();
});
describe('data', () => {
it('sets prop `headerSubItems` with array of dates containing days of week from timeframeItem', () => {
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);
......@@ -36,7 +40,6 @@ describe('MonthsHeaderSubItemComponent', () => {
});
});
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));
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);
......
......@@ -4538,22 +4538,10 @@ msgstr ""
msgid "GroupRoadmap|The roadmap shows the progress of your epics along a timeline"
msgstr ""
msgid "GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the months view, only epics in the past month, current month, and next 5 months are shown &ndash; from %{startDate} to %{endDate}."
msgid "GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups; from %{startDate} to %{endDate}."
msgstr ""
msgid "GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the quarters view, only epics in the past quarter, current quarter, and next 4 quarters are shown &ndash; from %{startDate} to %{endDate}."
msgstr ""
msgid "GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the weeks view, only epics in the past week, current week, and next 4 weeks are shown &ndash; from %{startDate} to %{endDate}."
msgstr ""
msgid "GroupRoadmap|To widen your search, change or remove filters. In the months view, only epics in the past month, current month, and next 5 months are shown &ndash; from %{startDate} to %{endDate}."
msgstr ""
msgid "GroupRoadmap|To widen your search, change or remove filters. In the quarters view, only epics in the past quarter, current quarter, and next 4 quarters are shown &ndash; from %{startDate} to %{endDate}."
msgstr ""
msgid "GroupRoadmap|To widen your search, change or remove filters. In the weeks view, only epics in the past week, current week, and next 4 weeks are shown &ndash; from %{startDate} to %{endDate}."
msgid "GroupRoadmap|To widen your search, change or remove filters; from %{startDate} to %{endDate}."
msgstr ""
msgid "GroupRoadmap|Until %{dateWord}"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment