Commit 9d46d31e authored by Kushal Pandya's avatar Kushal Pandya

Roadmap EpicListSection Component

parent 6149e048
<script>
import eventHub from '../event_hub';
import { SCROLL_BAR_SIZE } from '../constants';
import epicItem from './epic_item.vue';
export default {
components: {
epicItem,
},
props: {
epics: {
type: Array,
required: true,
},
timeframe: {
type: Array,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
shellWidth: {
type: Number,
required: true,
},
},
data() {
return {
shellHeight: 0,
emptyRowHeight: 0,
showEmptyRow: false,
};
},
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;`;
},
emptyRowCellStyles() {
return `height: ${this.emptyRowHeight}px;`;
},
},
watch: {
shellWidth: function shellWidth() {
// Scroll view to today indicator only when shellWidth is updated.
this.scrollToTodayIndicator();
},
},
mounted() {
this.$nextTick(() => {
this.initMounted();
});
},
methods: {
initMounted() {
// Get available shell height based on viewport height
this.shellHeight = window.innerHeight - (this.$el.offsetTop + this.$root.$el.offsetTop);
// In case there are epics present, initialize empty row
if (this.epics.length) {
this.initEmptyRow();
}
eventHub.$emit('epicsListRendered', {
width: this.$el.clientWidth,
height: this.shellHeight,
});
},
/**
* In case number of epics in the list are not sufficient
* to fill in full page height, we need to show an empty row
* at the bottom with fixed absolute height such that the
* column rulers expand to full page height
*
* This method calculates absolute height for empty column in pixels
* based on height of available list items and sets it to component
* props.
*/
initEmptyRow() {
const children = this.$children;
let approxChildrenHeight = children[0].$el.clientHeight * this.epics.length;
// Check if approximate height is greater than shell height
if (approxChildrenHeight < this.shellHeight) {
// reset approximate height and recalculate actual height
approxChildrenHeight = 0;
children.forEach((child) => {
// accumulate children height
// compensate for bottom border
approxChildrenHeight += child.$el.clientHeight;
});
// set height and show empty row reducing horizontal scrollbar size
this.emptyRowHeight = (this.shellHeight - approxChildrenHeight) - 1;
this.showEmptyRow = 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 scrollLeft = this.$el.scrollLeft;
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);
}
eventHub.$emit('epicsListScrolled', this.$el.scrollTop, this.$el.scrollLeft);
},
/**
* `clientWidth` is full width of list section, and we need to
* scroll up to 60% of the view where today indicator is present.
*
* Reason for 60% is that "today" always falls in the middle of timeframe range.
*/
scrollToTodayIndicator() {
const uptoTodayIndicator = Math.ceil((this.$el.clientWidth * 60) / 100);
this.$el.scrollTo(uptoTodayIndicator, 0);
},
},
};
</script>
<template>
<tbody
class="epics-list-section"
:style="tbodyStyles"
@scroll="handleScroll"
>
<epic-item
v-for="(epic, index) in epics"
:key="index"
:epic="epic"
:timeframe="timeframe"
:current-group-id="currentGroupId"
:shell-width="calcShellWidth"
/>
<tr
v-if="showEmptyRow"
class="epics-list-item epics-list-item-empty"
>
<td
class="epic-details-cell"
:style="emptyRowCellStyles"
>
</td>
<td
class="epic-timeline-cell"
v-for="(timeframeItem, index) in timeframe"
:key="index"
:style="emptyRowCellStyles"
>
</td>
</tr>
</tbody>
</template>
import Vue from 'vue';
import epicsListSectionComponent from 'ee/roadmap/components/epics_list_section.vue';
import RoadmapStore from 'ee/roadmap/store/roadmap_store';
import eventHub from 'ee/roadmap/event_hub';
import { rawEpics, mockTimeframe, mockGroupId, mockShellWidth } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const store = new RoadmapStore(mockGroupId, mockTimeframe);
store.setEpics(rawEpics);
const mockEpics = store.getEpics();
const createComponent = ({
epics = mockEpics,
timeframe = mockTimeframe,
currentGroupId = mockGroupId,
shellWidth = mockShellWidth,
}) => {
const Component = Vue.extend(epicsListSectionComponent);
return mountComponent(Component, {
epics,
timeframe,
currentGroupId,
shellWidth,
});
};
describe('EpicsListSectionComponent', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
vm = createComponent({});
expect(vm.shellHeight).toBe(0);
expect(vm.emptyRowHeight).toBe(0);
expect(vm.showEmptyRow).toBe(false);
});
});
describe('computed', () => {
beforeEach(() => {
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('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 string based on emptyRowHeight', () => {
expect(vm.emptyRowCellStyles).toBe('height: 0px;');
});
});
});
describe('methods', () => {
beforeEach(() => {
vm = createComponent({});
});
describe('initMounted', () => {
it('initializes shellHeight based on window.innerHeight and component element position', (done) => {
vm.$nextTick(() => {
expect(vm.shellHeight).toBe(600);
done();
});
});
it('calls initEmptyRow() when there are Epics to render', (done) => {
spyOn(vm, 'initEmptyRow').and.callThrough();
vm.$nextTick(() => {
expect(vm.initEmptyRow).toHaveBeenCalled();
done();
});
});
it('emits `epicsListRendered` via eventHub', (done) => {
spyOn(eventHub, '$emit');
vm.$nextTick(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('epicsListRendered', jasmine.any(Object));
done();
});
});
});
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.showEmptyRow).toBe(true);
done();
});
});
it('does not set `emptyRowHeight` and `showEmptyRow` props when shellHeight is less than approximate height of epics list', (done) => {
const initialHeight = window.innerHeight;
window.innerHeight = 0;
const vmMoreEpics = createComponent({
epics: mockEpics.concat(mockEpics).concat(mockEpics),
});
vmMoreEpics.$nextTick(() => {
expect(vmMoreEpics.emptyRowHeight).toBe(0);
expect(vmMoreEpics.showEmptyRow).toBe(false);
window.innerHeight = initialHeight; // reset to prevent any side effects
done();
});
});
});
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');
vm.scrollToTodayIndicator();
expect(vm.$el.scrollTo).toHaveBeenCalledWith(jasmine.any(Number), 0);
});
});
});
describe('template', () => {
beforeEach(() => {
vm = createComponent({});
});
it('renders component container element with class `epics-list-section`', (done) => {
vm.$nextTick(() => {
expect(vm.$el.classList.contains('epics-list-section')).toBe(true);
done();
});
});
it('renders component container element with `width` and `left` properties applied via style attribute', (done) => {
vm.$nextTick(() => {
expect(vm.$el.getAttribute('style')).toBe('width: 2015px; height: 0px;');
done();
});
});
});
});
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