Commit 3715a495 authored by Phil Hughes's avatar Phil Hughes

Merge branch '5068-roadmap-relayout' into 'master'

Roadmap markup relayout

Closes #5068 and #4848

See merge request gitlab-org/gitlab-ee!5020
parents e8ce2068 be7901fb
...@@ -24,12 +24,16 @@ ...@@ -24,12 +24,16 @@
type: Number, type: Number,
required: true, required: true,
}, },
itemWidth: {
type: Number,
required: true,
},
}, },
}; };
</script> </script>
<template> <template>
<tr class="epics-list-item"> <div class="epics-list-item clearfix">
<epic-item-details <epic-item-details
:epic="epic" :epic="epic"
:current-group-id="currentGroupId" :current-group-id="currentGroupId"
...@@ -41,6 +45,7 @@ ...@@ -41,6 +45,7 @@
:timeframe-item="timeframeItem" :timeframe-item="timeframeItem"
:epic="epic" :epic="epic"
:shell-width="shellWidth" :shell-width="shellWidth"
:item-width="itemWidth"
/> />
</tr> </div>
</template> </template>
...@@ -72,7 +72,7 @@ ...@@ -72,7 +72,7 @@
</script> </script>
<template> <template>
<td class="epic-details-cell"> <span class="epic-details-cell">
<div class="epic-title"> <div class="epic-title">
<a <a
v-tooltip v-tooltip
...@@ -101,5 +101,5 @@ ...@@ -101,5 +101,5 @@
> >
</span> </span>
</div> </div>
</td> </span>
</template> </template>
...@@ -30,6 +30,10 @@ ...@@ -30,6 +30,10 @@
type: Number, type: Number,
required: true, required: true,
}, },
itemWidth: {
type: Number,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -38,8 +42,10 @@ ...@@ -38,8 +42,10 @@
}; };
}, },
computed: { computed: {
tdStyles() { itemStyles() {
return `min-width: ${this.getCellWidth()}px;`; return {
width: `${this.itemWidth}px`,
};
}, },
showTimelineBar() { showTimelineBar() {
return this.hasStartDate(); return this.hasStartDate();
...@@ -53,15 +59,14 @@ ...@@ -53,15 +59,14 @@
}, },
methods: { methods: {
/** /**
* Gets cell width based on total number cells for * Gets cell width based on total number months for
* current timeframe and shellWidth. * current timeframe and shellWidth excluding details cell width.
* *
* In case cell width is too narrow, we have fixed minimum * In case cell width is too narrow, we have fixed minimum
* cell width (TIMELINE_CELL_MIN_WIDTH) to obey. * cell width (TIMELINE_CELL_MIN_WIDTH) to obey.
*/ */
getCellWidth() { getCellWidth() {
const minWidth = const minWidth = (this.shellWidth - EPIC_DETAILS_CELL_WIDTH) / this.timeframe.length;
Math.ceil((this.shellWidth - EPIC_DETAILS_CELL_WIDTH) / this.timeframe.length);
return Math.max(minWidth, TIMELINE_CELL_MIN_WIDTH); return Math.max(minWidth, TIMELINE_CELL_MIN_WIDTH);
}, },
...@@ -229,7 +234,7 @@ ...@@ -229,7 +234,7 @@
} }
// Reduce any offset from total width and round it off. // Reduce any offset from total width and round it off.
return Math.round(timelineBarWidth - offsetEnd); return timelineBarWidth - offsetEnd;
}, },
/** /**
* Renders timeline bar only if current * Renders timeline bar only if current
...@@ -246,9 +251,9 @@ ...@@ -246,9 +251,9 @@
</script> </script>
<template> <template>
<td <span
class="epic-timeline-cell" class="epic-timeline-cell"
:style="tdStyles" :style="itemStyles"
> >
<div class="timeline-bar-wrapper"> <div class="timeline-bar-wrapper">
<a <a
...@@ -265,5 +270,5 @@ ...@@ -265,5 +270,5 @@
> >
</a> </a>
</div> </div>
</td> </span>
</template> </template>
<script> <script>
import $ from 'jquery';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { SCROLL_BAR_SIZE } from '../constants'; import SectionMixin from '../mixins/section_mixin';
import epicItem from './epic_item.vue'; import epicItem from './epic_item.vue';
...@@ -10,6 +9,9 @@ ...@@ -10,6 +9,9 @@
components: { components: {
epicItem, epicItem,
}, },
mixins: [
SectionMixin,
],
props: { props: {
epics: { epics: {
type: Array, type: Array,
...@@ -27,6 +29,10 @@ ...@@ -27,6 +29,10 @@
type: Number, type: Number,
required: true, required: true,
}, },
listScrollable: {
type: Boolean,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -38,26 +44,20 @@ ...@@ -38,26 +44,20 @@
}; };
}, },
computed: { computed: {
/** emptyRowContainerStyles() {
* Return width after reducing scrollbar size return {
* such that Epic item cells do not consider height: `${this.emptyRowHeight}px`,
* scrollbar };
*/
calcShellWidth() {
return this.shellWidth - SCROLL_BAR_SIZE;
},
/**
* Adjust tbody styles while pushing scrollbar further away
* from the view
*/
tbodyStyles() {
return `width: ${this.shellWidth + SCROLL_BAR_SIZE}px; height: ${this.shellHeight}px;`;
}, },
emptyRowCellStyles() { emptyRowCellStyles() {
return `height: ${this.emptyRowHeight}px;`; return {
width: `${this.sectionItemWidth}px`,
};
}, },
shadowCellStyles() { shadowCellStyles() {
return `left: ${this.offsetLeft}px;`; return {
left: `${this.offsetLeft}px`,
};
}, },
}, },
watch: { watch: {
...@@ -69,14 +69,18 @@ ...@@ -69,14 +69,18 @@
}, },
}, },
mounted() { mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
this.$nextTick(() => { this.$nextTick(() => {
this.initMounted(); this.initMounted();
}); });
}, },
beforeDestroy() {
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
},
methods: { methods: {
initMounted() { initMounted() {
// Get available shell height based on viewport height // Get available shell height based on viewport height
this.shellHeight = window.innerHeight - (this.$el.offsetTop + this.$root.$el.offsetTop); this.shellHeight = window.innerHeight - this.$el.offsetTop;
// In case there are epics present, initialize empty row // In case there are epics present, initialize empty row
if (this.epics.length) { if (this.epics.length) {
...@@ -113,32 +117,12 @@ ...@@ -113,32 +117,12 @@
}); });
// set height and show empty row reducing horizontal scrollbar size // set height and show empty row reducing horizontal scrollbar size
this.emptyRowHeight = (this.shellHeight - approxChildrenHeight) - 1; this.emptyRowHeight = (this.shellHeight - approxChildrenHeight);
this.showEmptyRow = true; this.showEmptyRow = true;
} else { } else {
this.showBottomShadow = true; this.showBottomShadow = true;
} }
}, },
/**
* We can easily use `eventHub` and dispatch this event
* to all sibling and child components but it adds an overhead/delay
* resulting to janky element positioning. Hence, we directly
* update raw element properties upon event via jQuery.
*/
handleScroll() {
const { scrollTop, scrollLeft, scrollHeight, clientHeight } = this.$el;
const tableEl = this.$el.parentElement;
if (tableEl) {
const $theadEl = $(tableEl).find('thead');
const $tbodyEl = $(tableEl).find('tbody');
$theadEl.css('left', -scrollLeft);
$theadEl.find('th:nth-child(1)').css('left', scrollLeft);
$tbodyEl.find('td:nth-child(1)').css('left', scrollLeft);
}
this.showBottomShadow = (Math.ceil(scrollTop) + clientHeight) < scrollHeight;
eventHub.$emit('epicsListScrolled', scrollTop, scrollLeft);
},
/** /**
* `clientWidth` is full width of list section, and we need to * `clientWidth` is full width of list section, and we need to
* scroll up to 60% of the view where today indicator is present. * scroll up to 60% of the view where today indicator is present.
...@@ -149,45 +133,45 @@ ...@@ -149,45 +133,45 @@
const uptoTodayIndicator = Math.ceil((this.$el.clientWidth * 60) / 100); const uptoTodayIndicator = Math.ceil((this.$el.clientWidth * 60) / 100);
this.$el.scrollTo(uptoTodayIndicator, 0); this.$el.scrollTo(uptoTodayIndicator, 0);
}, },
handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) {
this.showBottomShadow = (Math.ceil(scrollTop) + clientHeight) < scrollHeight;
},
}, },
}; };
</script> </script>
<template> <template>
<tbody <div
class="epics-list-section" class="epics-list-section"
:style="tbodyStyles" :style="sectionContainerStyles"
@scroll="handleScroll"
> >
<tr
v-if="showBottomShadow"
class="bottom-shadow-cell"
:style="shadowCellStyles"
></tr>
<epic-item <epic-item
v-for="(epic, index) in epics" v-for="(epic, index) in epics"
:key="index" :key="index"
:epic="epic" :epic="epic"
:timeframe="timeframe" :timeframe="timeframe"
:current-group-id="currentGroupId" :current-group-id="currentGroupId"
:shell-width="calcShellWidth" :shell-width="sectionShellWidth"
:item-width="sectionItemWidth"
/> />
<tr <div
v-if="showEmptyRow" v-if="showEmptyRow"
class="epics-list-item epics-list-item-empty" class="epics-list-item epics-list-item-empty clearfix"
:style="emptyRowContainerStyles"
> >
<td <span class="epic-details-cell"></span>
class="epic-details-cell" <span
:style="emptyRowCellStyles"
>
</td>
<td
class="epic-timeline-cell"
v-for="(timeframeItem, index) in timeframe" v-for="(timeframeItem, index) in timeframe"
:key="index" :key="index"
class="epic-timeline-cell"
:style="emptyRowCellStyles" :style="emptyRowCellStyles"
> >
</td> </span>
</tr> </div>
</tbody> <div
v-if="showBottomShadow"
class="scroll-bottom-shadow"
:style="shadowCellStyles"
></div>
</div>
</template> </template>
<script> <script>
import { SCROLL_BAR_SIZE } from '../constants'; import bp from '~/breakpoints';
import { SCROLL_BAR_SIZE, EPIC_ITEM_HEIGHT, SHELL_MIN_WIDTH } from '../constants';
import eventHub from '../event_hub';
import epicsListSection from './epics_list_section.vue'; import epicsListSection from './epics_list_section.vue';
import roadmapTimelineSection from './roadmap_timeline_section.vue'; import roadmapTimelineSection from './roadmap_timeline_section.vue';
...@@ -26,13 +28,20 @@ ...@@ -26,13 +28,20 @@
data() { data() {
return { return {
shellWidth: 0, shellWidth: 0,
shellHeight: 0,
noScroll: false,
}; };
}, },
computed: { computed: {
tableStyles() { containerStyles() {
// return width after deducting size of vertical scrollbar const width = bp.windowWidth() > SHELL_MIN_WIDTH ?
// to hide the scrollbar while preserving ability to scroll this.shellWidth + this.getWidthOffset() :
return `width: ${this.shellWidth - SCROLL_BAR_SIZE}px;`; this.shellWidth;
return {
width: `${width}px`,
height: `${this.shellHeight}px`,
};
}, },
}, },
mounted() { mounted() {
...@@ -44,28 +53,45 @@ ...@@ -44,28 +53,45 @@
// before setting shellWidth // before setting shellWidth
// see https://vuejs.org/v2/api/#Vue-nextTick // see https://vuejs.org/v2/api/#Vue-nextTick
if (this.$el.parentElement) { if (this.$el.parentElement) {
this.shellWidth = this.$el.parentElement.clientWidth; 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();
} }
}); });
}, },
methods: {
getWidthOffset() {
return this.noScroll ? 0 : SCROLL_BAR_SIZE;
},
handleScroll() {
const { scrollTop, scrollLeft, clientHeight, scrollHeight } = this.$el;
if (!this.noScroll) {
eventHub.$emit('epicsListScrolled', { scrollTop, scrollLeft, clientHeight, scrollHeight });
}
},
},
}; };
</script> </script>
<template> <template>
<table <div
class="roadmap-shell" class="roadmap-shell"
:style="tableStyles" :class="{ 'prevent-vertical-scroll': noScroll }"
:style="containerStyles"
@scroll="handleScroll"
> >
<roadmap-timeline-section <roadmap-timeline-section
:epics="epics" :epics="epics"
:timeframe="timeframe" :timeframe="timeframe"
:shell-width="shellWidth" :shell-width="shellWidth"
:list-scrollable="!noScroll"
/> />
<epics-list-section <epics-list-section
:epics="epics" :epics="epics"
:timeframe="timeframe" :timeframe="timeframe"
:shell-width="shellWidth" :shell-width="shellWidth"
:current-group-id="currentGroupId" :current-group-id="currentGroupId"
:list-scrollable="!noScroll"
/> />
</table> </div>
</template> </template>
<script> <script>
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { SCROLL_BAR_SIZE } from '../constants'; import SectionMixin from '../mixins/section_mixin';
import timelineHeaderItem from './timeline_header_item.vue'; import timelineHeaderItem from './timeline_header_item.vue';
...@@ -9,6 +9,9 @@ ...@@ -9,6 +9,9 @@
components: { components: {
timelineHeaderItem, timelineHeaderItem,
}, },
mixins: [
SectionMixin,
],
props: { props: {
epics: { epics: {
type: Array, type: Array,
...@@ -22,20 +25,16 @@ ...@@ -22,20 +25,16 @@
type: Number, type: Number,
required: true, required: true,
}, },
listScrollable: {
type: Boolean,
required: true,
},
}, },
data() { data() {
return { return {
scrolledHeaderClass: '', scrolledHeaderClass: '',
}; };
}, },
computed: {
calcShellWidth() {
return this.shellWidth - SCROLL_BAR_SIZE;
},
theadStyles() {
return `width: ${this.calcShellWidth}px;`;
},
},
mounted() { mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll); eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
}, },
...@@ -43,30 +42,28 @@ ...@@ -43,30 +42,28 @@
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll); eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
}, },
methods: { methods: {
handleEpicsListScroll(scrollTop) { handleEpicsListScroll({ scrollTop }) {
// Add class only when epics list is scrolled at 1% the height of header // Add class only when epics list is scrolled at 1% the height of header
this.scrolledHeaderClass = (scrollTop > this.$el.clientHeight / 100) ? 'scrolled-ahead' : ''; this.scrolledHeaderClass = (scrollTop > this.$el.clientHeight / 100) ? 'scroll-top-shadow' : '';
}, },
}, },
}; };
</script> </script>
<template> <template>
<thead <div
class="roadmap-timeline-section" class="roadmap-timeline-section clearfix"
:class="scrolledHeaderClass" :class="scrolledHeaderClass"
:style="theadStyles" :style="sectionContainerStyles"
> >
<tr> <span class="timeline-header-blank"></span>
<th class="timeline-header-blank"></th> <timeline-header-item
<timeline-header-item v-for="(timeframeItem, index) in timeframe"
v-for="(timeframeItem, index) in timeframe" :key="index"
:key="index" :timeframe-index="index"
:timeframe-index="index" :timeframe-item="timeframeItem"
:timeframe-item="timeframeItem" :timeframe="timeframe"
:timeframe="timeframe" :item-width="sectionItemWidth"
:shell-width="calcShellWidth" />
/> </div>
</tr>
</thead>
</template> </template>
<script> <script>
import { monthInWords } from '~/lib/utils/datetime_utility'; import { monthInWords } from '~/lib/utils/datetime_utility';
import { EPIC_DETAILS_CELL_WIDTH, TIMELINE_CELL_MIN_WIDTH } from '../constants';
import timelineHeaderSubItem from './timeline_header_sub_item.vue'; import timelineHeaderSubItem from './timeline_header_sub_item.vue';
export default { export default {
...@@ -22,7 +20,7 @@ ...@@ -22,7 +20,7 @@
type: Array, type: Array,
required: true, required: true,
}, },
shellWidth: { itemWidth: {
type: Number, type: Number,
required: true, required: true,
}, },
...@@ -36,22 +34,10 @@ ...@@ -36,22 +34,10 @@
}; };
}, },
computed: { computed: {
thStyles() { itemStyles() {
const timeframeLength = this.timeframe.length; return {
width: `${this.itemWidth}px`,
// Calculate minimum width for single cell };
// based on total number of months in current timeframe
// and available shellWidth
const minWidth =
Math.ceil((this.shellWidth - EPIC_DETAILS_CELL_WIDTH) / timeframeLength);
// When shellWidth is too low, we need to obey global
// minimum cell width.
if (minWidth < TIMELINE_CELL_MIN_WIDTH) {
return `min-width: ${TIMELINE_CELL_MIN_WIDTH}px;`;
}
return `min-width: ${minWidth}px;`;
}, },
timelineHeaderLabel() { timelineHeaderLabel() {
const year = this.timeframeItem.getFullYear(); const year = this.timeframeItem.getFullYear();
...@@ -97,9 +83,9 @@ ...@@ -97,9 +83,9 @@
</script> </script>
<template> <template>
<th <span
class="timeline-header-item" class="timeline-header-item"
:style="thStyles" :style="itemStyles"
> >
<div <div
class="item-label" class="item-label"
...@@ -111,5 +97,5 @@ ...@@ -111,5 +97,5 @@
:timeframe-item="timeframeItem" :timeframe-item="timeframeItem"
:current-date="currentDate" :current-date="currentDate"
/> />
</th> </span>
</template> </template>
...@@ -38,8 +38,14 @@ ...@@ -38,8 +38,14 @@
// Get size in % from current date and days in month. // Get size in % from current date and days in month.
const left = Math.floor((this.currentDate.getDate() / daysInMonth) * 100); const left = Math.floor((this.currentDate.getDate() / daysInMonth) * 100);
// Set styles and reduce scrollbar height from total shell height. // We add 20 to height to ensure that
this.todayBarStyles = `height: ${height}px; left: ${left}%;`; // today indicator goes past the bottom
// edge of the browser even when
// scrollbar is present
this.todayBarStyles = {
height: `${height + 20}px`,
left: `${left}%`,
};
this.todayBarReady = true; this.todayBarReady = true;
}, },
}, },
......
...@@ -2,10 +2,14 @@ export const TIMEFRAME_LENGTH = 6; ...@@ -2,10 +2,14 @@ export const TIMEFRAME_LENGTH = 6;
export const EPIC_DETAILS_CELL_WIDTH = 320; export const EPIC_DETAILS_CELL_WIDTH = 320;
export const EPIC_ITEM_HEIGHT = 50;
export const TIMELINE_CELL_MIN_WIDTH = 180; export const TIMELINE_CELL_MIN_WIDTH = 180;
export const SHELL_MIN_WIDTH = 1620;
export const SCROLL_BAR_SIZE = 15; export const SCROLL_BAR_SIZE = 15;
export const TIMELINE_END_OFFSET_HALF = 17; export const TIMELINE_END_OFFSET_HALF = 6;
export const TIMELINE_END_OFFSET_FULL = 26; export const TIMELINE_END_OFFSET_FULL = 16;
import { EPIC_DETAILS_CELL_WIDTH, TIMELINE_CELL_MIN_WIDTH, SCROLL_BAR_SIZE } from '../constants';
export default {
computed: {
/**
* Return section width after reducing scrollbar size
* based on listScrollable such that Epic item cells
* do not consider scrollbar presence in shellWidth
*/
sectionShellWidth() {
return this.shellWidth - (this.listScrollable ? SCROLL_BAR_SIZE : 0);
},
sectionItemWidth() {
const timeframeLength = this.timeframe.length;
// Calculate minimum width for single cell
// based on total number of months in current timeframe
// and available shellWidth
const width = (this.sectionShellWidth - EPIC_DETAILS_CELL_WIDTH) / timeframeLength;
// When shellWidth is too low, we need to obey global
// minimum cell width.
return Math.max(width, TIMELINE_CELL_MIN_WIDTH);
},
sectionContainerStyles() {
const width = EPIC_DETAILS_CELL_WIDTH + (this.sectionItemWidth * this.timeframe.length);
return {
width: `${width}px`,
};
},
},
};
$header-item-height: 60px;
$item-height: 50px; $item-height: 50px;
$column-shadow: 15px 0 15px -15px rgba(0, 0, 0, 0.12) inset; $details-cell-width: 320px;
$border-style: 1px solid $border-gray-normal;
$scroll-top-gradient: linear-gradient(to bottom, rgba(0, 0, 0, 0.15) 0%, rgba(255, 255, 255, 0.001) 100%); $scroll-top-gradient: linear-gradient(to bottom, rgba(0, 0, 0, 0.15) 0%, rgba(255, 255, 255, 0.001) 100%);
$scroll-bottom-gradient: linear-gradient(to bottom, rgba(255, 255, 255, 0.001) 0%, rgba(0, 0, 0, 0.15) 100%); $scroll-bottom-gradient: linear-gradient(to bottom, rgba(255, 255, 255, 0.001) 0%, rgba(0, 0, 0, 0.15) 100%);
$border-style: 1px solid $border-gray-normal; $column-right-gradient: linear-gradient(to right, rgba(0, 0, 0, 0.15) 0%, rgba(255, 255, 255, 0.001) 100%);
$details-cell-width: 320px;
.group-epics-roadmap-wrapper { .group-epics-roadmap-wrapper {
padding-bottom: 0; padding-bottom: 0;
...@@ -17,96 +18,55 @@ $details-cell-width: 320px; ...@@ -17,96 +18,55 @@ $details-cell-width: 320px;
} }
} }
.roadmap-shell { .roadmap-container {
width: 100%;
overflow: hidden; overflow: hidden;
}
&, .roadmap-shell {
.roadmap-timeline-section, overflow-x: auto;
.epics-list-section {
display: block;
position: relative;
}
.epics-list-section .bottom-shadow-cell {
position: fixed;
bottom: 0;
height: 10px;
width: $details-cell-width;
pointer-events: none;
background: $scroll-bottom-gradient;
z-index: 3;
}
.epics-list-section .epic-details-cell:after,
.roadmap-timeline-section .timeline-header-blank:after,
.roadmap-timeline-section.scrolled-ahead .timeline-header-blank:before {
content: '';
position: absolute;
pointer-events: none;
}
.epics-list-section .epic-details-cell:after,
.roadmap-timeline-section .timeline-header-blank:after {
top: 0;
right: -15px;
height: 100%;
width: 14px;
box-shadow: $column-shadow;
}
.roadmap-timeline-section .timeline-header-blank:after {
top: -2px;
height: 62px;
}
.roadmap-timeline-section.scrolled-ahead .timeline-header-blank:before {
bottom: -10px;
left: 0;
height: 10px;
width: 100%;
background: $scroll-top-gradient;
}
.roadmap-timeline-section {
overflow: visible;
}
.epics-list-section {
overflow: auto;
.epics-list-item-empty {
.epic-details-cell {
border-bottom: none;
}
}
tr:not(.epics-list-item-empty):hover { &.prevent-vertical-scroll {
&, overflow-y: hidden;
.epic-details-cell {
background-color: $theme-gray-100;
}
}
} }
} }
.roadmap-timeline-section .timeline-header-blank:after,
.epics-list-section .epic-details-cell:after {
content: '';
position: absolute;
top: 0;
right: -$grid-size;
width: $grid-size;
pointer-events: none;
background: $column-right-gradient;
}
.roadmap-timeline-section { .roadmap-timeline-section {
.timeline-header-blank { position: sticky;
position: relative; position: -webkit-sticky;
display: block; top: 0;
top: 2px; z-index: 3;
height: 60px;
margin-top: -2px;
width: $details-cell-width;
background-color: $white-light;
border-right: $border-style;
z-index: 3;
}
.timeline-header-blank, .timeline-header-blank,
.timeline-header-item { .timeline-header-item {
box-sizing: border-box;
float: left;
height: $header-item-height;
border-bottom: $border-style; border-bottom: $border-style;
background-color: $white-light;
}
.timeline-header-blank {
position: sticky;
position: -webkit-sticky;
top: 0;
left: 0;
width: $details-cell-width;
z-index: 2;
&:after {
height: $header-item-height;
}
} }
.timeline-header-item { .timeline-header-item {
...@@ -143,9 +103,10 @@ $details-cell-width: 320px; ...@@ -143,9 +103,10 @@ $details-cell-width: 320px;
display: flex; display: flex;
.sublabel-value { .sublabel-value {
flex: 1; flex-grow: 1;
text-align: center; text-align: center;
font-size: $code_font_size; font-size: $code_font_size;
line-height: 1.5;
padding: 2px 0; padding: 2px 0;
} }
...@@ -155,13 +116,12 @@ $details-cell-width: 320px; ...@@ -155,13 +116,12 @@ $details-cell-width: 320px;
width: 2px; width: 2px;
background-color: $red-500; background-color: $red-500;
pointer-events: none; pointer-events: none;
z-index: 1;
} }
.today-bar:before { .today-bar:before {
content: ''; content: '';
position: absolute; position: absolute;
top: -3px; top: -2px;
left: -3px; left: -3px;
height: $grid-size; height: $grid-size;
width: $grid-size; width: $grid-size;
...@@ -174,28 +134,51 @@ $details-cell-width: 320px; ...@@ -174,28 +134,51 @@ $details-cell-width: 320px;
.epics-list-section { .epics-list-section {
.epics-list-item { .epics-list-item {
&:hover {
.epic-details-cell,
.epic-timeline-cell {
background-color: $theme-gray-100;
}
}
&.epics-list-item-empty { &.epics-list-item-empty {
&:hover {
.epic-details-cell,
.epic-timeline-cell {
background-color: $white-light;
border-bottom: none;
}
}
.epic-details-cell, .epic-details-cell,
.epic-details-cell:after,
.epic-timeline-cell { .epic-timeline-cell {
padding: 0; height: 100%;
} }
} }
.epic-details-cell, .epic-details-cell,
.epic-timeline-cell { .epic-timeline-cell {
border-right: $border-style; box-sizing: border-box;
float: left;
height: $item-height;
border-bottom: $border-style; border-bottom: $border-style;
} }
.epic-details-cell { .epic-details-cell {
position: relative; position: sticky;
display: block; position: -webkit-sticky;
left: 0;
width: $details-cell-width; width: $details-cell-width;
padding: $gl-padding-8 $gl-padding; padding: $gl-padding-8 $gl-padding;
font-size: $code_font_size; font-size: $code_font_size;
background-color: $white-light; background-color: $white-light;
z-index: 2; z-index: 2;
&:after {
height: $item-height;
}
.epic-title { .epic-title {
display: table; display: table;
table-layout: fixed; table-layout: fixed;
...@@ -225,13 +208,16 @@ $details-cell-width: 320px; ...@@ -225,13 +208,16 @@ $details-cell-width: 320px;
} }
.epic-timeline-cell { .epic-timeline-cell {
background-color: transparent;
border-right: $border-style;
.timeline-bar-wrapper { .timeline-bar-wrapper {
position: relative; position: relative;
} }
.timeline-bar { .timeline-bar {
position: absolute; position: absolute;
top: -12px; top: 12px;
height: 24px; height: 24px;
background-color: $blue-500; background-color: $blue-500;
border-radius: $border-radius-default; border-radius: $border-radius-default;
...@@ -268,8 +254,8 @@ $details-cell-width: 320px; ...@@ -268,8 +254,8 @@ $details-cell-width: 320px;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
&:before { &:before {
left: -8px; left: -$grid-size;
border-right: 8px solid $blue-500; border-right: $grid-size solid $blue-500;
} }
} }
...@@ -278,14 +264,14 @@ $details-cell-width: 320px; ...@@ -278,14 +264,14 @@ $details-cell-width: 320px;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
&:after { &:after {
right: -8px; right: -$grid-size;
border-left: 8px solid $blue-500; border-left: $grid-size solid $blue-500;
} }
} }
&.start-date-outside, &.start-date-outside,
&.start-date-undefined.end-date-outside { &.start-date-undefined.end-date-outside {
left: 8px; left: $grid-size;
} }
} }
...@@ -295,3 +281,26 @@ $details-cell-width: 320px; ...@@ -295,3 +281,26 @@ $details-cell-width: 320px;
} }
} }
} }
.roadmap-timeline-section.scroll-top-shadow .timeline-header-blank:before,
.epics-list-section .scroll-bottom-shadow {
height: $grid-size;
width: $details-cell-width;
pointer-events: none;
}
.roadmap-timeline-section.scroll-top-shadow .timeline-header-blank:before {
content: '';
position: absolute;
left: 0;
bottom: -$grid-size;
border-top: 1px solid $white-light;
background: $scroll-top-gradient;
}
.epics-list-section .scroll-bottom-shadow {
position: fixed;
bottom: 0;
background: $scroll-bottom-gradient;
z-index: 2;
}
---
title: Reimplement Roadmap timeline rendering for better performance
merge_request:
author:
type: performance
...@@ -3,13 +3,14 @@ import Vue from 'vue'; ...@@ -3,13 +3,14 @@ import Vue from 'vue';
import epicItemComponent from 'ee/roadmap/components/epic_item.vue'; import epicItemComponent from 'ee/roadmap/components/epic_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframe, mockEpic, mockGroupId, mockShellWidth } from '../mock_data'; import { mockTimeframe, mockEpic, mockGroupId, mockShellWidth, mockItemWidth } from '../mock_data';
const createComponent = ({ const createComponent = ({
epic = mockEpic, epic = mockEpic,
timeframe = mockTimeframe, timeframe = mockTimeframe,
currentGroupId = mockGroupId, currentGroupId = mockGroupId,
shellWidth = mockShellWidth, shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
}) => { }) => {
const Component = Vue.extend(epicItemComponent); const Component = Vue.extend(epicItemComponent);
...@@ -18,6 +19,7 @@ const createComponent = ({ ...@@ -18,6 +19,7 @@ const createComponent = ({
timeframe, timeframe,
currentGroupId, currentGroupId,
shellWidth, shellWidth,
itemWidth,
}); });
}; };
......
...@@ -4,13 +4,14 @@ import epicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline. ...@@ -4,13 +4,14 @@ import epicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.
import { TIMELINE_CELL_MIN_WIDTH, TIMELINE_END_OFFSET_FULL, TIMELINE_END_OFFSET_HALF } from 'ee/roadmap/constants'; import { TIMELINE_CELL_MIN_WIDTH, TIMELINE_END_OFFSET_FULL, TIMELINE_END_OFFSET_HALF } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframe, mockEpic, mockShellWidth } from '../mock_data'; import { mockTimeframe, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
const createComponent = ({ const createComponent = ({
timeframe = mockTimeframe, timeframe = mockTimeframe,
timeframeItem = mockTimeframe[0], timeframeItem = mockTimeframe[0],
epic = mockEpic, epic = mockEpic,
shellWidth = mockShellWidth, shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
}) => { }) => {
const Component = Vue.extend(epicItemTimelineComponent); const Component = Vue.extend(epicItemTimelineComponent);
...@@ -19,6 +20,7 @@ const createComponent = ({ ...@@ -19,6 +20,7 @@ const createComponent = ({
timeframeItem, timeframeItem,
epic, epic,
shellWidth, shellWidth,
itemWidth,
}); });
}; };
...@@ -38,10 +40,10 @@ describe('EpicItemTimelineComponent', () => { ...@@ -38,10 +40,10 @@ describe('EpicItemTimelineComponent', () => {
}); });
describe('computed', () => { describe('computed', () => {
describe('tdStyles', () => { describe('itemStyles', () => {
it('returns CSS min-width based on getCellWidth() method', () => { it('returns CSS min-width based on getCellWidth() method', () => {
vm = createComponent({}); vm = createComponent({});
expect(vm.tdStyles).toBe('min-width: 280px;'); expect(vm.itemStyles.width).toBe(`${mockItemWidth}px`);
}); });
}); });
}); });
...@@ -224,7 +226,7 @@ describe('EpicItemTimelineComponent', () => { ...@@ -224,7 +226,7 @@ describe('EpicItemTimelineComponent', () => {
it('renders component container element with `min-width` property applied via style attribute', () => { it('renders component container element with `min-width` property applied via style attribute', () => {
vm = createComponent({}); vm = createComponent({});
expect(vm.$el.getAttribute('style')).toBe('min-width: 280px;'); expect(vm.$el.getAttribute('style')).toBe(`width: ${mockItemWidth}px;`);
}); });
it('renders timeline bar element with class `timeline-bar` and class `timeline-bar-wrapper` as container element', () => { it('renders timeline bar element with class `timeline-bar` and class `timeline-bar-wrapper` as container element', () => {
......
...@@ -15,6 +15,7 @@ const createComponent = ({ ...@@ -15,6 +15,7 @@ const createComponent = ({
timeframe = mockTimeframe, timeframe = mockTimeframe,
currentGroupId = mockGroupId, currentGroupId = mockGroupId,
shellWidth = mockShellWidth, shellWidth = mockShellWidth,
listScrollable = false,
}) => { }) => {
const Component = Vue.extend(epicsListSectionComponent); const Component = Vue.extend(epicsListSectionComponent);
...@@ -23,6 +24,7 @@ const createComponent = ({ ...@@ -23,6 +24,7 @@ const createComponent = ({
timeframe, timeframe,
currentGroupId, currentGroupId,
shellWidth, shellWidth,
listScrollable,
}); });
}; };
...@@ -39,6 +41,8 @@ describe('EpicsListSectionComponent', () => { ...@@ -39,6 +41,8 @@ describe('EpicsListSectionComponent', () => {
expect(vm.shellHeight).toBe(0); expect(vm.shellHeight).toBe(0);
expect(vm.emptyRowHeight).toBe(0); expect(vm.emptyRowHeight).toBe(0);
expect(vm.showEmptyRow).toBe(false); expect(vm.showEmptyRow).toBe(false);
expect(vm.offsetLeft).toBe(0);
expect(vm.showBottomShadow).toBe(false);
}); });
}); });
...@@ -47,24 +51,21 @@ describe('EpicsListSectionComponent', () => { ...@@ -47,24 +51,21 @@ describe('EpicsListSectionComponent', () => {
vm = createComponent({}); vm = createComponent({});
}); });
describe('calcShellWidth', () => { describe('emptyRowContainerStyles', () => {
it('returns shellWidth after deducting predefined scrollbar size', () => { it('returns computed style object based on emptyRowHeight prop value', () => {
// shellWidth is 2000 (as defined above in mockShellWidth) expect(vm.emptyRowContainerStyles.height).toBe('0px');
// SCROLLBAR_SIZE is 15 (as defined in app's constants.js)
// Hence, calcShellWidth = shellWidth - SCROLLBAR_SIZE
expect(vm.calcShellWidth).toBe(1985);
}); });
}); });
describe('tbodyStyles', () => { describe('emptyRowCellStyles', () => {
it('returns computed style string based on shellWidth and shellHeight', () => { it('returns computed style object based on sectionItemWidth prop value', () => {
expect(vm.tbodyStyles).toBe('width: 2015px; height: 0px;'); expect(vm.emptyRowCellStyles.width).toBe('280px');
}); });
}); });
describe('emptyRowCellStyles', () => { describe('shadowCellStyles', () => {
it('returns computed style string based on emptyRowHeight', () => { it('returns computed style object based on `offsetLeft` prop value', () => {
expect(vm.emptyRowCellStyles).toBe('height: 0px;'); expect(vm.shadowCellStyles.left).toBe('0px');
}); });
}); });
}); });
...@@ -104,7 +105,7 @@ describe('EpicsListSectionComponent', () => { ...@@ -104,7 +105,7 @@ describe('EpicsListSectionComponent', () => {
describe('initEmptyRow', () => { describe('initEmptyRow', () => {
it('sets `emptyRowHeight` and `showEmptyRow` props when shellHeight is greater than approximate height of epics list', (done) => { it('sets `emptyRowHeight` and `showEmptyRow` props when shellHeight is greater than approximate height of epics list', (done) => {
vm.$nextTick(() => { vm.$nextTick(() => {
expect(vm.emptyRowHeight).toBe(599); // total size -1px expect(vm.emptyRowHeight).toBe(600);
expect(vm.showEmptyRow).toBe(true); expect(vm.showEmptyRow).toBe(true);
done(); done();
}); });
...@@ -125,14 +126,6 @@ describe('EpicsListSectionComponent', () => { ...@@ -125,14 +126,6 @@ describe('EpicsListSectionComponent', () => {
}); });
}); });
describe('handleScroll', () => {
it('emits `epicsListScrolled` event via eventHub', () => {
spyOn(eventHub, '$emit');
vm.handleScroll();
expect(eventHub.$emit).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Number), jasmine.any(Number));
});
});
describe('scrollToTodayIndicator', () => { describe('scrollToTodayIndicator', () => {
it('scrolls table body to put timeline today indicator in focus', () => { it('scrolls table body to put timeline today indicator in focus', () => {
spyOn(vm.$el, 'scrollTo'); spyOn(vm.$el, 'scrollTo');
...@@ -154,9 +147,17 @@ describe('EpicsListSectionComponent', () => { ...@@ -154,9 +147,17 @@ describe('EpicsListSectionComponent', () => {
}); });
}); });
it('renders component container element with `width` and `left` properties applied via style attribute', (done) => { it('renders component container element with `width` property applied via style attribute', (done) => {
vm.$nextTick(() => {
expect(vm.$el.getAttribute('style')).toBe(`width: ${mockShellWidth}px;`);
done();
});
});
it('renders bottom shadow element when `showBottomShadow` prop is true', (done) => {
vm.showBottomShadow = true;
vm.$nextTick(() => { vm.$nextTick(() => {
expect(vm.$el.getAttribute('style')).toBe('width: 2015px; height: 0px;'); expect(vm.$el.querySelector('.scroll-bottom-shadow')).not.toBe(null);
done(); done();
}); });
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import roadmapShellComponent from 'ee/roadmap/components/roadmap_shell.vue'; import roadmapShellComponent from 'ee/roadmap/components/roadmap_shell.vue';
import eventHub from 'ee/roadmap/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockEpic, mockTimeframe, mockGroupId } from '../mock_data'; import { mockEpic, mockTimeframe, mockGroupId, mockScrollBarSize } from '../mock_data';
const createComponent = ({ const createComponent = ({
epics = [mockEpic], epics = [mockEpic],
timeframe = mockTimeframe, timeframe = mockTimeframe,
currentGroupId = mockGroupId, currentGroupId = mockGroupId,
}) => { }, el) => {
const Component = Vue.extend(roadmapShellComponent); const Component = Vue.extend(roadmapShellComponent);
return mountComponent(Component, { return mountComponent(Component, {
epics, epics,
timeframe, timeframe,
currentGroupId, currentGroupId,
}); }, el);
}; };
describe('RoadmapShellComponent', () => { describe('RoadmapShellComponent', () => {
...@@ -33,22 +34,79 @@ describe('RoadmapShellComponent', () => { ...@@ -33,22 +34,79 @@ describe('RoadmapShellComponent', () => {
describe('data', () => { describe('data', () => {
it('returns default data props', () => { it('returns default data props', () => {
expect(vm.shellWidth).toBe(0); expect(vm.shellWidth).toBe(0);
expect(vm.shellHeight).toBe(0);
expect(vm.noScroll).toBe(false);
}); });
}); });
describe('tableStyles', () => { describe('computed', () => {
it('returns style string based on shellWidth and Scollbar size', () => { describe('containerStyles', () => {
// Since shellWidth is initialized on component mount beforeEach(() => {
// from parentElement.clientWidth, it will always be Zero document.body.innerHTML += '<div class="roadmap-container"><div id="roadmap-shell"></div></div>';
// as parentElement is not available during tests. });
// so end result is 0 - scrollbar_size = -15
expect(vm.tableStyles).toBe('width: -15px;'); afterEach(() => {
document.querySelector('.roadmap-container').remove();
});
it('returns style object based on shellWidth and current Window width with Scollbar size offset', (done) => {
const vmWithParentEl = createComponent({}, document.getElementById('roadmap-shell'));
Vue.nextTick(() => {
const stylesObj = vmWithParentEl.containerStyles;
// Ensure that value for `width` & `height`
// is a non-zero number.
expect(parseInt(stylesObj.width, 10) !== 0).toBe(true);
expect(parseInt(stylesObj.height, 10) !== 0).toBe(true);
vmWithParentEl.$destroy();
done();
});
});
});
});
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(() => {
spyOn(eventHub, '$emit');
});
it('emits `epicsListScrolled` event via eventHub when `noScroll` prop is false', () => {
vm.noScroll = false;
vm.handleScroll();
expect(eventHub.$emit).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Object));
});
it('does not emit any event via eventHub when `noScroll` prop is true', () => {
vm.noScroll = true;
vm.handleScroll();
expect(eventHub.$emit).not.toHaveBeenCalled();
});
}); });
}); });
describe('template', () => { describe('template', () => {
it('renders component container element with class `roadmap-shell`', () => { it('renders component container element with class `roadmap-shell`', () => {
expect(vm.$el.classList.contains('roadmap-shell')).toBeTruthy(); expect(vm.$el.classList.contains('roadmap-shell')).toBe(true);
});
it('adds `prevent-vertical-scroll` class on component container element', (done) => {
vm.noScroll = true;
Vue.nextTick(() => {
expect(vm.$el.classList.contains('prevent-vertical-scroll')).toBe(true);
done();
});
}); });
}); });
}); });
...@@ -10,6 +10,7 @@ const createComponent = ({ ...@@ -10,6 +10,7 @@ const createComponent = ({
epics = [mockEpic], epics = [mockEpic],
timeframe = mockTimeframe, timeframe = mockTimeframe,
shellWidth = mockShellWidth, shellWidth = mockShellWidth,
listScrollable = false,
}) => { }) => {
const Component = Vue.extend(roadmapTimelineSectionComponent); const Component = Vue.extend(roadmapTimelineSectionComponent);
...@@ -17,6 +18,7 @@ const createComponent = ({ ...@@ -17,6 +18,7 @@ const createComponent = ({
epics, epics,
timeframe, timeframe,
shellWidth, shellWidth,
listScrollable,
}); });
}; };
...@@ -37,33 +39,16 @@ describe('RoadmapTimelineSectionComponent', () => { ...@@ -37,33 +39,16 @@ describe('RoadmapTimelineSectionComponent', () => {
}); });
}); });
describe('computed', () => {
describe('calcShellWidth', () => {
it('returns shellWidth by deducting Scrollbar size', () => {
// shellWidth is 2000 (as defined above in mockShellWidth)
// SCROLLBAR_SIZE is 15 (as defined in app's constants.js)
// Hence, calcShellWidth = shellWidth - SCROLLBAR_SIZE
expect(vm.calcShellWidth).toBe(1985);
});
});
describe('theadStyles', () => {
it('returns style string for thead based on calcShellWidth', () => {
expect(vm.theadStyles).toBe('width: 1985px;');
});
});
});
describe('methods', () => { describe('methods', () => {
describe('handleEpicsListScroll', () => { describe('handleEpicsListScroll', () => {
it('sets `scrolled-ahead` class on thead element based on provided scrollTop value', () => { it('sets `scrolled-ahead` class on thead element based on provided scrollTop value', () => {
// vm.$el.clientHeight is 0 during tests // vm.$el.clientHeight is 0 during tests
// hence any value greater than 0 should // hence any value greater than 0 should
// update scrolledHeaderClass prop // update scrolledHeaderClass prop
vm.handleEpicsListScroll(1); vm.handleEpicsListScroll({ scrollTop: 1 });
expect(vm.scrolledHeaderClass).toBe('scrolled-ahead'); expect(vm.scrolledHeaderClass).toBe('scroll-top-shadow');
vm.handleEpicsListScroll(0); vm.handleEpicsListScroll({ scrollTop: 0 });
expect(vm.scrolledHeaderClass).toBe(''); expect(vm.scrolledHeaderClass).toBe('');
}); });
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import timelineHeaderItemComponent from 'ee/roadmap/components/timeline_header_item.vue'; import timelineHeaderItemComponent from 'ee/roadmap/components/timeline_header_item.vue';
import { TIMELINE_CELL_MIN_WIDTH } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframe, mockShellWidth } from '../mock_data'; import { mockTimeframe, mockShellWidth, mockItemWidth } from '../mock_data';
const mockTimeframeIndex = 0; const mockTimeframeIndex = 0;
...@@ -13,6 +12,7 @@ const createComponent = ({ ...@@ -13,6 +12,7 @@ const createComponent = ({
timeframeItem = mockTimeframe[mockTimeframeIndex], timeframeItem = mockTimeframe[mockTimeframeIndex],
timeframe = mockTimeframe, timeframe = mockTimeframe,
shellWidth = mockShellWidth, shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
}) => { }) => {
const Component = Vue.extend(timelineHeaderItemComponent); const Component = Vue.extend(timelineHeaderItemComponent);
...@@ -21,6 +21,7 @@ const createComponent = ({ ...@@ -21,6 +21,7 @@ const createComponent = ({
timeframeItem, timeframeItem,
timeframe, timeframe,
shellWidth, shellWidth,
itemWidth,
}); });
}; };
...@@ -42,15 +43,10 @@ describe('TimelineHeaderItemComponent', () => { ...@@ -42,15 +43,10 @@ describe('TimelineHeaderItemComponent', () => {
}); });
describe('computed', () => { describe('computed', () => {
describe('thStyles', () => { describe('itemStyles', () => {
it('returns style string for th element based on shellWidth, timeframe length and Epic details cell width', () => { it('returns style object for container element based on value of `itemWidth` prop', () => {
vm = createComponent({}); vm = createComponent({});
expect(vm.thStyles).toBe('min-width: 280px;'); expect(vm.itemStyles.width).toBe('180px');
});
it('returns style string for th element with minimum permissible width when calculated width is lower defined minimum width', () => {
vm = createComponent({ shellWidth: 1000 });
expect(vm.thStyles).toBe(`min-width: ${TIMELINE_CELL_MIN_WIDTH}px;`);
}); });
}); });
......
...@@ -46,7 +46,9 @@ describe('TimelineTodayIndicatorComponent', () => { ...@@ -46,7 +46,9 @@ describe('TimelineTodayIndicatorComponent', () => {
vm.handleEpicsListRender({ vm.handleEpicsListRender({
height: 100, height: 100,
}); });
expect(vm.todayBarStyles).toBe('height: 100px; left: 50%;'); // Current date being 15th const stylesObj = vm.todayBarStyles;
expect(stylesObj.height).toBe('120px');
expect(stylesObj.left).toBe('50%');
expect(vm.todayBarReady).toBe(true); expect(vm.todayBarReady).toBe(true);
}); });
}); });
......
import Vue from 'vue';
import roadmapTimelineSectionComponent from 'ee/roadmap/components/roadmap_timeline_section.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockEpic, mockTimeframe, mockShellWidth, mockScrollBarSize } from '../mock_data';
const createComponent = ({
epics = [mockEpic],
timeframe = mockTimeframe,
shellWidth = mockShellWidth,
listScrollable = false,
}) => {
const Component = Vue.extend(roadmapTimelineSectionComponent);
return mountComponent(Component, {
epics,
timeframe,
shellWidth,
listScrollable,
});
};
describe('SectionMixin', () => {
let vm;
beforeEach(() => {
vm = createComponent({});
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('sectionShellWidth', () => {
it('returns shellWidth as it is when `listScrollable` prop is false', () => {
expect(vm.sectionShellWidth).toBe(mockShellWidth);
});
it('returns shellWidth after deducating value of SCROLL_BAR_SIZE when `listScrollable` prop is true', () => {
const vmScrollable = createComponent({ listScrollable: true });
expect(vmScrollable.sectionShellWidth).toBe(mockShellWidth - mockScrollBarSize);
vmScrollable.$destroy();
});
});
describe('sectionItemWidth', () => {
it('returns calculated item width based on sectionShellWidth and timeframe size', () => {
expect(vm.sectionItemWidth).toBe(280);
});
});
describe('sectionContainerStyles', () => {
it('returns style string for container element based on sectionShellWidth', () => {
expect(vm.sectionContainerStyles.width).toBe(`${mockShellWidth}px`);
});
});
});
});
import { getTimeframeWindow } from '~/lib/utils/datetime_utility'; import { getTimeframeWindow } from '~/lib/utils/datetime_utility';
import { TIMEFRAME_LENGTH } from 'ee/roadmap/constants'; import { TIMEFRAME_LENGTH } from 'ee/roadmap/constants';
export const mockScrollBarSize = 15;
export const mockGroupId = 2; export const mockGroupId = 2;
export const mockShellWidth = 2000; export const mockShellWidth = 2000;
export const mockItemWidth = 180;
export const epicsPath = '/groups/gitlab-org/-/epics.json?start_date=2017-11-1&end_date=2018-4-30'; export const epicsPath = '/groups/gitlab-org/-/epics.json?start_date=2017-11-1&end_date=2018-4-30';
export const mockSvgPath = '/foo/bar.svg'; export const mockSvgPath = '/foo/bar.svg';
......
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