Commit 5ff494d3 authored by Fatih Acet's avatar Fatih Acet

Merge branch '9245-roadmap-ux-enhancements' into 'master'

Roadmap infinite scrolling UX enhancements

Closes #9245

See merge request gitlab-org/gitlab-ee!9242
parents d78e7abd a0ee4a9b
......@@ -3,7 +3,6 @@ import _ from 'underscore';
import Flash from '~/flash';
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';
......@@ -14,7 +13,6 @@ export default {
components: {
epicsListEmpty,
roadmapShell,
GlLoadingIcon,
},
props: {
store: {
......@@ -44,7 +42,7 @@ export default {
},
data() {
return {
isLoading: true,
isLoading: false,
isEpicsListEmpty: false,
hasError: false,
handleResizeThrottled: {},
......@@ -86,9 +84,16 @@ export default {
.getEpics()
.then(res => res.data)
.then(epics => {
this.isLoading = false;
if (epics.length) {
this.store.setEpics(epics);
this.$nextTick(() => {
// Render timeline bars as we're already having timeline
// rendered before fetch
eventHub.$emit('refreshTimeline', {
todayBarReady: true,
initialRender: true,
});
});
} else {
this.isEpicsListEmpty = true;
}
......@@ -107,14 +112,15 @@ export default {
.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,
this.processExtendedTimeline({
itemsCount: timeframe ? timeframe.length : 0,
extendType,
roadmapTimelineEl,
});
});
}
})
.catch(() => {
this.hasError = true;
......@@ -156,7 +162,6 @@ export default {
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,
});
......@@ -172,16 +177,10 @@ export default {
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,
roadmapTimelineEl,
});
});
},
......@@ -191,12 +190,6 @@ export default {
<template>
<div :class="{ 'overflow-reset': isEpicsListEmpty }" class="roadmap-container">
<gl-loading-icon
v-if="isLoading"
:label="s__('GroupRoadmap|Loading roadmap')"
:size="2"
class="loading-animation prepend-top-20 append-bottom-20"
/>
<roadmap-shell
v-if="showRoadmap"
:preset-type="presetType"
......
......@@ -57,7 +57,7 @@ export default {
},
},
watch: {
shellWidth: function shellWidth() {
shellWidth() {
// Render timeline bar only when shellWidth is updated.
this.renderTimelineBar();
},
......
......@@ -3,7 +3,7 @@ import eventHub from '../event_hub';
import SectionMixin from '../mixins/section_mixin';
import { TIMELINE_CELL_MIN_WIDTH } from '../constants';
import { TIMELINE_CELL_MIN_WIDTH, EPIC_ITEM_HEIGHT } from '../constants';
import epicItem from './epic_item.vue';
......@@ -74,18 +74,14 @@ export default {
},
mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$on('refreshTimeline', () => {
this.initEmptyRow(false);
});
eventHub.$on('refreshTimeline', this.handleTimelineRefresh);
this.$nextTick(() => {
this.initMounted();
});
},
beforeDestroy() {
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$off('refreshTimeline', () => {
this.initEmptyRow(false);
});
eventHub.$off('refreshTimeline', this.handleTimelineRefresh);
},
methods: {
initMounted() {
......@@ -112,7 +108,7 @@ export default {
* based on height of available list items and sets it to component
* props.
*/
initEmptyRow(showEmptyRow = false) {
initEmptyRow() {
const children = this.$children;
let approxChildrenHeight = children[0].$el.clientHeight * this.epics.length;
......@@ -130,7 +126,6 @@ export default {
this.emptyRowHeight = this.shellHeight - approxChildrenHeight;
this.showEmptyRow = true;
} else {
this.showEmptyRow = showEmptyRow;
this.showBottomShadow = true;
}
},
......@@ -141,6 +136,28 @@ export default {
scrollToTodayIndicator() {
this.$el.parentElement.scrollBy(TIMELINE_CELL_MIN_WIDTH / 2, 0);
},
/**
* Method to update list section when refreshTimeline event
* is emitted on eventHub
*
* This method ensures that empty row is toggled
* based on whether list has enough epics to make
* list vertically scrollable.
*/
handleTimelineRefresh({ initialRender = false }) {
// Initialize emptyRow only once.
if (initialRender) {
this.initEmptyRow();
}
// Check if container height is less than total height of all Epic
// items combined (AKA list is scrollable).
const isListVertScrollable =
this.$el.parentElement.offsetHeight < EPIC_ITEM_HEIGHT * (this.epics.length + 1);
// Toggle empty row.
this.showEmptyRow = !isListVertScrollable;
},
handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) {
this.showBottomShadow = Math.ceil(scrollTop) + clientHeight < scrollHeight;
},
......
<script>
import bp from '~/breakpoints';
import { GlSkeletonLoading } from '@gitlab/ui';
import { isInViewport } from '~/lib/utils/common_utils';
import { SCROLL_BAR_SIZE, EPIC_ITEM_HEIGHT, SHELL_MIN_WIDTH, EXTEND_AS } from '../constants';
import { SCROLL_BAR_SIZE, EPIC_ITEM_HEIGHT, EXTEND_AS } from '../constants';
import eventHub from '../event_hub';
import epicsListSection from './epics_list_section.vue';
......@@ -9,6 +10,7 @@ import roadmapTimelineSection from './roadmap_timeline_section.vue';
export default {
components: {
GlSkeletonLoading,
epicsListSection,
roadmapTimelineSection,
},
......@@ -40,18 +42,29 @@ export default {
},
computed: {
containerStyles() {
const width =
bp.windowWidth() > SHELL_MIN_WIDTH
? this.shellWidth + this.getWidthOffset()
: this.shellWidth;
return {
width: `${width}px`,
width: `${this.shellWidth}px`,
height: `${this.shellHeight}px`,
};
},
},
watch: {
/**
* Watcher to monitor whether epics list is long enough
* to allow vertical list scrolling.
*
* In case of scrollable list, we don't want vertical scrollbar
* to be visible, so we mask the scrollbar by increasing shell
* width past the scrollbar size.
*/
noScroll(value) {
if (this.$el.parentElement) {
this.shellWidth = this.getShellWidth(value);
}
},
},
mounted() {
eventHub.$on('refreshTimeline', this.handleEpicsListRendered);
this.$nextTick(() => {
// Client width at the time of component mount will not
// provide accurate size of viewport until child contents are
......@@ -62,18 +75,28 @@ export default {
if (this.$el.parentElement) {
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.shellWidth = this.getShellWidth(this.noScroll);
// We're guarding this as in tests, `roadmapTimeline`
// is not ready when this line is executed.
if (this.$refs.roadmapTimeline) {
this.timeframeStartOffset = this.$refs.roadmapTimeline.$el
.querySelector('.timeline-header-item')
.querySelector('.item-sublabel .sublabel-value:first-child')
.getBoundingClientRect().left;
}
}
});
},
beforeDestroy() {
eventHub.$off('refreshTimeline', this.handleEpicsListRendered);
},
methods: {
getWidthOffset() {
return this.noScroll ? 0 : SCROLL_BAR_SIZE;
getShellWidth(noScroll) {
return this.$el.parentElement.clientWidth + (noScroll ? 0 : SCROLL_BAR_SIZE);
},
handleEpicsListRendered() {
this.noScroll = this.shellHeight > EPIC_ITEM_HEIGHT * (this.epics.length + 1);
},
handleScroll() {
const { scrollTop, scrollLeft, clientHeight, scrollHeight } = this.$el;
......@@ -92,7 +115,6 @@ export default {
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 });
},
},
......@@ -114,6 +136,11 @@ export default {
:shell-width="shellWidth"
:list-scrollable="!noScroll"
/>
<div v-if="!epics.length" class="skeleton-loader js-skeleton-loader">
<div v-for="n in 10" :key="n" class="mt-2">
<gl-skeleton-loading :lines="2" />
</div>
</div>
<epics-list-section
:preset-type="presetType"
:epics="epics"
......
<script>
import { totalDaysInMonth, dayInQuarter, totalDaysInQuarter } from '~/lib/utils/datetime_utility';
import { EPIC_DETAILS_CELL_WIDTH, PRESET_TYPES } from '../constants';
import { EPIC_DETAILS_CELL_WIDTH, PRESET_TYPES, DAYS_IN_WEEK } from '../constants';
import eventHub from '../event_hub';
......@@ -42,8 +42,9 @@ export default {
* and renders vertical line over the area where
* today falls in current timeline
*/
handleEpicsListRender({ height, todayBarReady }) {
handleEpicsListRender({ todayBarReady }) {
let left = 0;
let height = 0;
// Get total days of current timeframe Item and then
// get size in % from current date and days in range
......@@ -59,15 +60,21 @@ export default {
(this.currentDate.getDate() / totalDaysInMonth(this.timeframeItem)) * 100,
);
} else if (this.presetType === PRESET_TYPES.WEEKS) {
left = Math.floor(((this.currentDate.getDay() + 1) / 7) * 100 - 7);
left = Math.floor(((this.currentDate.getDay() + 1) / DAYS_IN_WEEK) * 100 - DAYS_IN_WEEK);
}
// On initial load, container element height is 0
if (this.$root.$el.clientHeight === 0) {
height = window.innerHeight - this.$root.$el.offsetTop;
} else {
// When list is scrollable both vertically and horizontally
// We set height using top-level parent container height & position of
// today indicator element container.
height = this.$root.$el.clientHeight - this.$el.parentElement.offsetTop;
}
// We add 20 to height to ensure that
// today indicator goes past the bottom
// edge of the browser even when
// scrollbar is present
this.todayBarStyles = {
height: `${height + 20}px`,
height: `${height}px`,
left: `${left}%`,
};
this.todayBarReady = todayBarReady === undefined ? true : todayBarReady;
......
......@@ -6,8 +6,6 @@ export const EPIC_ITEM_HEIGHT = 50;
export const TIMELINE_CELL_MIN_WIDTH = 180;
export const SHELL_MIN_WIDTH = 1620;
export const SCROLL_BAR_SIZE = 15;
export const EPIC_HIGHLIGHT_REMOVE_AFTER = 3000;
......
......@@ -204,7 +204,7 @@ export const getTimeframeForWeeksView = (initialDate = new Date(), length) => {
timeframe.push(newDate(startDate));
// Move date next Sunday
startDate.setDate(startDate.getDate() + 7);
startDate.setDate(startDate.getDate() + DAYS_IN_WEEK);
}
return timeframe;
......@@ -216,12 +216,10 @@ export const extendTimeframeForWeeksView = (initialDate = new Date(), length) =>
if (length < 0) {
// When length is negative, we need to go
// back as many weeks in time as value of length
startDate.setDate(startDate.getDate() + (length + 1) * 7);
} else {
startDate.setDate(startDate.getDate());
startDate.setDate(startDate.getDate() + length * DAYS_IN_WEEK);
}
return getTimeframeForWeeksView(startDate, Math.abs(length) + 1);
return getTimeframeForWeeksView(startDate, Math.abs(length));
};
export const extendTimeframeForPreset = ({
......@@ -348,8 +346,8 @@ export const getTimeframeForPreset = (
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
timeframeStart.setDate(timeframeStart.getDate());
timeframeEnd.setDate(timeframeEnd.getDate() + DAYS_IN_WEEK); // Move date ahead by a week
}
// Extend timeframe on initial load to ensure
......@@ -413,30 +411,40 @@ export const getEpicsPathForPreset = ({
export const sortEpics = (epics, sortedBy) => {
const sortByStartDate = sortedBy.indexOf('start_date') > -1;
const sortOrderAsc = sortedBy.indexOf('asc') > -1;
const pastDate = new Date(new Date().getFullYear() - 100, 0, 1);
const futureDate = new Date(new Date().getFullYear() + 100, 0, 1);
epics.sort((a, b) => {
let aDate;
let bDate;
if (sortByStartDate) {
aDate = a.startDate;
if (a.startDateOutOfRange) {
aDate = a.originalStartDate;
if (a.startDateUndefined) {
// Set proxy date to be far in the past
// to ensure sort order is correct.
aDate = pastDate;
} else {
aDate = a.startDateOutOfRange ? a.originalStartDate : a.startDate;
}
bDate = b.startDate;
if (b.startDateOutOfRange) {
bDate = b.originalStartDate;
if (b.startDateUndefined) {
aDate = pastDate;
} else {
bDate = b.startDateOutOfRange ? b.originalStartDate : b.startDate;
}
} else {
aDate = a.endDate;
if (a.endDateOutOfRange) {
aDate = a.originalEndDate;
if (a.endDateUndefined) {
// Set proxy date to be far into the future
// to ensure sort order is correct.
aDate = futureDate;
} else {
aDate = a.endDateOutOfRange ? a.originalEndDate : a.endDate;
}
bDate = b.endDate;
if (b.endDateOutOfRange) {
bDate = b.originalEndDate;
if (b.endDateUndefined) {
bDate = futureDate;
} else {
bDate = b.endDateOutOfRange ? b.originalEndDate : b.endDate;
}
}
......
$header-item-height: 60px;
$item-height: 50px;
$details-cell-width: 320px;
$timeline-cell-width: 180px;
$border-style: 1px solid $border-gray-normal;
$roadmap-gradient-dark-gray: rgba(0, 0, 0, 0.15);
$roadmap-gradient-gray: rgba(255, 255, 255, 0.001);
......@@ -118,15 +119,32 @@ $column-right-gradient: linear-gradient(
}
.roadmap-shell {
position: relative;
overflow-x: auto;
.skeleton-loader {
position: absolute;
top: $header-item-height;
left: $timeline-cell-width / 2;
width: $details-cell-width;
height: 100%;
padding-top: $gl-padding-top;
padding-left: $gl-padding;
z-index: 4;
&::after {
height: 100%;
}
}
&.prevent-vertical-scroll {
overflow-y: hidden;
}
}
.roadmap-timeline-section .timeline-header-blank::after,
.epics-list-section .epic-details-cell::after {
.epics-list-section .epic-details-cell::after,
.skeleton-loader::after {
content: "";
position: absolute;
top: 0;
......
......@@ -66,7 +66,7 @@ describe('AppComponent', () => {
describe('data', () => {
it('returns default data props', () => {
expect(vm.isLoading).toBe(true);
expect(vm.isLoading).toBe(false);
expect(vm.isEpicsListEmpty).toBe(false);
expect(vm.hasError).toBe(false);
expect(vm.handleResizeThrottled).toBeDefined();
......@@ -150,6 +150,7 @@ describe('AppComponent', () => {
it('calls service.getEpics and sets response to the store on success', done => {
mock.onGet(vm.service.epicsPath).reply(200, rawEpics);
spyOn(vm.store, 'setEpics');
spyOn(eventHub, '$emit');
vm.fetchEpics();
......@@ -157,7 +158,19 @@ describe('AppComponent', () => {
setTimeout(() => {
expect(vm.isLoading).toBe(false);
expect(vm.store.setEpics).toHaveBeenCalledWith(rawEpics);
done();
vm.$nextTick()
.then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith(
'refreshTimeline',
jasmine.objectContaining({
todayBarReady: true,
initialRender: true,
}),
);
})
.then(done)
.catch(done.fail);
}, 0);
});
......@@ -210,13 +223,15 @@ describe('AppComponent', () => {
it('calls service.fetchEpicsForTimeframe and adds response to the store on success', done => {
mock.onGet(vm.service.epicsPath).reply(200, rawEpics);
const extendType = EXTEND_AS.APPEND;
spyOn(vm.service, 'getEpicsForTimeframe').and.callThrough();
spyOn(vm.store, 'addEpics');
spyOn(vm, '$nextTick').and.stub();
spyOn(vm, 'processExtendedTimeline');
vm.fetchEpicsForTimeframe({
timeframe: mockTimeframeMonths,
extendType: EXTEND_AS.APPEND,
extendType,
roadmapTimelineEl,
});
......@@ -227,7 +242,18 @@ describe('AppComponent', () => {
);
setTimeout(() => {
expect(vm.store.addEpics).toHaveBeenCalledWith(rawEpics);
done();
vm.$nextTick()
.then(() => {
expect(vm.processExtendedTimeline).toHaveBeenCalledWith(
jasmine.objectContaining({
itemsCount: 8,
extendType,
roadmapTimelineEl,
}),
);
})
.then(done)
.catch(done.fail);
}, 0);
});
......@@ -307,7 +333,6 @@ describe('AppComponent', () => {
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;
......@@ -319,14 +344,6 @@ describe('AppComponent', () => {
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
......@@ -343,7 +360,6 @@ describe('AppComponent', () => {
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;
......@@ -355,14 +371,6 @@ describe('AppComponent', () => {
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
......
......@@ -7,7 +7,7 @@ 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, mockTimeframeInitialDate, mockGroupId, mockScrollBarSize } from '../mock_data';
import { mockEpic, mockTimeframeInitialDate, mockGroupId } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
......@@ -45,6 +45,7 @@ describe('RoadmapShellComponent', () => {
expect(vm.shellWidth).toBe(0);
expect(vm.shellHeight).toBe(0);
expect(vm.noScroll).toBe(false);
expect(vm.timeframeStartOffset).toBe(0);
});
});
......@@ -59,7 +60,7 @@ describe('RoadmapShellComponent', () => {
document.querySelector('.roadmap-container').remove();
});
it('returns style object based on shellWidth and current Window width with Scollbar size offset', done => {
it('returns style object based on shellWidth and shellHeight', done => {
const vmWithParentEl = createComponent({}, document.getElementById('roadmap-shell'));
Vue.nextTick(() => {
const stylesObj = vmWithParentEl.containerStyles;
......@@ -75,21 +76,6 @@ describe('RoadmapShellComponent', () => {
});
describe('methods', () => {
describe('getWidthOffset', () => {
it('returns 0 when noScroll prop is true', () => {
vm.noScroll = true;
expect(vm.getWidthOffset()).toBe(0);
});
it('returns value of SCROLL_BAR_SIZE when noScroll prop is false', () => {
vm.noScroll = false;
expect(vm.getWidthOffset()).toBe(mockScrollBarSize);
});
});
describe('handleScroll', () => {
beforeEach(() => {
document.body.innerHTML +=
'<div class="roadmap-container"><div id="roadmap-shell"></div></div>';
......@@ -99,6 +85,7 @@ describe('RoadmapShellComponent', () => {
document.querySelector('.roadmap-container').remove();
});
describe('handleScroll', () => {
it('emits `epicsListScrolled` event via eventHub', done => {
const vmWithParentEl = createComponent({}, document.getElementById('roadmap-shell'));
spyOn(eventHub, '$emit');
......@@ -123,6 +110,17 @@ describe('RoadmapShellComponent', () => {
expect(vm.$el.classList.contains('roadmap-shell')).toBe(true);
});
it('renders skeleton loader element when Epics list is empty', done => {
vm.epics = [];
vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.js-skeleton-loader')).not.toBeNull();
})
.then(done)
.catch(done.fail);
});
it('adds `prevent-vertical-scroll` class on component container element', done => {
vm.noScroll = true;
Vue.nextTick(() => {
......
......@@ -49,17 +49,24 @@ describe('TimelineTodayIndicatorComponent', () => {
describe('methods', () => {
describe('handleEpicsListRender', () => {
it('sets `todayBarStyles` and `todayBarReady` props based on provided height param, timeframeItem and currentDate props', () => {
it('sets `todayBarStyles` and `todayBarReady` props', () => {
vm = createComponent({});
vm.handleEpicsListRender({
height: 100,
});
vm.handleEpicsListRender({});
const stylesObj = vm.todayBarStyles;
expect(stylesObj.height).toBe('120px');
expect(stylesObj.height).toBe('600px');
expect(stylesObj.left).toBe('50%');
expect(vm.todayBarReady).toBe(true);
});
it('sets `todayBarReady` prop based on value of provided `todayBarReady` param', () => {
vm = createComponent({});
vm.handleEpicsListRender({
todayBarReady: false,
});
expect(vm.todayBarReady).toBe(false);
});
});
});
......@@ -90,9 +97,7 @@ describe('TimelineTodayIndicatorComponent', () => {
describe('template', () => {
it('renders component container element with class `today-bar`', done => {
vm = createComponent({});
vm.handleEpicsListRender({
height: 100,
});
vm.handleEpicsListRender({});
vm.$nextTick(() => {
expect(vm.$el.classList.contains('today-bar')).toBe(true);
done();
......
......@@ -70,13 +70,12 @@ export const mockTimeframeMonthsAppend = [
];
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),
new Date(2017, 11, 17),
new Date(2017, 11, 24),
];
export const mockTimeframeWeeksAppend = [
new Date(2018, 0, 28),
......@@ -85,7 +84,6 @@ export const mockTimeframeWeeksAppend = [
new Date(2018, 1, 18),
new Date(2018, 1, 25),
new Date(2018, 2, 4),
new Date(2018, 2, 11),
];
export const mockEpic = {
......
......@@ -4529,9 +4529,6 @@ msgstr ""
msgid "GroupRoadmap|From %{dateWord}"
msgstr ""
msgid "GroupRoadmap|Loading roadmap"
msgstr ""
msgid "GroupRoadmap|Something went wrong while fetching epics"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment