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 @@
type: Number,
required: true,
},
itemWidth: {
type: Number,
required: true,
},
},
};
</script>
<template>
<tr class="epics-list-item">
<div class="epics-list-item clearfix">
<epic-item-details
:epic="epic"
:current-group-id="currentGroupId"
......@@ -41,6 +45,7 @@
:timeframe-item="timeframeItem"
:epic="epic"
:shell-width="shellWidth"
:item-width="itemWidth"
/>
</tr>
</div>
</template>
......@@ -72,7 +72,7 @@
</script>
<template>
<td class="epic-details-cell">
<span class="epic-details-cell">
<div class="epic-title">
<a
v-tooltip
......@@ -101,5 +101,5 @@
>
</span>
</div>
</td>
</span>
</template>
......@@ -30,6 +30,10 @@
type: Number,
required: true,
},
itemWidth: {
type: Number,
required: true,
},
},
data() {
return {
......@@ -38,8 +42,10 @@
};
},
computed: {
tdStyles() {
return `min-width: ${this.getCellWidth()}px;`;
itemStyles() {
return {
width: `${this.itemWidth}px`,
};
},
showTimelineBar() {
return this.hasStartDate();
......@@ -53,15 +59,14 @@
},
methods: {
/**
* Gets cell width based on total number cells for
* current timeframe and shellWidth.
* Gets cell width based on total number months for
* current timeframe and shellWidth excluding details cell width.
*
* In case cell width is too narrow, we have fixed minimum
* cell width (TIMELINE_CELL_MIN_WIDTH) to obey.
*/
getCellWidth() {
const minWidth =
Math.ceil((this.shellWidth - EPIC_DETAILS_CELL_WIDTH) / this.timeframe.length);
const minWidth = (this.shellWidth - EPIC_DETAILS_CELL_WIDTH) / this.timeframe.length;
return Math.max(minWidth, TIMELINE_CELL_MIN_WIDTH);
},
......@@ -229,7 +234,7 @@
}
// Reduce any offset from total width and round it off.
return Math.round(timelineBarWidth - offsetEnd);
return timelineBarWidth - offsetEnd;
},
/**
* Renders timeline bar only if current
......@@ -246,9 +251,9 @@
</script>
<template>
<td
<span
class="epic-timeline-cell"
:style="tdStyles"
:style="itemStyles"
>
<div class="timeline-bar-wrapper">
<a
......@@ -265,5 +270,5 @@
>
</a>
</div>
</td>
</span>
</template>
<script>
import $ from 'jquery';
import eventHub from '../event_hub';
import { SCROLL_BAR_SIZE } from '../constants';
import SectionMixin from '../mixins/section_mixin';
import epicItem from './epic_item.vue';
......@@ -10,6 +9,9 @@
components: {
epicItem,
},
mixins: [
SectionMixin,
],
props: {
epics: {
type: Array,
......@@ -27,6 +29,10 @@
type: Number,
required: true,
},
listScrollable: {
type: Boolean,
required: true,
},
},
data() {
return {
......@@ -38,26 +44,20 @@
};
},
computed: {
/**
* Return width after reducing scrollbar size
* such that Epic item cells do not consider
* 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;`;
emptyRowContainerStyles() {
return {
height: `${this.emptyRowHeight}px`,
};
},
emptyRowCellStyles() {
return `height: ${this.emptyRowHeight}px;`;
return {
width: `${this.sectionItemWidth}px`,
};
},
shadowCellStyles() {
return `left: ${this.offsetLeft}px;`;
return {
left: `${this.offsetLeft}px`,
};
},
},
watch: {
......@@ -69,14 +69,18 @@
},
},
mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
this.$nextTick(() => {
this.initMounted();
});
},
beforeDestroy() {
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
},
methods: {
initMounted() {
// 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
if (this.epics.length) {
......@@ -113,32 +117,12 @@
});
// set height and show empty row reducing horizontal scrollbar size
this.emptyRowHeight = (this.shellHeight - approxChildrenHeight) - 1;
this.emptyRowHeight = (this.shellHeight - approxChildrenHeight);
this.showEmptyRow = true;
} else {
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
* scroll up to 60% of the view where today indicator is present.
......@@ -149,45 +133,45 @@
const uptoTodayIndicator = Math.ceil((this.$el.clientWidth * 60) / 100);
this.$el.scrollTo(uptoTodayIndicator, 0);
},
handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) {
this.showBottomShadow = (Math.ceil(scrollTop) + clientHeight) < scrollHeight;
},
},
};
</script>
<template>
<tbody
<div
class="epics-list-section"
:style="tbodyStyles"
@scroll="handleScroll"
:style="sectionContainerStyles"
>
<tr
v-if="showBottomShadow"
class="bottom-shadow-cell"
:style="shadowCellStyles"
></tr>
<epic-item
v-for="(epic, index) in epics"
:key="index"
:epic="epic"
:timeframe="timeframe"
:current-group-id="currentGroupId"
:shell-width="calcShellWidth"
:shell-width="sectionShellWidth"
:item-width="sectionItemWidth"
/>
<tr
<div
v-if="showEmptyRow"
class="epics-list-item epics-list-item-empty"
>
<td
class="epic-details-cell"
:style="emptyRowCellStyles"
class="epics-list-item epics-list-item-empty clearfix"
:style="emptyRowContainerStyles"
>
</td>
<td
class="epic-timeline-cell"
<span class="epic-details-cell"></span>
<span
v-for="(timeframeItem, index) in timeframe"
:key="index"
class="epic-timeline-cell"
:style="emptyRowCellStyles"
>
</td>
</tr>
</tbody>
</span>
</div>
<div
v-if="showBottomShadow"
class="scroll-bottom-shadow"
:style="shadowCellStyles"
></div>
</div>
</template>
<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 roadmapTimelineSection from './roadmap_timeline_section.vue';
......@@ -26,13 +28,20 @@
data() {
return {
shellWidth: 0,
shellHeight: 0,
noScroll: false,
};
},
computed: {
tableStyles() {
// return width after deducting size of vertical scrollbar
// to hide the scrollbar while preserving ability to scroll
return `width: ${this.shellWidth - SCROLL_BAR_SIZE}px;`;
containerStyles() {
const width = bp.windowWidth() > SHELL_MIN_WIDTH ?
this.shellWidth + this.getWidthOffset() :
this.shellWidth;
return {
width: `${width}px`,
height: `${this.shellHeight}px`,
};
},
},
mounted() {
......@@ -44,28 +53,45 @@
// before setting shellWidth
// see https://vuejs.org/v2/api/#Vue-nextTick
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>
<template>
<table
<div
class="roadmap-shell"
:style="tableStyles"
:class="{ 'prevent-vertical-scroll': noScroll }"
:style="containerStyles"
@scroll="handleScroll"
>
<roadmap-timeline-section
:epics="epics"
:timeframe="timeframe"
:shell-width="shellWidth"
:list-scrollable="!noScroll"
/>
<epics-list-section
:epics="epics"
:timeframe="timeframe"
:shell-width="shellWidth"
:current-group-id="currentGroupId"
:list-scrollable="!noScroll"
/>
</table>
</div>
</template>
<script>
import eventHub from '../event_hub';
import { SCROLL_BAR_SIZE } from '../constants';
import SectionMixin from '../mixins/section_mixin';
import timelineHeaderItem from './timeline_header_item.vue';
......@@ -9,6 +9,9 @@
components: {
timelineHeaderItem,
},
mixins: [
SectionMixin,
],
props: {
epics: {
type: Array,
......@@ -22,20 +25,16 @@
type: Number,
required: true,
},
listScrollable: {
type: Boolean,
required: true,
},
},
data() {
return {
scrolledHeaderClass: '',
};
},
computed: {
calcShellWidth() {
return this.shellWidth - SCROLL_BAR_SIZE;
},
theadStyles() {
return `width: ${this.calcShellWidth}px;`;
},
},
mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
},
......@@ -43,30 +42,28 @@
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
},
methods: {
handleEpicsListScroll(scrollTop) {
handleEpicsListScroll({ scrollTop }) {
// 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>
<template>
<thead
class="roadmap-timeline-section"
<div
class="roadmap-timeline-section clearfix"
:class="scrolledHeaderClass"
:style="theadStyles"
:style="sectionContainerStyles"
>
<tr>
<th class="timeline-header-blank"></th>
<span class="timeline-header-blank"></span>
<timeline-header-item
v-for="(timeframeItem, index) in timeframe"
:key="index"
:timeframe-index="index"
:timeframe-item="timeframeItem"
:timeframe="timeframe"
:shell-width="calcShellWidth"
:item-width="sectionItemWidth"
/>
</tr>
</thead>
</div>
</template>
<script>
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';
export default {
......@@ -22,7 +20,7 @@
type: Array,
required: true,
},
shellWidth: {
itemWidth: {
type: Number,
required: true,
},
......@@ -36,22 +34,10 @@
};
},
computed: {
thStyles() {
const timeframeLength = this.timeframe.length;
// 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;`;
itemStyles() {
return {
width: `${this.itemWidth}px`,
};
},
timelineHeaderLabel() {
const year = this.timeframeItem.getFullYear();
......@@ -97,9 +83,9 @@
</script>
<template>
<th
<span
class="timeline-header-item"
:style="thStyles"
:style="itemStyles"
>
<div
class="item-label"
......@@ -111,5 +97,5 @@
:timeframe-item="timeframeItem"
:current-date="currentDate"
/>
</th>
</span>
</template>
......@@ -38,8 +38,14 @@
// Get size in % from current date and days in month.
const left = Math.floor((this.currentDate.getDate() / daysInMonth) * 100);
// Set styles and reduce scrollbar height from total shell height.
this.todayBarStyles = `height: ${height}px; left: ${left}%;`;
// 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`,
left: `${left}%`,
};
this.todayBarReady = true;
},
},
......
......@@ -2,10 +2,14 @@ export const TIMEFRAME_LENGTH = 6;
export const EPIC_DETAILS_CELL_WIDTH = 320;
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 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;
$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-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;
$details-cell-width: 320px;
$column-right-gradient: linear-gradient(to right, rgba(0, 0, 0, 0.15) 0%, rgba(255, 255, 255, 0.001) 100%);
.group-epics-roadmap-wrapper {
padding-bottom: 0;
......@@ -17,96 +18,55 @@ $details-cell-width: 320px;
}
}
.roadmap-shell {
width: 100%;
.roadmap-container {
overflow: hidden;
}
&,
.roadmap-timeline-section,
.epics-list-section {
display: block;
position: relative;
}
.roadmap-shell {
overflow-x: auto;
.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;
&.prevent-vertical-scroll {
overflow-y: hidden;
}
}
.epics-list-section .epic-details-cell:after,
.roadmap-timeline-section .timeline-header-blank:after,
.roadmap-timeline-section.scrolled-ahead .timeline-header-blank:before {
.roadmap-timeline-section .timeline-header-blank:after,
.epics-list-section .epic-details-cell:after {
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 {
&,
.epic-details-cell {
background-color: $theme-gray-100;
}
}
}
right: -$grid-size;
width: $grid-size;
pointer-events: none;
background: $column-right-gradient;
}
.roadmap-timeline-section {
.timeline-header-blank {
position: relative;
display: block;
top: 2px;
height: 60px;
margin-top: -2px;
width: $details-cell-width;
background-color: $white-light;
border-right: $border-style;
position: sticky;
position: -webkit-sticky;
top: 0;
z-index: 3;
}
.timeline-header-blank,
.timeline-header-item {
box-sizing: border-box;
float: left;
height: $header-item-height;
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 {
......@@ -143,9 +103,10 @@ $details-cell-width: 320px;
display: flex;
.sublabel-value {
flex: 1;
flex-grow: 1;
text-align: center;
font-size: $code_font_size;
line-height: 1.5;
padding: 2px 0;
}
......@@ -155,13 +116,12 @@ $details-cell-width: 320px;
width: 2px;
background-color: $red-500;
pointer-events: none;
z-index: 1;
}
.today-bar:before {
content: '';
position: absolute;
top: -3px;
top: -2px;
left: -3px;
height: $grid-size;
width: $grid-size;
......@@ -174,28 +134,51 @@ $details-cell-width: 320px;
.epics-list-section {
.epics-list-item {
&:hover {
.epic-details-cell,
.epic-timeline-cell {
background-color: $theme-gray-100;
}
}
&.epics-list-item-empty {
&:hover {
.epic-details-cell,
.epic-timeline-cell {
padding: 0;
background-color: $white-light;
border-bottom: none;
}
}
.epic-details-cell,
.epic-details-cell:after,
.epic-timeline-cell {
border-right: $border-style;
height: 100%;
}
}
.epic-details-cell,
.epic-timeline-cell {
box-sizing: border-box;
float: left;
height: $item-height;
border-bottom: $border-style;
}
.epic-details-cell {
position: relative;
display: block;
position: sticky;
position: -webkit-sticky;
left: 0;
width: $details-cell-width;
padding: $gl-padding-8 $gl-padding;
font-size: $code_font_size;
background-color: $white-light;
z-index: 2;
&:after {
height: $item-height;
}
.epic-title {
display: table;
table-layout: fixed;
......@@ -225,13 +208,16 @@ $details-cell-width: 320px;
}
.epic-timeline-cell {
background-color: transparent;
border-right: $border-style;
.timeline-bar-wrapper {
position: relative;
}
.timeline-bar {
position: absolute;
top: -12px;
top: 12px;
height: 24px;
background-color: $blue-500;
border-radius: $border-radius-default;
......@@ -268,8 +254,8 @@ $details-cell-width: 320px;
border-bottom-left-radius: 0;
&:before {
left: -8px;
border-right: 8px solid $blue-500;
left: -$grid-size;
border-right: $grid-size solid $blue-500;
}
}
......@@ -278,14 +264,14 @@ $details-cell-width: 320px;
border-bottom-right-radius: 0;
&:after {
right: -8px;
border-left: 8px solid $blue-500;
right: -$grid-size;
border-left: $grid-size solid $blue-500;
}
}
&.start-date-outside,
&.start-date-undefined.end-date-outside {
left: 8px;
left: $grid-size;
}
}
......@@ -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';
import epicItemComponent from 'ee/roadmap/components/epic_item.vue';
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 = ({
epic = mockEpic,
timeframe = mockTimeframe,
currentGroupId = mockGroupId,
shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
}) => {
const Component = Vue.extend(epicItemComponent);
......@@ -18,6 +19,7 @@ const createComponent = ({
timeframe,
currentGroupId,
shellWidth,
itemWidth,
});
};
......
......@@ -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 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 = ({
timeframe = mockTimeframe,
timeframeItem = mockTimeframe[0],
epic = mockEpic,
shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
}) => {
const Component = Vue.extend(epicItemTimelineComponent);
......@@ -19,6 +20,7 @@ const createComponent = ({
timeframeItem,
epic,
shellWidth,
itemWidth,
});
};
......@@ -38,10 +40,10 @@ describe('EpicItemTimelineComponent', () => {
});
describe('computed', () => {
describe('tdStyles', () => {
describe('itemStyles', () => {
it('returns CSS min-width based on getCellWidth() method', () => {
vm = createComponent({});
expect(vm.tdStyles).toBe('min-width: 280px;');
expect(vm.itemStyles.width).toBe(`${mockItemWidth}px`);
});
});
});
......@@ -224,7 +226,7 @@ describe('EpicItemTimelineComponent', () => {
it('renders component container element with `min-width` property applied via style attribute', () => {
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', () => {
......
......@@ -15,6 +15,7 @@ const createComponent = ({
timeframe = mockTimeframe,
currentGroupId = mockGroupId,
shellWidth = mockShellWidth,
listScrollable = false,
}) => {
const Component = Vue.extend(epicsListSectionComponent);
......@@ -23,6 +24,7 @@ const createComponent = ({
timeframe,
currentGroupId,
shellWidth,
listScrollable,
});
};
......@@ -39,6 +41,8 @@ describe('EpicsListSectionComponent', () => {
expect(vm.shellHeight).toBe(0);
expect(vm.emptyRowHeight).toBe(0);
expect(vm.showEmptyRow).toBe(false);
expect(vm.offsetLeft).toBe(0);
expect(vm.showBottomShadow).toBe(false);
});
});
......@@ -47,24 +51,21 @@ describe('EpicsListSectionComponent', () => {
vm = createComponent({});
});
describe('calcShellWidth', () => {
it('returns shellWidth after deducting predefined 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('emptyRowContainerStyles', () => {
it('returns computed style object based on emptyRowHeight prop value', () => {
expect(vm.emptyRowContainerStyles.height).toBe('0px');
});
});
describe('tbodyStyles', () => {
it('returns computed style string based on shellWidth and shellHeight', () => {
expect(vm.tbodyStyles).toBe('width: 2015px; height: 0px;');
describe('emptyRowCellStyles', () => {
it('returns computed style object based on sectionItemWidth prop value', () => {
expect(vm.emptyRowCellStyles.width).toBe('280px');
});
});
describe('emptyRowCellStyles', () => {
it('returns computed style string based on emptyRowHeight', () => {
expect(vm.emptyRowCellStyles).toBe('height: 0px;');
describe('shadowCellStyles', () => {
it('returns computed style object based on `offsetLeft` prop value', () => {
expect(vm.shadowCellStyles.left).toBe('0px');
});
});
});
......@@ -104,7 +105,7 @@ describe('EpicsListSectionComponent', () => {
describe('initEmptyRow', () => {
it('sets `emptyRowHeight` and `showEmptyRow` props when shellHeight is greater than approximate height of epics list', (done) => {
vm.$nextTick(() => {
expect(vm.emptyRowHeight).toBe(599); // total size -1px
expect(vm.emptyRowHeight).toBe(600);
expect(vm.showEmptyRow).toBe(true);
done();
});
......@@ -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', () => {
it('scrolls table body to put timeline today indicator in focus', () => {
spyOn(vm.$el, 'scrollTo');
......@@ -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(() => {
expect(vm.$el.getAttribute('style')).toBe('width: 2015px; height: 0px;');
expect(vm.$el.querySelector('.scroll-bottom-shadow')).not.toBe(null);
done();
});
});
......
import Vue from '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 { mockEpic, mockTimeframe, mockGroupId } from '../mock_data';
import { mockEpic, mockTimeframe, mockGroupId, mockScrollBarSize } from '../mock_data';
const createComponent = ({
epics = [mockEpic],
timeframe = mockTimeframe,
currentGroupId = mockGroupId,
}) => {
}, el) => {
const Component = Vue.extend(roadmapShellComponent);
return mountComponent(Component, {
epics,
timeframe,
currentGroupId,
});
}, el);
};
describe('RoadmapShellComponent', () => {
......@@ -33,22 +34,79 @@ describe('RoadmapShellComponent', () => {
describe('data', () => {
it('returns default data props', () => {
expect(vm.shellWidth).toBe(0);
expect(vm.shellHeight).toBe(0);
expect(vm.noScroll).toBe(false);
});
});
describe('computed', () => {
describe('containerStyles', () => {
beforeEach(() => {
document.body.innerHTML += '<div class="roadmap-container"><div id="roadmap-shell"></div></div>';
});
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);
});
describe('tableStyles', () => {
it('returns style string based on shellWidth and Scollbar size', () => {
// Since shellWidth is initialized on component mount
// from parentElement.clientWidth, it will always be Zero
// as parentElement is not available during tests.
// so end result is 0 - scrollbar_size = -15
expect(vm.tableStyles).toBe('width: -15px;');
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', () => {
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 = ({
epics = [mockEpic],
timeframe = mockTimeframe,
shellWidth = mockShellWidth,
listScrollable = false,
}) => {
const Component = Vue.extend(roadmapTimelineSectionComponent);
......@@ -17,6 +18,7 @@ const createComponent = ({
epics,
timeframe,
shellWidth,
listScrollable,
});
};
......@@ -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('handleEpicsListScroll', () => {
it('sets `scrolled-ahead` class on thead element based on provided scrollTop value', () => {
// vm.$el.clientHeight is 0 during tests
// hence any value greater than 0 should
// update scrolledHeaderClass prop
vm.handleEpicsListScroll(1);
expect(vm.scrolledHeaderClass).toBe('scrolled-ahead');
vm.handleEpicsListScroll({ scrollTop: 1 });
expect(vm.scrolledHeaderClass).toBe('scroll-top-shadow');
vm.handleEpicsListScroll(0);
vm.handleEpicsListScroll({ scrollTop: 0 });
expect(vm.scrolledHeaderClass).toBe('');
});
});
......
import Vue from '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 { mockTimeframe, mockShellWidth } from '../mock_data';
import { mockTimeframe, mockShellWidth, mockItemWidth } from '../mock_data';
const mockTimeframeIndex = 0;
......@@ -13,6 +12,7 @@ const createComponent = ({
timeframeItem = mockTimeframe[mockTimeframeIndex],
timeframe = mockTimeframe,
shellWidth = mockShellWidth,
itemWidth = mockItemWidth,
}) => {
const Component = Vue.extend(timelineHeaderItemComponent);
......@@ -21,6 +21,7 @@ const createComponent = ({
timeframeItem,
timeframe,
shellWidth,
itemWidth,
});
};
......@@ -42,15 +43,10 @@ describe('TimelineHeaderItemComponent', () => {
});
describe('computed', () => {
describe('thStyles', () => {
it('returns style string for th element based on shellWidth, timeframe length and Epic details cell width', () => {
describe('itemStyles', () => {
it('returns style object for container element based on value of `itemWidth` prop', () => {
vm = createComponent({});
expect(vm.thStyles).toBe('min-width: 280px;');
});
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;`);
expect(vm.itemStyles.width).toBe('180px');
});
});
......
......@@ -46,7 +46,9 @@ describe('TimelineTodayIndicatorComponent', () => {
vm.handleEpicsListRender({
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);
});
});
......
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 { TIMEFRAME_LENGTH } from 'ee/roadmap/constants';
export const mockScrollBarSize = 15;
export const mockGroupId = 2;
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 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