diff --git a/ee/app/assets/javascripts/roadmap/components/app.vue b/ee/app/assets/javascripts/roadmap/components/app.vue index 4dd6338338d7fb65e08ce5447e14f60dcd599619..2da718a0b1aba9b4f56bf18da87ae057ad53d325 100644 --- a/ee/app/assets/javascripts/roadmap/components/app.vue +++ b/ee/app/assets/javascripts/roadmap/components/app.vue @@ -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" diff --git a/ee/app/assets/javascripts/roadmap/components/epic_item.vue b/ee/app/assets/javascripts/roadmap/components/epic_item.vue index 59fcc1c59bc4a63ddfe08f05dea74d99e2a586ad..c46dd568b8e38461eba147ad0d43a8044464f768 100644 --- a/ee/app/assets/javascripts/roadmap/components/epic_item.vue +++ b/ee/app/assets/javascripts/roadmap/components/epic_item.vue @@ -1,7 +1,11 @@ <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" diff --git a/ee/app/assets/javascripts/roadmap/components/epic_item_timeline.vue b/ee/app/assets/javascripts/roadmap/components/epic_item_timeline.vue index 03d79bd6c2bbe7e858cfe958dc6204af63c9dfa2..70a12e8a929c084d3971922594b211e91f9af1bd 100644 --- a/ee/app/assets/javascripts/roadmap/components/epic_item_timeline.vue +++ b/ee/app/assets/javascripts/roadmap/components/epic_item_timeline.vue @@ -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" diff --git a/ee/app/assets/javascripts/roadmap/components/epics_list_empty.vue b/ee/app/assets/javascripts/roadmap/components/epics_list_empty.vue index f4aec7cc9813553641c62593d477d3f2e34e4a24..d13fb7a103498a0fc661162876f79ae98b07ef24 100644 --- a/ee/app/assets/javascripts/roadmap/components/epics_list_empty.vue +++ b/ee/app/assets/javascripts/roadmap/components/epics_list_empty.vue @@ -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, }); diff --git a/ee/app/assets/javascripts/roadmap/components/epics_list_section.vue b/ee/app/assets/javascripts/roadmap/components/epics_list_section.vue index 0dd982908444898565f914c70973bf145c54b2b4..2597e3ad9b778edc9b0810d2c922487dce4cc732 100644 --- a/ee/app/assets/javascripts/roadmap/components/epics_list_section.vue +++ b/ee/app/assets/javascripts/roadmap/components/epics_list_section.vue @@ -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; diff --git a/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_item.vue b/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_item.vue index 542e13ac3223f27d9291897416771b508e37241f..b9614c551add4c4f889b98ba0ad135ede6034608 100644 --- a/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_item.vue +++ b/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_item.vue @@ -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)) { diff --git a/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item.vue b/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item.vue index c80f73f36907a8d85c2eafe97a6cfa15ea5f334f..4120c1c1986e62f4d3b231e7db7b475db689a46d 100644 --- a/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item.vue +++ b/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item.vue @@ -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; }, diff --git a/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_item.vue b/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_item.vue index fcf983a6a15a60870ebbb179f57b43ca1881a968..ab6f62b16dfa10308b583f47ee0ab2cba664c94b 100644 --- a/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_item.vue +++ b/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_item.vue @@ -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 ''; diff --git a/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item.vue b/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item.vue index cd9528fa85271bc47a45d845d5e25e9b9b08b4dc..ed7e83fd3c04344106bcb39d2225b19264563894 100644 --- a/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item.vue +++ b/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item.vue @@ -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() ); }, }, diff --git a/ee/app/assets/javascripts/roadmap/components/roadmap_shell.vue b/ee/app/assets/javascripts/roadmap/components/roadmap_shell.vue index 780d9164587d25b685520a7a38fece601cc21aca..9b6b0ac3f5b1c89d3caee989331ccb439312472b 100644 --- a/ee/app/assets/javascripts/roadmap/components/roadmap_shell.vue +++ b/ee/app/assets/javascripts/roadmap/components/roadmap_shell.vue @@ -1,6 +1,7 @@ <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" diff --git a/ee/app/assets/javascripts/roadmap/components/timeline_today_indicator.vue b/ee/app/assets/javascripts/roadmap/components/timeline_today_indicator.vue index 5244ae65cf50602bfbe1b473fecd2558da81cb72..3d142f5aaa8132b083e1da669e15735a5c8d2fba 100644 --- a/ee/app/assets/javascripts/roadmap/components/timeline_today_indicator.vue +++ b/ee/app/assets/javascripts/roadmap/components/timeline_today_indicator.vue @@ -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; diff --git a/ee/app/assets/javascripts/roadmap/constants.js b/ee/app/assets/javascripts/roadmap/constants.js index 80cf0fa44bfbe33d5beb113571a25227fb4e3998..a76b299b6c0b9db1cc65c6dde2a941ebf8bf50bb 100644 --- a/ee/app/assets/javascripts/roadmap/constants.js +++ b/ee/app/assets/javascripts/roadmap/constants.js @@ -1,7 +1,5 @@ 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 – 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 – 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 – 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 – 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 – 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 – from %{startDate} to %{endDate}.', - ), + TIMEFRAME_LENGTH: 7, }, }; diff --git a/ee/app/assets/javascripts/roadmap/index.js b/ee/app/assets/javascripts/roadmap/index.js index 252d3ead4d3b928202493111c8ced3622bf48f4c..9c8234716188cdda5ad4b07397a6875ac7fbbbb3 100644 --- a/ee/app/assets/javascripts/roadmap/index.js +++ b/ee/app/assets/javascripts/roadmap/index.js @@ -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, }, diff --git a/ee/app/assets/javascripts/roadmap/mixins/months_preset_mixin.js b/ee/app/assets/javascripts/roadmap/mixins/months_preset_mixin.js index 01c6d33779e9f3b92d88f110ca11345f658b4c18..2b2dbf02f2562b03132ec1244b9941f1da5169f6 100644 --- a/ee/app/assets/javascripts/roadmap/mixins/months_preset_mixin.js +++ b/ee/app/assets/javascripts/roadmap/mixins/months_preset_mixin.js @@ -1,7 +1,5 @@ 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; }, }, }; diff --git a/ee/app/assets/javascripts/roadmap/mixins/quarters_preset_mixin.js b/ee/app/assets/javascripts/roadmap/mixins/quarters_preset_mixin.js index 9b70b17e18bbe2ef5723b443134c978034a5d6f4..402c6de03a0d50e37e99777fdc8a3b3be6b733bb 100644 --- a/ee/app/assets/javascripts/roadmap/mixins/quarters_preset_mixin.js +++ b/ee/app/assets/javascripts/roadmap/mixins/quarters_preset_mixin.js @@ -1,7 +1,5 @@ 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; }, }, }; diff --git a/ee/app/assets/javascripts/roadmap/mixins/weeks_preset_mixin.js b/ee/app/assets/javascripts/roadmap/mixins/weeks_preset_mixin.js index 67b3c4a9c9e423dce851b581958bae3d3fcf3958..28f332a515f66caaf0313d2fb1b25c12279f0842 100644 --- a/ee/app/assets/javascripts/roadmap/mixins/weeks_preset_mixin.js +++ b/ee/app/assets/javascripts/roadmap/mixins/weeks_preset_mixin.js @@ -1,4 +1,4 @@ -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; }, }, }; diff --git a/ee/app/assets/javascripts/roadmap/service/roadmap_service.js b/ee/app/assets/javascripts/roadmap/service/roadmap_service.js index ea4d58aeff18548b91b90eddf708a4a34da7d375..4c2a0cf81ef046d9b9e99d3d18f9052634acf4b7 100644 --- a/ee/app/assets/javascripts/roadmap/service/roadmap_service.js +++ b/ee/app/assets/javascripts/roadmap/service/roadmap_service.js @@ -1,11 +1,28 @@ 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); } } diff --git a/ee/app/assets/javascripts/roadmap/store/roadmap_store.js b/ee/app/assets/javascripts/roadmap/store/roadmap_store.js index fb4a25d17b66541882ec7c174a88bf3160bc1795..64ca6487702cc43e911814b3f2c2920ae2f421fb 100644 --- a/ee/app/assets/javascripts/roadmap/store/roadmap_store.js +++ b/ee/app/assets/javascripts/roadmap/store/roadmap_store.js @@ -1,16 +1,19 @@ 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), + }); + } + } } diff --git a/ee/app/assets/javascripts/roadmap/utils/roadmap_utils.js b/ee/app/assets/javascripts/roadmap/utils/roadmap_utils.js index 22d2d388f5aed28129189795e8f9b961a3240c3b..d638981e3a848071abb20e973e4dbe085cadd9e4 100644 --- a/ee/app/assets/javascripts/roadmap/utils/roadmap_utils.js +++ b/ee/app/assets/javascripts/roadmap/utils/roadmap_utils.js @@ -1,15 +1,23 @@ -import { getTimeframeWindowFrom, totalDaysInMonth } from '~/lib/utils/datetime_utility'; +import { newDate, getTimeframeWindowFrom, totalDaysInMonth } from '~/lib/utils/datetime_utility'; -import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants'; +import { PRESET_TYPES, PRESET_DEFAULTS, EXTEND_AS, TIMELINE_CELL_MIN_WIDTH } from '../constants'; + +const monthsForQuarters = { + 1: [0, 1, 2], + 2: [3, 4, 5], + 3: [6, 7, 8], + 4: [9, 10, 11], +}; /** * This method returns array of Objects representing Quarters based on provided initialDate * * For eg; If initialDate is 15th Jan 2018 * Then as per Roadmap specs, we need to show - * 1 quarter before current quarter AND + * 2 quarters before current quarters + * current quarter AND * 4 quarters after current quarter - * thus, total of 6 quarters. + * thus, total of 7 quarters (21 Months). * * So returned array from this method will be; * [ @@ -47,37 +55,32 @@ import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants'; * * @param {Date} initialDate */ -export const getTimeframeForQuartersView = (initialDate = new Date()) => { - const startDate = initialDate; +export const getTimeframeForQuartersView = (initialDate = new Date(), timeframe = []) => { + const startDate = newDate(initialDate); startDate.setHours(0, 0, 0, 0); - const monthsForQuarters = { - 1: [0, 1, 2], - 2: [3, 4, 5], - 3: [6, 7, 8], - 4: [9, 10, 11], - }; - - // Get current quarter for current month - const currentQuarter = Math.floor((startDate.getMonth() + 3) / 3); - // Get index of current month in current quarter - // It could be 0, 1, 2 (i.e. first, second or third) - const currentMonthInCurrentQuarter = monthsForQuarters[currentQuarter].indexOf( - startDate.getMonth(), - ); + if (!timeframe.length) { + // Get current quarter for current month + const currentQuarter = Math.floor((startDate.getMonth() + 3) / 3); + // Get index of current month in current quarter + // It could be 0, 1, 2 (i.e. first, second or third) + const currentMonthInCurrentQuarter = monthsForQuarters[currentQuarter].indexOf( + startDate.getMonth(), + ); - // To move start back to first month of previous quarter - // Adding quarter size (3) to month order will give us - // exact number of months we need to go back in time - const startMonth = currentMonthInCurrentQuarter + 3; - const quartersTimeframe = []; - // Move startDate to first month of previous quarter - startDate.setMonth(startDate.getMonth() - startMonth); + // To move start back to first month of 2 quarters prior by + // adding quarter size (3 + 3) to month order will give us + // exact number of months we need to go back in time + const startMonth = currentMonthInCurrentQuarter + 6; + // Move startDate to first month of previous quarter + startDate.setMonth(startDate.getMonth() - startMonth); - // Get timeframe for the length we determined for this preset - // start from the startDate - const timeframe = getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH); + // Get timeframe for the length we determined for this preset + // start from the startDate + timeframe.push(...getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH)); + } + const quartersTimeframe = []; // Iterate over the timeframe and break it down // in chunks of quarters for (let i = 0; i < timeframe.length; i += 3) { @@ -100,83 +103,246 @@ export const getTimeframeForQuartersView = (initialDate = new Date()) => { return quartersTimeframe; }; +export const extendTimeframeForQuartersView = (initialDate = new Date(), length) => { + const startDate = newDate(initialDate); + startDate.setDate(1); + + startDate.setMonth(startDate.getMonth() + (length > 0 ? 1 : -1)); + const timeframe = getTimeframeWindowFrom(startDate, length); + + return getTimeframeForQuartersView(startDate, length > 0 ? timeframe : timeframe.reverse()); +}; + /** * This method returns array of Dates respresenting Months based on provided initialDate * * For eg; If initialDate is 15th Jan 2018 * Then as per Roadmap specs, we need to show - * 1 month before current month AND + * 2 months before current month, + * current month AND * 5 months after current month - * thus, total of 7 months. + * thus, total of 8 months. * * So returned array from this method will be; * [ - * 1 Dec 2017, 1 Jan 2018, 1 Feb 2018, 1 Mar 2018, - * 1 Apr 2018, 1 May 2018, 30 Jun 2018 + * 1 Nov 2017, 1 Dec 2017, 1 Jan 2018, 1 Feb 2018, + * 1 Mar 2018, 1 Apr 2018, 1 May 2018, 30 Jun 2018 * ] * * @param {Date} initialDate */ export const getTimeframeForMonthsView = (initialDate = new Date()) => { - const startDate = initialDate; - startDate.setHours(0, 0, 0, 0); + const startDate = newDate(initialDate); // Move startDate to a month prior to current month - startDate.setMonth(startDate.getMonth() - 1); + startDate.setMonth(startDate.getMonth() - 2); return getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.MONTHS.TIMEFRAME_LENGTH); }; +export const extendTimeframeForMonthsView = (initialDate = new Date(), length) => { + const startDate = newDate(initialDate); + + // When length is positive (which means extension is of type APPEND) + // Set initial date as first day of the month. + if (length > 0) { + startDate.setDate(1); + } + + const timeframe = getTimeframeWindowFrom(startDate, length - 1).slice(1); + + return length > 0 ? timeframe : timeframe.reverse(); +}; + /** * This method returns array of Dates respresenting Months based on provided initialDate * * For eg; If initialDate is 15th Jan 2018 * Then as per Roadmap specs, we need to show - * 1 week before current week AND + * 2 weeks before current week, + * current week AND * 4 weeks after current week - * thus, total of 6 weeks. + * thus, total of 7 weeks. * Note that week starts on Sunday * * So returned array from this method will be; * [ - * 7 Jan 2018, 14 Jan 2018, 21 Jan 2018, + * 31 Dec 2017, 7 Jan 2018, 14 Jan 2018, 21 Jan 2018, * 28 Jan 2018, 4 Mar 2018, 11 Mar 2018 * ] * * @param {Date} initialDate */ export const getTimeframeForWeeksView = (initialDate = new Date()) => { - const startDate = initialDate; + const startDate = newDate(initialDate); startDate.setHours(0, 0, 0, 0); const dayOfWeek = startDate.getDay(); - const daysToFirstDayOfPrevWeek = dayOfWeek + 7; + const daysToFirstDayOfPrevWeek = dayOfWeek + 14; const timeframe = []; - // Move startDate to first day (Sunday) of previous week + // Move startDate to first day (Sunday) of 2 weeks prior startDate.setDate(startDate.getDate() - daysToFirstDayOfPrevWeek); // Iterate for the length of this preset for (let i = 0; i < PRESET_DEFAULTS.WEEKS.TIMEFRAME_LENGTH; i += 1) { // Push date to timeframe only when day is - // first day (Sunday) of the week1 - if (startDate.getDay() === 0) { - timeframe.push(new Date(startDate.getTime())); - } - // Move date one day further - startDate.setDate(startDate.getDate() + 1); + // first day (Sunday) of the week + timeframe.push(newDate(startDate)); + + // Move date next Sunday + startDate.setDate(startDate.getDate() + 7); } return timeframe; }; -export const getTimeframeForPreset = (presetType = PRESET_TYPES.MONTHS) => { +export const extendTimeframeForWeeksView = (initialDate = new Date(), length) => { + const startDate = newDate(initialDate); + + if (length < 0) { + startDate.setDate(startDate.getDate() + (length + 1) * 7); + } else { + startDate.setDate(startDate.getDate() + 7); + } + + const timeframe = getTimeframeForWeeksView(startDate, length + 1); + return timeframe.slice(1); +}; + +export const extendTimeframeForPreset = ({ + presetType = PRESET_TYPES.MONTHS, + extendAs = EXTEND_AS.PREPEND, + extendByLength = 0, + initialDate, +}) => { if (presetType === PRESET_TYPES.QUARTERS) { - return getTimeframeForQuartersView(); + const length = extendByLength || PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH; + + return extendTimeframeForQuartersView( + initialDate, + extendAs === EXTEND_AS.PREPEND ? -length : length, + ); } else if (presetType === PRESET_TYPES.MONTHS) { - return getTimeframeForMonthsView(); + const length = extendByLength || PRESET_DEFAULTS.MONTHS.TIMEFRAME_LENGTH; + + return extendTimeframeForMonthsView( + initialDate, + extendAs === EXTEND_AS.PREPEND ? -length : length, + ); } - return getTimeframeForWeeksView(); + + const length = extendByLength || PRESET_DEFAULTS.WEEKS.TIMEFRAME_LENGTH; + + return extendTimeframeForWeeksView( + initialDate, + extendAs === EXTEND_AS.PREPEND ? -length : length, + ); +}; + +export const extendTimeframeForAvailableWidth = ({ + timeframe, + timeframeStart, + timeframeEnd, + availableTimeframeWidth, + presetType, +}) => { + let timeframeLength = timeframe.length; + + // Estimate how many more timeframe columns are needed + // to fill in extra screen space so that timeline becomes + // horizontally scrollable. + while (availableTimeframeWidth / timeframeLength > TIMELINE_CELL_MIN_WIDTH) { + timeframeLength += 1; + } + // We double the increaseLengthBy to make sure there's enough room + // to perform horizontal scroll without triggering timeframe extension + // on initial page load. + const increaseLengthBy = (timeframeLength - timeframe.length) * 2; + + // If there are timeframe items to be added + // to make timeline scrollable, do as follows. + if (increaseLengthBy > 0) { + // Split length in 2 parts and get + // count for both prepend and append. + const prependBy = Math.floor(increaseLengthBy / 2); + const appendBy = Math.ceil(increaseLengthBy / 2); + + if (prependBy) { + // Prepend the timeline with + // the count as given by prependBy + timeframe.unshift( + ...extendTimeframeForPreset({ + extendAs: EXTEND_AS.PREPEND, + initialDate: timeframeStart, + // In case of presetType `quarters`, length would represent + // number of months for total quarters, hence we do `* 3`. + extendByLength: presetType === PRESET_TYPES.QUARTERS ? prependBy * 3 : prependBy, + presetType, + }), + ); + } + + if (appendBy) { + // Append the timeline with + // the count as given by appendBy + timeframe.push( + ...extendTimeframeForPreset({ + extendAs: EXTEND_AS.APPEND, + initialDate: timeframeEnd, + // In case of presetType `quarters`, length would represent + // number of months for total quarters, hence we do `* 3`. + // + // For other preset types, we add `2` to appendBy to compensate for + // last item of original timeframe (month or week) + extendByLength: presetType === PRESET_TYPES.QUARTERS ? appendBy * 3 : appendBy + 2, + presetType, + }), + ); + } + } +}; + +export const getTimeframeForPreset = ( + presetType = PRESET_TYPES.MONTHS, + availableTimeframeWidth = 0, +) => { + let timeframe; + let timeframeStart; + let timeframeEnd; + + // Get timeframe based on presetType and + // extract timeframeStart and timeframeEnd + // date objects + if (presetType === PRESET_TYPES.QUARTERS) { + timeframe = getTimeframeForQuartersView(); + [timeframeStart] = timeframe[0].range; + // eslint-disable-next-line prefer-destructuring + timeframeEnd = timeframe[timeframe.length - 1].range[2]; + } else if (presetType === PRESET_TYPES.MONTHS) { + timeframe = getTimeframeForMonthsView(); + [timeframeStart] = timeframe; + timeframeEnd = timeframe[timeframe.length - 1]; + } else { + timeframe = getTimeframeForWeeksView(); + timeframeStart = newDate(timeframe[0]); + timeframeEnd = newDate(timeframe[timeframe.length - 1]); + timeframeStart.setDate(timeframeStart.getDate() - 7); // Move date back by a week + timeframeEnd.setDate(timeframeEnd.getDate() + 7); // Move date ahead by a week + } + + // Extend timeframe on initial load to ensure + // timeline is horizontally scrollable in all + // screen sizes. + extendTimeframeForAvailableWidth({ + timeframe, + timeframeStart, + timeframeEnd, + availableTimeframeWidth, + presetType, + }); + + return timeframe; }; export const getEpicsPathForPreset = ({ @@ -184,7 +350,7 @@ export const getEpicsPathForPreset = ({ filterQueryString = '', presetType = '', timeframe = [], - state = 'all', + epicsState = 'all', }) => { let start; let end; @@ -208,13 +374,13 @@ export const getEpicsPathForPreset = ({ end = lastTimeframe; } else if (presetType === PRESET_TYPES.WEEKS) { start = firstTimeframe; - end = new Date(lastTimeframe.getTime()); + end = newDate(lastTimeframe); end.setDate(end.getDate() + 6); } const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`; const endDate = `${end.getFullYear()}-${end.getMonth() + 1}-${end.getDate()}`; - epicsPath += `?state=${state}&start_date=${startDate}&end_date=${endDate}`; + epicsPath += `?state=${epicsState}&start_date=${startDate}&end_date=${endDate}`; if (filterQueryString) { epicsPath += `&${filterQueryString}`; @@ -222,3 +388,43 @@ export const getEpicsPathForPreset = ({ return epicsPath; }; + +export const sortEpics = (epics, sortedBy) => { + const sortByStartDate = sortedBy.indexOf('start_date') > -1; + const sortOrderAsc = sortedBy.indexOf('asc') > -1; + + epics.sort((a, b) => { + let aDate; + let bDate; + + if (sortByStartDate) { + aDate = a.startDate; + if (a.startDateOutOfRange) { + aDate = a.originalStartDate; + } + + bDate = b.startDate; + if (b.startDateOutOfRange) { + bDate = b.originalStartDate; + } + } else { + aDate = a.endDate; + if (a.endDateOutOfRange) { + aDate = a.originalEndDate; + } + + bDate = b.endDate; + if (b.endDateOutOfRange) { + bDate = b.originalEndDate; + } + } + + // Sort in ascending or descending order + if (aDate.getTime() < bDate.getTime()) { + return sortOrderAsc ? -1 : 1; + } else if (aDate.getTime() > bDate.getTime()) { + return sortOrderAsc ? 1 : -1; + } + return 0; + }); +}; diff --git a/ee/app/assets/stylesheets/pages/roadmap.scss b/ee/app/assets/stylesheets/pages/roadmap.scss index b5d2b6196722aa5e74f0214118d5be4bc4e00722..b23766fcf74228433b8a8f8316d1c8b170e9e769 100644 --- a/ee/app/assets/stylesheets/pages/roadmap.scss +++ b/ee/app/assets/stylesheets/pages/roadmap.scss @@ -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 { diff --git a/ee/spec/javascripts/roadmap/components/app_spec.js b/ee/spec/javascripts/roadmap/components/app_spec.js index fa55917a9d6beefd1a6c24e6016ab81d5f349fc0..d8710cc6c0d053895585bdc5a75772d28a28d7b5 100644 --- a/ee/spec/javascripts/roadmap/components/app_spec.js +++ b/ee/spec/javascripts/roadmap/components/app_spec.js @@ -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', () => { diff --git a/ee/spec/javascripts/roadmap/components/epic_item_spec.js b/ee/spec/javascripts/roadmap/components/epic_item_spec.js index c8a6796f77d02e8741ff3842e3b4aa20dfc6d3b7..add5b13d888581430bd0778b07332df0107d364e 100644 --- a/ee/spec/javascripts/roadmap/components/epic_item_spec.js +++ b/ee/spec/javascripts/roadmap/components/epic_item_spec.js @@ -1,18 +1,24 @@ 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(); diff --git a/ee/spec/javascripts/roadmap/components/epic_item_timeline_spec.js b/ee/spec/javascripts/roadmap/components/epic_item_timeline_spec.js index 0f16e7b6596a50cca109e56ea89475f830b2e339..f85274420c202c8a9230925364c74c5fd255af89 100644 --- a/ee/spec/javascripts/roadmap/components/epic_item_timeline_spec.js +++ b/ee/spec/javascripts/roadmap/components/epic_item_timeline_spec.js @@ -1,15 +1,15 @@ 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(); - }); - }); }); }); diff --git a/ee/spec/javascripts/roadmap/components/epics_list_empty_spec.js b/ee/spec/javascripts/roadmap/components/epics_list_empty_spec.js index 46ad26376853d36bfa0a54c97112451516c36503..ddbb39f0896670f5704257439347504548be4be4 100644 --- a/ee/spec/javascripts/roadmap/components/epics_list_empty_spec.js +++ b/ee/spec/javascripts/roadmap/components/epics_list_empty_spec.js @@ -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 – 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 – 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 – 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 – 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 – 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 – 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'); }); }); diff --git a/ee/spec/javascripts/roadmap/components/epics_list_section_spec.js b/ee/spec/javascripts/roadmap/components/epics_list_section_spec.js index 1a2b273f3cae6ef182b2e06db18f28d1e85f6506..cb37021dd8aae4a80e6d5005746ae60640958adb 100644 --- a/ee/spec/javascripts/roadmap/components/epics_list_section_spec.js +++ b/ee/spec/javascripts/roadmap/components/epics_list_section_spec.js @@ -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', () => { diff --git a/ee/spec/javascripts/roadmap/components/preset_months/months_header_item_spec.js b/ee/spec/javascripts/roadmap/components/preset_months/months_header_item_spec.js index 523a8ae5ec96a6790145d11bcd5dc2dfab8d24b2..3d478d65ae767ff556d47d061944ce0fad04c371 100644 --- a/ee/spec/javascripts/roadmap/components/preset_months/months_header_item_spec.js +++ b/ee/spec/javascripts/roadmap/components/preset_months/months_header_item_spec.js @@ -1,10 +1,12 @@ 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'); }); }); }); diff --git a/ee/spec/javascripts/roadmap/components/preset_months/months_header_sub_item_spec.js b/ee/spec/javascripts/roadmap/components/preset_months/months_header_sub_item_spec.js index 7e62c1aa56bf8e0d91bba97e68ea43ee7c8734a3..cdd9bf04727ea89dcb238963c23f2087c4c5467e 100644 --- a/ee/spec/javascripts/roadmap/components/preset_months/months_header_sub_item_spec.js +++ b/ee/spec/javascripts/roadmap/components/preset_months/months_header_sub_item_spec.js @@ -1,9 +1,12 @@ 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], diff --git a/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_item_spec.js b/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_item_spec.js index 93b6364ffb05ef4a142c33bec23b96693ca03563..611a02b9b2d184bd5a816a92c76bb79bd1e518ab 100644 --- a/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_item_spec.js +++ b/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_item_spec.js @@ -1,11 +1,13 @@ 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'); }); }); }); diff --git a/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item_spec.js b/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item_spec.js index 12f84e12b8c998af336c942c54effdb89722d161..69bf9d38726adb2d15f08c4a9c99d07b0b5bb51b 100644 --- a/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item_spec.js +++ b/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item_spec.js @@ -1,9 +1,12 @@ 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); diff --git a/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_item_spec.js b/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_item_spec.js index 26a5c1e40e13d6237c9833fa8bf3981da15db95c..1177534b097339839f5883630d1a00fb52dab5d3 100644 --- a/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_item_spec.js +++ b/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_item_spec.js @@ -1,11 +1,13 @@ 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'); }); }); }); diff --git a/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item_spec.js b/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item_spec.js index f8a8af1a3c5771973d5520b5901e4ede418ec638..6d5a7a4dad4ac045b1b486fd2efe612d8ec08751 100644 --- a/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item_spec.js +++ b/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item_spec.js @@ -1,9 +1,12 @@ 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({}); diff --git a/ee/spec/javascripts/roadmap/components/roadmap_shell_spec.js b/ee/spec/javascripts/roadmap/components/roadmap_shell_spec.js index d1a1375fb3f422db85f63e90531b99e1c9cad3e7..c9d5a91a197e1534476b8719f2fec1ddc43c0305 100644 --- a/ee/spec/javascripts/roadmap/components/roadmap_shell_spec.js +++ b/ee/spec/javascripts/roadmap/components/roadmap_shell_spec.js @@ -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); }); }); }); diff --git a/ee/spec/javascripts/roadmap/components/roadmap_timeline_section_spec.js b/ee/spec/javascripts/roadmap/components/roadmap_timeline_section_spec.js index 246c540f8579bf3a18f7a035c147459719a51ea2..1cb3369f6d3941c465bd5b930f4a06cb5b93e839 100644 --- a/ee/spec/javascripts/roadmap/components/roadmap_timeline_section_spec.js +++ b/ee/spec/javascripts/roadmap/components/roadmap_timeline_section_spec.js @@ -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, diff --git a/ee/spec/javascripts/roadmap/components/timeline_today_indicator_spec.js b/ee/spec/javascripts/roadmap/components/timeline_today_indicator_spec.js index 0ad21a725d57529db00b95502edb0eadee7a129e..12f07560e4b112cea7d25810be356d5847dba3ff 100644 --- a/ee/spec/javascripts/roadmap/components/timeline_today_indicator_spec.js +++ b/ee/spec/javascripts/roadmap/components/timeline_today_indicator_spec.js @@ -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)); }); }); diff --git a/ee/spec/javascripts/roadmap/mixins/months_preset_mixin_spec.js b/ee/spec/javascripts/roadmap/mixins/months_preset_mixin_spec.js index ec2bd35090b994a29c2767aa8a96de61088f3dbb..9eca1073609a92ca568df025e1c8c37bf214c50c 100644 --- a/ee/spec/javascripts/roadmap/mixins/months_preset_mixin_spec.js +++ b/ee/spec/javascripts/roadmap/mixins/months_preset_mixin_spec.js @@ -1,10 +1,14 @@ 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); }); }); }); diff --git a/ee/spec/javascripts/roadmap/mixins/quarters_preset_mixin_spec.js b/ee/spec/javascripts/roadmap/mixins/quarters_preset_mixin_spec.js index a047cd1f61271d9871946115b5afe014257a02f4..360606a6f7016b59b1b7d0ded545b8a622f08baf 100644 --- a/ee/spec/javascripts/roadmap/mixins/quarters_preset_mixin_spec.js +++ b/ee/spec/javascripts/roadmap/mixins/quarters_preset_mixin_spec.js @@ -1,10 +1,14 @@ 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); }); }); }); diff --git a/ee/spec/javascripts/roadmap/mixins/section_mixin_spec.js b/ee/spec/javascripts/roadmap/mixins/section_mixin_spec.js index 9bc67f011cde9e91f95a3a56f1f03acab729acf7..7698b3a7701620b0cb7b1f5a9c752bb7b4eff9b4 100644 --- a/ee/spec/javascripts/roadmap/mixins/section_mixin_spec.js +++ b/ee/spec/javascripts/roadmap/mixins/section_mixin_spec.js @@ -1,11 +1,19 @@ 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); }); }); diff --git a/ee/spec/javascripts/roadmap/mixins/weeks_preset_mixin_spec.js b/ee/spec/javascripts/roadmap/mixins/weeks_preset_mixin_spec.js index 3ac996bc907f92eafd4cac26fd95291a31ef7933..83a23a9299f1c3c0df1243223d2247518694bfd9 100644 --- a/ee/spec/javascripts/roadmap/mixins/weeks_preset_mixin_spec.js +++ b/ee/spec/javascripts/roadmap/mixins/weeks_preset_mixin_spec.js @@ -1,10 +1,14 @@ 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); }); }); }); diff --git a/ee/spec/javascripts/roadmap/mock_data.js b/ee/spec/javascripts/roadmap/mock_data.js index 799a743b492414d1d05833306fec812523b8bf8a..56c32ea02782c067ca6f41f9017a30d5996ac6a0 100644 --- a/ee/spec/javascripts/roadmap/mock_data.js +++ b/ee/spec/javascripts/roadmap/mock_data.js @@ -1,9 +1,3 @@ -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), + }, +]; diff --git a/ee/spec/javascripts/roadmap/service/roadmap_service_spec.js b/ee/spec/javascripts/roadmap/service/roadmap_service_spec.js index 9f68ecf41f51e24daa3445024c7682b580c80489..f374698bee7ac01848f054607a25d004b0e6845a 100644 --- a/ee/spec/javascripts/roadmap/service/roadmap_service_spec.js +++ b/ee/spec/javascripts/roadmap/service/roadmap_service_spec.js @@ -1,13 +1,23 @@ 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, + }), + ); }); }); }); diff --git a/ee/spec/javascripts/roadmap/store/roadmap_store_spec.js b/ee/spec/javascripts/roadmap/store/roadmap_store_spec.js index 0754354896cbf9b1586a76fc33215e0881c1fc6c..1ad1f29269b13c1e292b3040a5171659c09a0a20 100644 --- a/ee/spec/javascripts/roadmap/store/roadmap_store_spec.js +++ b/ee/spec/javascripts/roadmap/store/roadmap_store_spec.js @@ -1,21 +1,32 @@ 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); diff --git a/ee/spec/javascripts/roadmap/utils/roadmap_utils_spec.js b/ee/spec/javascripts/roadmap/utils/roadmap_utils_spec.js index 94b85b2473c084edaeeaa6c8c683604f2980fe0c..b41722b52c53ce50fc05f596bd1a0b76d7fd29fc 100644 --- a/ee/spec/javascripts/roadmap/utils/roadmap_utils_spec.js +++ b/ee/spec/javascripts/roadmap/utils/roadmap_utils_spec.js @@ -1,12 +1,32 @@ import { getTimeframeForQuartersView, + extendTimeframeForQuartersView, getTimeframeForMonthsView, + extendTimeframeForMonthsView, getTimeframeForWeeksView, + extendTimeframeForWeeksView, + extendTimeframeForAvailableWidth, getEpicsPathForPreset, + sortEpics, } from 'ee/roadmap/utils/roadmap_utils'; import { PRESET_TYPES } from 'ee/roadmap/constants'; +import { + mockTimeframeInitialDate, + mockTimeframeQuartersPrepend, + mockTimeframeQuartersAppend, + mockTimeframeMonthsPrepend, + mockTimeframeMonthsAppend, + mockTimeframeWeeksPrepend, + mockTimeframeWeeksAppend, + mockUnsortedEpics, +} from '../mock_data'; + +const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate); +const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate); +const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate); + describe('getTimeframeForQuartersView', () => { let timeframe; @@ -14,8 +34,8 @@ describe('getTimeframeForQuartersView', () => { timeframe = getTimeframeForQuartersView(new Date(2018, 0, 1)); }); - it('returns timeframe with total of 6 quarters', () => { - expect(timeframe.length).toBe(6); + it('returns timeframe with total of 7 quarters', () => { + expect(timeframe.length).toBe(7); }); it('each timeframe item has `quarterSequence`, `year` and `range` present', () => { @@ -26,15 +46,15 @@ describe('getTimeframeForQuartersView', () => { expect(Array.isArray(timeframeItem.range)).toBe(true); }); - it('first timeframe item refers to quarter prior to current quarter', () => { + it('first timeframe item refers to 2 quarters prior to current quarter', () => { const timeframeItem = timeframe[0]; const expectedQuarter = { - 0: { month: 9, date: 1 }, // 1 Oct 2017 - 1: { month: 10, date: 1 }, // 1 Nov 2017 - 2: { month: 11, date: 31 }, // 31 Dec 2017 + 0: { month: 6, date: 1 }, // 1 Jul 2017 + 1: { month: 7, date: 1 }, // 1 Aug 2017 + 2: { month: 8, date: 30 }, // 30 Sep 2017 }; - expect(timeframeItem.quarterSequence).toEqual(4); + expect(timeframeItem.quarterSequence).toEqual(3); expect(timeframeItem.year).toEqual(2017); timeframeItem.range.forEach((month, index) => { expect(month.getFullYear()).toBe(2017); @@ -61,6 +81,44 @@ describe('getTimeframeForQuartersView', () => { }); }); +describe('extendTimeframeForQuartersView', () => { + it('returns extended timeframe into the past from current timeframe startDate', () => { + const initialDate = mockTimeframeQuarters[0].range[0]; + + const extendedTimeframe = extendTimeframeForQuartersView(initialDate, -9); + + expect(extendedTimeframe.length).toBe(mockTimeframeQuartersPrepend.length); + extendedTimeframe.forEach((timeframeItem, index) => { + expect(timeframeItem.year).toBe(mockTimeframeQuartersPrepend[index].year); + expect(timeframeItem.quarterSequence).toBe( + mockTimeframeQuartersPrepend[index].quarterSequence, + ); + + timeframeItem.range.forEach((rangeItem, j) => { + expect(rangeItem.getTime()).toBe(mockTimeframeQuartersPrepend[index].range[j].getTime()); + }); + }); + }); + + it('returns extended timeframe into the future from current timeframe endDate', () => { + const initialDate = mockTimeframeQuarters[mockTimeframeQuarters.length - 1].range[2]; + + const extendedTimeframe = extendTimeframeForQuartersView(initialDate, 9); + + expect(extendedTimeframe.length).toBe(mockTimeframeQuartersAppend.length); + extendedTimeframe.forEach((timeframeItem, index) => { + expect(timeframeItem.year).toBe(mockTimeframeQuartersAppend[index].year); + expect(timeframeItem.quarterSequence).toBe( + mockTimeframeQuartersAppend[index].quarterSequence, + ); + + timeframeItem.range.forEach((rangeItem, j) => { + expect(rangeItem.getTime()).toBe(mockTimeframeQuartersAppend[index].range[j].getTime()); + }); + }); + }); +}); + describe('getTimeframeForMonthsView', () => { let timeframe; @@ -68,15 +126,15 @@ describe('getTimeframeForMonthsView', () => { timeframe = getTimeframeForMonthsView(new Date(2018, 0, 1)); }); - it('returns timeframe with total of 7 months', () => { - expect(timeframe.length).toBe(7); + it('returns timeframe with total of 8 months', () => { + expect(timeframe.length).toBe(8); }); - it('first timeframe item refers to month prior to current month', () => { + it('first timeframe item refers to 2 months prior to current month', () => { const timeframeItem = timeframe[0]; const expectedMonth = { year: 2017, - month: 11, + month: 10, date: 1, }; @@ -99,6 +157,28 @@ describe('getTimeframeForMonthsView', () => { }); }); +describe('extendTimeframeForMonthsView', () => { + it('returns extended timeframe into the past from current timeframe startDate', () => { + const initialDate = mockTimeframeMonths[0]; + const extendedTimeframe = extendTimeframeForMonthsView(initialDate, -6); + + expect(extendedTimeframe.length).toBe(mockTimeframeMonthsPrepend.length); + extendedTimeframe.forEach((timeframeItem, index) => { + expect(timeframeItem.getTime()).toBe(mockTimeframeMonthsPrepend[index].getTime()); + }); + }); + + it('returns extended timeframe into the future from current timeframe endDate', () => { + const initialDate = mockTimeframeMonths[mockTimeframeMonths.length - 1]; + const extendedTimeframe = extendTimeframeForMonthsView(initialDate, 7); + + expect(extendedTimeframe.length).toBe(mockTimeframeMonthsAppend.length); + extendedTimeframe.forEach((timeframeItem, index) => { + expect(timeframeItem.getTime()).toBe(mockTimeframeMonthsAppend[index].getTime()); + }); + }); +}); + describe('getTimeframeForWeeksView', () => { let timeframe; @@ -106,16 +186,16 @@ describe('getTimeframeForWeeksView', () => { timeframe = getTimeframeForWeeksView(new Date(2018, 0, 1)); }); - it('returns timeframe with total of 6 weeks', () => { - expect(timeframe.length).toBe(6); + it('returns timeframe with total of 7 weeks', () => { + expect(timeframe.length).toBe(7); }); - it('first timeframe item refers to week prior to current week', () => { + it('first timeframe item refers to 2 weeks prior to current week', () => { const timeframeItem = timeframe[0]; const expectedMonth = { year: 2017, month: 11, - date: 24, + date: 17, }; expect(timeframeItem.getFullYear()).toBe(expectedMonth.year); @@ -137,6 +217,67 @@ describe('getTimeframeForWeeksView', () => { }); }); +describe('extendTimeframeForWeeksView', () => { + it('returns extended timeframe into the past from current timeframe startDate', () => { + const extendedTimeframe = extendTimeframeForWeeksView(mockTimeframeWeeks[0], -6); // initialDate: 17 Dec 2017 + + expect(extendedTimeframe.length).toBe(mockTimeframeWeeksPrepend.length); + extendedTimeframe.forEach((timeframeItem, index) => { + expect(timeframeItem.getTime()).toBe(mockTimeframeWeeksPrepend[index].getTime()); + }); + }); + + it('returns extended timeframe into the future from current timeframe endDate', () => { + const extendedTimeframe = extendTimeframeForWeeksView( + mockTimeframeWeeks[mockTimeframeWeeks.length - 1], // initialDate: 28 Jan 2018 + 6, + ); + + expect(extendedTimeframe.length).toBe(mockTimeframeWeeksAppend.length); + extendedTimeframe.forEach((timeframeItem, index) => { + expect(timeframeItem.getTime()).toBe(mockTimeframeWeeksAppend[index].getTime()); + }); + }); +}); + +describe('extendTimeframeForAvailableWidth', () => { + let timeframe; + let timeframeStart; + let timeframeEnd; + + beforeEach(() => { + timeframe = mockTimeframeMonths.slice(); + [timeframeStart] = timeframe; + timeframeEnd = timeframe[timeframe.length - 1]; + }); + + it('should not extend `timeframe` when availableTimeframeWidth is small enough to force horizontal scrollbar to show up', () => { + extendTimeframeForAvailableWidth({ + availableTimeframeWidth: 100, + presetType: PRESET_TYPES.MONTHS, + timeframe, + timeframeStart, + timeframeEnd, + }); + + expect(timeframe.length).toBe(mockTimeframeMonths.length); + }); + + it('should extend `timeframe` when availableTimeframeWidth is large enough that it can fit more timeframe items to show up horizontal scrollbar', () => { + extendTimeframeForAvailableWidth({ + availableTimeframeWidth: 2000, + presetType: PRESET_TYPES.MONTHS, + timeframe, + timeframeStart, + timeframeEnd, + }); + + expect(timeframe.length).toBe(16); + expect(timeframe[0].getTime()).toBe(1498867200000); // 1 July 2017 + expect(timeframe[timeframe.length - 1].getTime()).toBe(1540944000000); // 31 Oct 2018 + }); +}); + describe('getEpicsPathForPreset', () => { const basePath = '/groups/gitlab-org/-/epics.json'; const filterQueryString = 'scope=all&utf8=✓&state=opened&label_name[]=Bug'; @@ -149,7 +290,7 @@ describe('getEpicsPathForPreset', () => { presetType: PRESET_TYPES.QUARTERS, }); - expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-10-1&end_date=2019-3-31`); + expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-7-1&end_date=2019-3-31`); }); it('returns epics path string based on provided basePath and timeframe for Months', () => { @@ -160,7 +301,7 @@ describe('getEpicsPathForPreset', () => { presetType: PRESET_TYPES.MONTHS, }); - expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-12-1&end_date=2018-6-30`); + expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-11-1&end_date=2018-6-30`); }); it('returns epics path string based on provided basePath and timeframe for Weeks', () => { @@ -171,7 +312,7 @@ describe('getEpicsPathForPreset', () => { presetType: PRESET_TYPES.WEEKS, }); - expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-12-24&end_date=2018-2-3`); + expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-12-17&end_date=2018-2-3`); }); it('returns epics path string while preserving filterQueryString', () => { @@ -184,7 +325,94 @@ describe('getEpicsPathForPreset', () => { }); expect(epicsPath).toBe( - `${basePath}?state=all&start_date=2017-12-1&end_date=2018-6-30&scope=all&utf8=✓&state=opened&label_name[]=Bug`, + `${basePath}?state=all&start_date=2017-11-1&end_date=2018-6-30&scope=all&utf8=✓&state=opened&label_name[]=Bug`, ); }); + + it('returns epics path string containing epicsState', () => { + const epicsState = 'opened'; + const timeframe = getTimeframeForMonthsView(new Date(2018, 0, 1)); + const epicsPath = getEpicsPathForPreset({ + presetType: PRESET_TYPES.MONTHS, + basePath, + timeframe, + epicsState, + }); + + expect(epicsPath).toContain(`state=${epicsState}`); + }); +}); + +describe('sortEpics', () => { + it('sorts epics list by startDate in ascending order when `sortedBy` param is `start_date_asc`', () => { + const epics = mockUnsortedEpics.slice(); + const sortedOrder = [ + new Date(2014, 3, 17), + new Date(2015, 5, 8), + new Date(2017, 2, 12), + new Date(2019, 4, 12), + ]; + + sortEpics(epics, 'start_date_asc'); + + expect(epics.length).toBe(mockUnsortedEpics.length); + + epics.forEach((epic, index) => { + expect(epic.startDate.getTime()).toBe(sortedOrder[index].getTime()); + }); + }); + + it('sorts epics list by startDate in descending order when `sortedBy` param is `start_date_desc`', () => { + const epics = mockUnsortedEpics.slice(); + const sortedOrder = [ + new Date(2019, 4, 12), + new Date(2017, 2, 12), + new Date(2015, 5, 8), + new Date(2014, 3, 17), + ]; + + sortEpics(epics, 'start_date_desc'); + + expect(epics.length).toBe(mockUnsortedEpics.length); + + epics.forEach((epic, index) => { + expect(epic.startDate.getTime()).toBe(sortedOrder[index].getTime()); + }); + }); + + it('sorts epics list by endDate in ascending order when `sortedBy` param is `end_date_asc`', () => { + const epics = mockUnsortedEpics.slice(); + const sortedOrder = [ + new Date(2015, 7, 15), + new Date(2016, 3, 1), + new Date(2017, 7, 20), + new Date(2019, 7, 30), + ]; + + sortEpics(epics, 'end_date_asc'); + + expect(epics.length).toBe(mockUnsortedEpics.length); + + epics.forEach((epic, index) => { + expect(epic.endDate.getTime()).toBe(sortedOrder[index].getTime()); + }); + }); + + it('sorts epics list by endDate in descending order when `sortedBy` param is `end_date_desc`', () => { + const epics = mockUnsortedEpics.slice(); + const sortedOrder = [ + new Date(2019, 7, 30), + new Date(2017, 7, 20), + new Date(2016, 3, 1), + new Date(2015, 7, 15), + ]; + + sortEpics(epics, 'end_date_desc'); + + expect(epics.length).toBe(mockUnsortedEpics.length); + + epics.forEach((epic, index) => { + expect(epic.endDate.getTime()).toBe(sortedOrder[index].getTime()); + }); + }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6858c2811b1cd16faa788c8b827cbb33b37341c9..0e9344f1bbb73f3c24c0928ea13b13c42ead3b9f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 – 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 – 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 – 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 – 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 – 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 – from %{startDate} to %{endDate}." +msgid "GroupRoadmap|To widen your search, change or remove filters; from %{startDate} to %{endDate}." msgstr "" msgid "GroupRoadmap|Until %{dateWord}"