Commit 27062213 authored by Coung Ngo's avatar Coung Ngo

Add ability to expand epics in roadmap

Add new feature hidden behind :roadmap_graphql and
:unfiltered_epic_aggregates feature flags
parent c5e82147
......@@ -9,25 +9,28 @@ type: reference
> - In [GitLab 12.9](https://gitlab.com/gitlab-org/gitlab/issues/5164) and later, the epic bars show their title, progress, and completed weight percentage.
An Epic within a group containing **Start date** and/or **Due date**
can be visualized in a form of a timeline (e.g. a Gantt chart). The Epics Roadmap page
can be visualized in a form of a timeline (a Gantt chart). The Epics Roadmap page
shows such a visualization for all the epics which are under a group and/or its subgroups.
On the epic bars, you can see their title, progress, and completed weight percentage.
When you hover over an epic bar, a popover appears with its title, start and due dates, and weight
completed.
You can expand epics that contain child epics to show their child epics in the roadmap.
You can click the chevron **{angle-down}** next to the epic title to expand and collapse the child epics.
![roadmap view](img/roadmap_view_v12_9.png)
A dropdown allows you to show only open or closed epics. By default, all epics are shown.
A dropdown menu allows you to show only open or closed epics. By default, all epics are shown.
![epics state dropdown](img/epics_state_dropdown.png)
Epics in the view can be sorted by:
You can sort epics in the Roadmap view by:
- **Created date**
- **Last updated**
- **Start date**
- **Due date**
- Created date
- Last updated
- Start date
- Due date
Each option contains a button that toggles the sort order between **ascending** and **descending**. The sort option and order will be persisted when browsing Epics,
including the [epics list view](../epics/index.md).
......
<script>
import { GlIcon, GlTooltip } from '@gitlab/ui';
import { __ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
GlIcon,
GlTooltip,
},
props: {
epic: {
type: Object,
......@@ -18,20 +26,63 @@ export default {
isEpicGroupDifferent() {
return this.currentGroupId !== this.epic.groupId;
},
isExpandIconHidden() {
return this.epic.isChildEpic || !this.epic?.children?.edges?.length;
},
expandIconName() {
return this.epic.isChildEpicShowing ? 'angle-down' : 'angle-right';
},
expandIconLabel() {
return this.epic.isChildEpicShowing ? __('Collapse') : __('Expand');
},
childEpicsCount() {
return this.epic.isChildEpic ? '-' : this.epic?.children?.edges?.length || 0;
},
},
methods: {
toggleIsEpicExpanded() {
eventHub.$emit('toggleIsEpicExpanded', this.epic.id);
},
},
};
</script>
<template>
<span class="epic-details-cell" data-qa-selector="epic_details_cell">
<div class="epic-title">
<a :href="epic.webUrl" :title="epic.title" class="epic-url">{{ epic.title }}</a>
<div class="epic-details-cell d-flex p-2" data-qa-selector="epic_details_cell">
<div
:class="{ invisible: isExpandIconHidden }"
class="epic-details-cell-expand-icon cursor-pointer"
tabindex="0"
@click="toggleIsEpicExpanded"
@keydown.enter="toggleIsEpicExpanded"
>
<gl-icon
:name="expandIconName"
class="text-secondary width"
:aria-label="expandIconLabel"
:size="12"
/>
</div>
<div class="overflow-hidden flex-grow-1" :class="[epic.isChildEpic ? 'ml-4 mr-2' : 'mx-2']">
<a :href="epic.webUrl" :title="epic.title" class="epic-title d-block text-body bold">
{{ epic.title }}
</a>
<div class="epic-group-timeframe text-secondary">
<span v-if="isEpicGroupDifferent" :title="epic.groupFullName" class="epic-group">
{{ epic.groupName }} &middot;
</span>
<span class="epic-timeframe" :title="timeframeString">{{ timeframeString }}</span>
</div>
</div>
<div class="epic-group-timeframe">
<span v-if="isEpicGroupDifferent" :title="epic.groupFullName" class="epic-group"
>{{ epic.groupName }} &middot;</span
>
<span class="epic-timeframe">{{ timeframeString }}</span>
<div
ref="childEpicsCount"
:class="['text-secondary', 'text-nowrap', { invisible: epic.isChildEpic }]"
>
<gl-icon name="epic" class="align-text-bottom" />
{{ childEpicsCount }}
</div>
</span>
<gl-tooltip v-if="!epic.isChildEpic" :target="() => $refs.childEpicsCount">
{{ n__(`%d child epic`, `%d child epics`, childEpicsCount) }}
</gl-tooltip>
</div>
</template>
......@@ -2,6 +2,7 @@
import { GlPopover, GlProgressBar } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import { generateKey } from '../utils/epic_utils';
import CommonMixin from '../mixins/common_mixin';
import QuartersPresetMixin from '../mixins/quarters_preset_mixin';
......@@ -16,7 +17,6 @@ import {
PRESET_TYPES,
SMALL_TIMELINE_BAR,
TIMELINE_CELL_MIN_WIDTH,
VERY_SMALL_TIMELINE_BAR,
} from '../constants';
export default {
......@@ -105,7 +105,7 @@ export default {
}
return false;
},
epicBarInnerStyle() {
timelineBarInnerStyle() {
return {
maxWidth: `${this.clientWidth - EPIC_DETAILS_CELL_WIDTH}px`,
};
......@@ -122,16 +122,11 @@ export default {
}
return Infinity;
},
showTimelineBarEllipsis() {
isTimelineBarSmall() {
return this.timelineBarWidth < SMALL_TIMELINE_BAR;
},
timelineBarEllipsis() {
if (this.timelineBarWidth < VERY_SMALL_TIMELINE_BAR) {
return '.';
} else if (this.timelineBarWidth < SMALL_TIMELINE_BAR) {
return '...';
}
return '';
timelineBarTitle() {
return this.isTimelineBarSmall ? '...' : this.epic.title;
},
epicTotalWeight() {
if (this.epic.descendantWeightSum) {
......@@ -157,6 +152,9 @@ export default {
return __('- of - weight completed');
},
},
methods: {
generateKey,
},
};
</script>
......@@ -166,34 +164,33 @@ export default {
<div class="epic-bar-wrapper">
<a
v-if="hasStartDate"
:id="`epic-bar-${epic.id}`"
:id="generateKey(epic)"
:href="epic.webUrl"
:style="timelineBarStyles(epic)"
class="epic-bar"
:class="['epic-bar', 'rounded', { 'epic-bar-child-epic': epic.isChildEpic }]"
>
<div class="epic-bar-inner" :style="epicBarInnerStyle">
<gl-progress-bar
class="epic-bar-progress append-bottom-2"
:value="epicWeightPercentage"
/>
<div class="epic-bar-inner px-2 py-1" :style="timelineBarInnerStyle">
<p class="epic-bar-title text-nowrap text-truncate mb-0">
{{ timelineBarTitle }}
</p>
<div v-if="showTimelineBarEllipsis" class="m-0">{{ timelineBarEllipsis }}</div>
<div v-else class="d-flex">
<span class="flex-grow-1 text-nowrap text-truncate mr-3">
{{ epic.title }}
</span>
<span class="d-flex align-items-center text-nowrap">
<icon class="append-right-2" :size="16" name="weight" />
<div v-if="!isTimelineBarSmall" class="d-flex align-items-center">
<gl-progress-bar
class="epic-bar-progress flex-grow-1 mr-1"
:value="epicWeightPercentage"
/>
<span class="gl-font-size-small d-flex align-items-center text-nowrap">
<icon class="append-right-2" :size="12" name="weight" />
{{ epicWeightPercentage }}%
</span>
</div>
</div>
</a>
<gl-popover
:target="`epic-bar-${epic.id}`"
:target="generateKey(epic)"
:title="epic.title"
triggers="hover focus"
placement="right"
triggers="hover"
placement="lefttop"
>
<p class="text-secondary m-0">{{ timeframeString(epic) }}</p>
<p class="m-0">{{ popoverWeightText }}</p>
......
<script>
import { mapState, mapActions } from 'vuex';
import VirtualList from 'vue-virtual-scroll-list';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import { generateKey } from '../utils/epic_utils';
import { EPIC_DETAILS_CELL_WIDTH, TIMELINE_CELL_MIN_WIDTH, EPIC_ITEM_HEIGHT } from '../constants';
......@@ -62,14 +62,29 @@ export default {
left: `${this.offsetLeft}px`,
};
},
displayedEpics() {
const displayedEpics = [];
this.epics.forEach(epic => {
displayedEpics.push(epic);
if (epic.isChildEpicShowing) {
displayedEpics.push(...epic.children.edges);
}
});
return displayedEpics;
},
},
mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$on('toggleIsEpicExpanded', this.toggleIsEpicExpanded);
window.addEventListener('resize', this.syncClientWidth);
this.initMounted();
},
beforeDestroy() {
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$off('toggleIsEpicExpanded', this.toggleIsEpicExpanded);
window.removeEventListener('resize', this.syncClientWidth);
},
methods: {
......@@ -130,6 +145,11 @@ export default {
},
};
},
toggleIsEpicExpanded(epicId) {
const epic = this.epics.find(e => e.id === epicId);
epic.isChildEpicShowing = !epic.isChildEpicShowing;
},
generateKey,
},
};
</script>
......@@ -150,9 +170,9 @@ export default {
</template>
<template v-else>
<epic-item
v-for="(epic, index) in epics"
v-for="(epic, index) in displayedEpics"
ref="epicItems"
:key="index"
:key="generateKey(epic)"
:first-epic="index === 0"
:preset-type="presetType"
:epic="epic"
......
......@@ -14,11 +14,7 @@ export const DAYS_IN_WEEK = 7;
export const PERCENTAGE = 100;
export const SMALL_TIMELINE_BAR = 100;
export const VERY_SMALL_TIMELINE_BAR = 27;
export const BUFFER_OVERLAP_SIZE = 20;
export const SMALL_TIMELINE_BAR = 40;
export const PRESET_TYPES = {
QUARTERS: 'QUARTERS',
......
......@@ -30,6 +30,27 @@ query epicChildEpics(
name
fullName
}
children {
edges {
node {
id
title
description
state
webUrl
startDate
dueDate
descendantWeightSum {
closedIssues
openedIssues
}
group {
name
fullName
}
}
}
}
}
}
}
......
......@@ -37,6 +37,27 @@ query groupEpics(
name
fullName
}
children {
edges {
node {
id
title
description
state
webUrl
startDate
dueDate
descendantWeightSum {
closedIssues
openedIssues
}
group {
name
fullName
}
}
}
}
}
}
}
......
......@@ -60,13 +60,9 @@ export const fetchGroupEpics = (
})
.then(({ data }) => {
const { group } = data;
let edges;
if (epicIid) {
edges = (group.epic && group.epic.children.edges) || [];
} else {
edges = (group.epics && group.epics.edges) || [];
}
const edges = epicIid
? (group.epic && group.epic.children.edges) || []
: (group.epics && group.epics.edges) || [];
return epicUtils.extractGroupEpics(edges);
});
......@@ -84,6 +80,24 @@ export const receiveEpicsSuccess = (
getters.timeframeStartDate,
getters.timeframeEndDate,
);
formattedEpic.isChildEpic = false;
formattedEpic.isChildEpicShowing = false;
// Format child epics
if (formattedEpic?.children?.edges?.length > 0) {
formattedEpic.children.edges = formattedEpic.children.edges
.map(epicUtils.flattenGroupProperty)
.map(epicUtils.addIsChildEpicTrueProperty)
.map(e =>
roadmapItemUtils.formatRoadmapItemDetails(
e,
getters.timeframeStartDate,
getters.timeframeEndDate,
),
);
}
// Exclude any Epic that has invalid dates
// or is already present in Roadmap timeline
if (
......@@ -171,6 +185,11 @@ export const fetchEpicsForTimeframeGQL = ({ state, dispatch }, { timeframe }) =>
.catch(() => dispatch('receiveEpicsFailure'));
};
/**
* Adds more EpicItemTimeline cells to the start or end of the roadmap.
*
* @param extendAs An EXTEND_AS enum value
*/
export const extendTimeframe = ({ commit, state, getters }, { extendAs }) => {
const isExtendTypePrepend = extendAs === EXTEND_AS.PREPEND;
......@@ -187,14 +206,28 @@ export const extendTimeframe = ({ commit, state, getters }, { extendAs }) => {
}
};
/**
* For epics that have no start or end date, this function updates their start and end dates
* so that the epic bars get longer to appear infinitely scrolling.
*/
export const refreshEpicDates = ({ commit, state, getters }) => {
const epics = state.epics.map(epic =>
roadmapItemUtils.processRoadmapItemDates(
const epics = state.epics.map(epic => {
// Update child epic dates too
if (epic?.children?.edges?.length > 0) {
epic.children.edges.map(e =>
roadmapItemUtils.processRoadmapItemDates(
e,
getters.timeframeStartDate,
getters.timeframeEndDate,
),
);
}
return roadmapItemUtils.processRoadmapItemDates(
epic,
getters.timeframeStartDate,
getters.timeframeEndDate,
),
);
);
});
commit(types.SET_EPICS, epics);
};
......
......@@ -7,18 +7,23 @@ export const gqClient = createGqClient(
},
);
export const flattenGroupProperty = ({ node, epicNode = node }) => ({
...epicNode,
// We can get rid of below two lines
// by updating `epic_item_details.vue`
// once we move to GraphQL permanently.
groupName: epicNode.group.name,
groupFullName: epicNode.group.fullName,
});
/**
* Returns array of epics extracted from GraphQL response
* discarding the `edges`->`node` nesting
*
* @param {Object} group
* @param {Object} edges
*/
export const extractGroupEpics = edges =>
edges.map(({ node, epicNode = node }) => ({
...epicNode,
// We can get rid of below two lines
// by updating `epic_item_details.vue`
// once we move to GraphQL permanently.
groupName: epicNode.group.name,
groupFullName: epicNode.group.fullName,
}));
export const extractGroupEpics = edges => edges.map(flattenGroupProperty);
export const addIsChildEpicTrueProperty = obj => ({ ...obj, isChildEpic: true });
export const generateKey = epic => `${epic.isChildEpic ? 'child-epic-' : 'epic-'}${epic.id}`;
......@@ -166,7 +166,6 @@ html.group-epics-roadmap-html {
.timeline-header-blank,
.timeline-header-item {
box-sizing: border-box;
float: left;
height: $header-item-height;
border-bottom: $border-style;
......@@ -233,7 +232,7 @@ html.group-epics-roadmap-html {
width: $gl-vert-padding;
background-color: $red-500;
border-radius: 50%;
transform: translateX(-2px);
transform: translateX(-3px);
}
}
......@@ -286,7 +285,6 @@ html.group-epics-roadmap-html {
.epic-details-cell,
.epic-timeline-cell {
box-sizing: border-box;
float: left;
height: $item-height;
border-bottom: $border-style;
......@@ -296,8 +294,8 @@ html.group-epics-roadmap-html {
position: sticky;
position: -webkit-sticky;
left: 0;
line-height: 1.3;
width: $details-cell-width;
padding: $gl-padding-8 $gl-padding;
font-size: $code-font-size;
background-color: $white;
z-index: 10;
......@@ -305,38 +303,19 @@ html.group-epics-roadmap-html {
&::after {
height: $item-height;
}
}
.epic-title,
.epic-group-timeframe {
will-change: contents;
}
.epic-title {
display: table;
table-layout: fixed;
width: 100%;
.epic-url {
display: table-cell;
color: $gray-900;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.epic-details-cell-expand-icon {
height: 20px;
}
.epic-group-timeframe {
color: $gray-700;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.epic-title,
.epic-group-timeframe {
@include text-truncate;
}
.epic-group:hover {
cursor: pointer;
}
.epic-group:hover {
cursor: pointer;
}
.epic-timeline-cell {
......@@ -359,7 +338,6 @@ html.group-epics-roadmap-html {
top: 5px;
height: 40px;
background-color: $blue-600;
border-radius: $border-radius-default;
will-change: width, left;
z-index: 5;
......@@ -372,10 +350,13 @@ html.group-epics-roadmap-html {
position: sticky;
position: -webkit-sticky;
left: $details-cell-width;
padding: $gl-padding-8;
color: $white;
}
.epic-bar-title {
line-height: 1.2;
}
.epic-bar-progress {
background-color: $blue-300;
......@@ -384,6 +365,37 @@ html.group-epics-roadmap-html {
}
}
.epic-bar-child-epic {
background-color: $white;
border: 1px solid $blue-600;
&:hover {
background-color: $white;
border: 1px solid $blue-700;
text-decoration: none;
.epic-bar-progress .progress-bar {
background-color: $blue-700;
}
}
.epic-bar-inner {
color: $blue-600;
&:hover {
color: $blue-700;
}
}
.epic-bar-progress {
background-color: $blue-200;
.progress-bar {
background-color: $blue-600;
}
}
}
.epic-scroll-bottom-shadow {
@include roadmap-scroll-mixin;
position: fixed;
......
......@@ -72,8 +72,8 @@ describe 'Epic show', :js do
page.within('.roadmap-shell .epics-list-section') do
expect(page).not_to have_content(not_child.title)
expect(find('.epics-list-item:nth-child(1) .epic-title a')).to have_content('Child epic B')
expect(find('.epics-list-item:nth-child(2) .epic-title a')).to have_content('Child epic A')
expect(find('.epics-list-item:nth-child(1) .epic-title')).to have_content('Child epic B')
expect(find('.epics-list-item:nth-child(2) .epic-title')).to have_content('Child epic A')
end
end
end
......
......@@ -8,6 +8,7 @@ describe 'epics list', :js do
before do
stub_licensed_features(epics: true)
stub_feature_flags(unfiltered_epic_aggregates: false)
sign_in(user)
end
......
......@@ -24,6 +24,7 @@ describe 'group epic roadmap', :js do
before do
stub_licensed_features(epics: true)
stub_feature_flags(unfiltered_epic_aggregates: false)
sign_in(user)
end
......
import Vue from 'vue';
import { GlIcon, GlTooltip } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EpicItemDetails from 'ee/roadmap/components/epic_item_details.vue';
import eventHub from 'ee/roadmap/event_hub';
import {
mockGroupId,
mockFormattedEpic,
mockFormattedChildEpic2,
mockFormattedChildEpic1,
} from '../mock_data';
const createComponent = (
epic = mockFormattedEpic,
currentGroupId = mockGroupId,
timeframeString = 'Jul 10, 2017 – Jun 2, 2018',
) => {
return shallowMount(EpicItemDetails, {
propsData: {
epic,
currentGroupId,
timeframeString,
},
});
};
import epicItemDetailsComponent from 'ee/roadmap/components/epic_item_details.vue';
const getTitle = wrapper => wrapper.find('.epic-title');
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockGroupId, mockEpic } from '../mock_data';
const getGroupName = wrapper => wrapper.find('.epic-group');
const createComponent = (epic = mockEpic, currentGroupId = mockGroupId) => {
const Component = Vue.extend(epicItemDetailsComponent);
const getExpandIconDiv = wrapper => wrapper.find('.epic-details-cell-expand-icon');
return mountComponent(Component, {
epic,
currentGroupId,
timeframeString: 'Jul 10, 2017 – Jun 2, 2018',
});
};
const getChildEpicsCount = wrapper => wrapper.find({ ref: 'childEpicsCount' });
describe('EpicItemDetailsComponent', () => {
let vm;
describe('EpicItemDetails', () => {
let wrapper;
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
describe('computed', () => {
describe('isEpicGroupDifferent', () => {
it('returns true when Epic.groupId is different from currentGroupId', () => {
const mockEpicItem = Object.assign({}, mockEpic, { groupId: 1 });
vm = createComponent(mockEpicItem, 2);
describe('epic title', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('is displayed', () => {
expect(getTitle(wrapper).text()).toBe(mockFormattedEpic.title);
});
it('contains a link to the epic', () => {
expect(getTitle(wrapper).attributes('href')).toBe(mockFormattedEpic.webUrl);
});
});
describe('epic group name', () => {
describe('when the epic group ID is different from the current group ID', () => {
let epic;
beforeEach(() => {
epic = {
mockFormattedEpic,
groupId: 1,
groupName: 'Bar',
groupFullName: 'Foo / Bar',
};
wrapper = createComponent(epic, 2);
});
it('is displayed', () => {
expect(getGroupName(wrapper).text()).toContain(epic.groupName);
});
expect(vm.isEpicGroupDifferent).toBe(true);
it('is set to the title attribute', () => {
expect(getGroupName(wrapper).attributes('title')).toBe(epic.groupFullName);
});
});
it('returns false when Epic.groupId is same as currentGroupId', () => {
const mockEpicItem = Object.assign({}, mockEpic, { groupId: 1 });
vm = createComponent(mockEpicItem, 1);
describe('when the epic group ID is the same as the current group ID', () => {
let epic;
beforeEach(() => {
epic = {
...mockFormattedEpic,
groupId: 1,
groupName: 'Bar',
groupFullName: 'Foo / Bar',
};
wrapper = createComponent(epic, 1);
});
expect(vm.isEpicGroupDifferent).toBe(false);
it('is hidden', () => {
expect(getGroupName(wrapper).exists()).toBe(false);
});
});
});
describe('template', () => {
it('renders component container element with class `epic-details-cell`', () => {
vm = createComponent();
describe('timeframe', () => {
it('is displayed', () => {
wrapper = createComponent();
const timeframe = wrapper.find('.epic-timeframe');
expect(vm.$el.classList.contains('epic-details-cell')).toBe(true);
expect(timeframe.text()).toBe('Jul 10, 2017 – Jun 2, 2018');
});
});
it('renders Epic title correctly', () => {
vm = createComponent();
const epicTitleEl = vm.$el.querySelector('.epic-title .epic-url');
describe('epic', () => {
describe('expand icon', () => {
it('is hidden when epic has no sub-epics', () => {
wrapper = createComponent();
expect(epicTitleEl).not.toBeNull();
expect(epicTitleEl.getAttribute('href')).toBe(mockEpic.webUrl);
expect(epicTitleEl.innerText.trim()).toBe(mockEpic.title);
});
expect(getExpandIconDiv(wrapper).classes()).toContain('invisible');
});
it('is shown when epic has sub-epics', () => {
const epic = {
...mockFormattedEpic,
children: {
edges: [mockFormattedChildEpic1],
},
};
wrapper = createComponent(epic);
expect(getExpandIconDiv(wrapper).classes()).not.toContain('invisible');
});
it('renders Epic group name and tooltip', () => {
const mockEpicItem = Object.assign({}, mockEpic, {
groupId: 1,
groupName: 'Bar',
groupFullName: 'Foo / Bar',
it('shows "angle-right" icon when sub-epics are not expanded', () => {
wrapper = createComponent();
expect(wrapper.find(GlIcon).attributes('name')).toBe('angle-right');
});
it('shows "angle-down" icon when sub-epics are expanded', () => {
const epic = {
...mockFormattedEpic,
isChildEpicShowing: true,
};
wrapper = createComponent(epic);
expect(wrapper.find(GlIcon).attributes('name')).toBe('angle-down');
});
it('has "Expand" label when sub-epics are not expanded', () => {
wrapper = createComponent();
expect(wrapper.find(GlIcon).attributes('aria-label')).toBe('Expand');
});
it('has "Collapse" label when sub-epics are expanded', () => {
const epic = {
...mockFormattedEpic,
isChildEpicShowing: true,
};
wrapper = createComponent(epic);
expect(wrapper.find(GlIcon).attributes('aria-label')).toBe('Collapse');
});
it('emits toggleIsEpicExpanded event when clicked', () => {
spyOn(eventHub, '$emit');
const id = 42;
const epic = {
...mockFormattedEpic,
id,
children: {
edges: [mockFormattedChildEpic1],
},
};
wrapper = createComponent(epic);
getExpandIconDiv(wrapper).trigger('click');
expect(eventHub.$emit).toHaveBeenCalledWith('toggleIsEpicExpanded', id);
});
vm = createComponent(mockEpicItem, 2);
const epicGroupNameEl = vm.$el.querySelector('.epic-group-timeframe .epic-group');
expect(epicGroupNameEl).not.toBeNull();
expect(epicGroupNameEl.innerText.trim()).toContain(mockEpicItem.groupName);
expect(epicGroupNameEl.getAttribute('title')).toBe(mockEpicItem.groupFullName);
it('is hidden when it is sub-epic', () => {
const epic = {
...mockFormattedEpic,
isChildEpic: true,
};
wrapper = createComponent(epic);
expect(getExpandIconDiv(wrapper).classes()).toContain('invisible');
});
});
it('renders Epic timeframe', () => {
vm = createComponent();
const epicTimeframeEl = vm.$el.querySelector('.epic-group-timeframe .epic-timeframe');
describe('sub-epics count', () => {
it('shows the correct count of sub-epics', () => {
const epic = {
...mockFormattedEpic,
children: {
edges: [mockFormattedChildEpic1, mockFormattedChildEpic2],
},
};
wrapper = createComponent(epic);
expect(getChildEpicsCount(wrapper).text()).toBe('2');
});
it('shows the count as 0 when there are no sub-epics', () => {
wrapper = createComponent();
expect(epicTimeframeEl).not.toBeNull();
expect(epicTimeframeEl.innerText.trim()).toBe('Jul 10, 2017 – Jun 2, 2018');
expect(getChildEpicsCount(wrapper).text()).toBe('0');
});
it('has a tooltip with the count', () => {
const epic = {
...mockFormattedEpic,
children: {
edges: [mockFormattedChildEpic1],
},
};
wrapper = createComponent(epic);
expect(wrapper.find(GlTooltip).text()).toBe('1 child epic');
});
it('is hidden when it is a sub-epic', () => {
const epic = {
...mockFormattedEpic,
isChildEpic: true,
};
wrapper = createComponent(epic);
expect(getChildEpicsCount(wrapper).classes()).toContain('invisible');
});
});
});
});
import Vue from 'vue';
import epicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { GlPopover, GlProgressBar } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import CurrentDayIndicator from 'ee/roadmap/components/current_day_indicator.vue';
import EpicItemTimeline from 'ee/roadmap/components/epic_item_timeline.vue';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframeInitialDate, mockEpic } from '../mock_data';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { mockTimeframeInitialDate, mockFormattedEpic } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ({
epic = mockFormattedEpic,
presetType = PRESET_TYPES.MONTHS,
timeframe = mockTimeframeMonths,
timeframeItem = mockTimeframeMonths[0],
epic = mockEpic,
timeframeString = '',
}) => {
const Component = Vue.extend(epicItemTimelineComponent);
return mountComponent(Component, {
presetType,
timeframe,
timeframeItem,
epic,
timeframeString,
} = {}) => {
return shallowMount(EpicItemTimeline, {
propsData: {
epic,
presetType,
timeframe,
timeframeItem,
timeframeString,
},
});
};
const getEpicBar = wrapper => wrapper.find('.epic-bar');
describe('EpicItemTimelineComponent', () => {
let vm;
let wrapper;
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
describe('computed', () => {
beforeEach(() => {
vm = createComponent({});
});
describe('startDateValues', () => {
it('returns object containing date parts from epic.startDate', () => {
expect(vm.startDateValues).toEqual(
jasmine.objectContaining({
day: mockEpic.startDate.getDay(),
date: mockEpic.startDate.getDate(),
month: mockEpic.startDate.getMonth(),
year: mockEpic.startDate.getFullYear(),
time: mockEpic.startDate.getTime(),
}),
);
});
});
describe('epic bar', () => {
it('shows the title', () => {
wrapper = createComponent();
describe('endDateValues', () => {
it('returns object containing date parts from epic.endDate', () => {
expect(vm.endDateValues).toEqual(
jasmine.objectContaining({
day: mockEpic.endDate.getDay(),
date: mockEpic.endDate.getDate(),
month: mockEpic.endDate.getMonth(),
year: mockEpic.endDate.getFullYear(),
time: mockEpic.endDate.getTime(),
}),
);
});
expect(getEpicBar(wrapper).text()).toContain(mockFormattedEpic.title);
});
describe('epicTotalWeight', () => {
it('returns the correct percentage of completed to total weights', () => {
vm = createComponent({});
expect(vm.epicTotalWeight).toBe(5);
});
it('returns undefined if weights information is not present', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
descendantWeightSum: undefined,
}),
});
it('shows the progress bar with correct value', () => {
wrapper = createComponent();
expect(vm.epicTotalWeight).toBe(undefined);
});
expect(wrapper.find(GlProgressBar).attributes('value')).toBe('60');
});
describe('epicWeightPercentage', () => {
it('returns the correct percentage of completed to total weights', () => {
vm = createComponent({});
it('shows the percentage', () => {
wrapper = createComponent();
expect(vm.epicWeightPercentage).toBe(60);
});
it('returns 0 when there is no total weight', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
descendantWeightSum: undefined,
}),
});
expect(vm.epicWeightPercentage).toBe(0);
});
expect(getEpicBar(wrapper).text()).toContain('60%');
});
describe('popoverWeightText', () => {
it('returns a description of the weight completed', () => {
vm = createComponent({});
expect(vm.popoverWeightText).toBe('3 of 5 weight completed');
});
it('returns a description with no numbers for weight completed when there is no weights information', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
descendantWeightSum: undefined,
}),
});
it('contains a link to the epic', () => {
wrapper = createComponent();
expect(vm.popoverWeightText).toBe('- of - weight completed');
});
expect(getEpicBar(wrapper).attributes('href')).toBe(mockFormattedEpic.webUrl);
});
});
describe('template', () => {
it('renders component container element with class `epic-timeline-cell`', () => {
vm = createComponent({});
describe('popover', () => {
it('shows the start and end dates', () => {
wrapper = createComponent();
expect(vm.$el.classList.contains('epic-timeline-cell')).toBe(true);
expect(wrapper.find(GlPopover).text()).toContain('Jun 26, 2017 – Mar 10, 2018');
});
it('renders current day indicator element', () => {
const currentDate = new Date();
vm = createComponent({
timeframeItem: new Date(currentDate.getFullYear(), currentDate.getMonth(), 1),
});
it('shows the weight completed', () => {
wrapper = createComponent();
expect(vm.$el.querySelector('span.current-day-indicator')).not.toBeNull();
expect(wrapper.find(GlPopover).text()).toContain('3 of 5 weight completed');
});
it('renders timeline bar element with class `epic-bar` and class `epic-bar-wrapper` as container element', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframeMonths[1] }),
timeframeItem: mockTimeframeMonths[1],
it('shows the weight completed with no numbers when there is no weights information', () => {
wrapper = createComponent({
epic: {
...mockFormattedEpic,
descendantWeightSum: undefined,
},
});
expect(vm.$el.querySelector('.epic-bar-wrapper .epic-bar')).not.toBeNull();
});
it('renders timeline bar with calculated `width` and `left` properties applied via style attribute', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframeMonths[0],
endDate: new Date(2018, 1, 15),
}),
});
const timelineBarEl = vm.$el.querySelector('.epic-bar-wrapper .epic-bar');
expect(timelineBarEl.getAttribute('style')).toContain('width');
expect(timelineBarEl.getAttribute('style')).toContain('left: 0px;');
expect(wrapper.find(GlPopover).text()).toContain('- of - weight completed');
});
});
it('renders component with the title in the epic bar', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframeMonths[1] }),
timeframeItem: mockTimeframeMonths[1],
});
it('shows current day indicator element', () => {
wrapper = createComponent();
expect(vm.$el.querySelector('.epic-bar').textContent).toContain(mockEpic.title);
});
expect(wrapper.find(CurrentDayIndicator).exists()).toBe(true);
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vue from 'vue';
import VirtualList from 'vue-virtual-scroll-list';
import epicsListSectionComponent from 'ee/roadmap/components/epics_list_section.vue';
import EpicItem from 'ee/roadmap/components/epic_item.vue';
......@@ -10,6 +11,8 @@ import {
TIMELINE_CELL_MIN_WIDTH,
} from 'ee/roadmap/constants';
import {
mockFormattedChildEpic1,
mockFormattedChildEpic2,
mockTimeframeInitialDate,
mockGroupId,
rawEpics,
......@@ -34,6 +37,10 @@ store.dispatch('receiveEpicsSuccess', { rawEpics });
const mockEpics = store.state.epics;
store.state.epics[0].children = {
edges: [mockFormattedChildEpic1, mockFormattedChildEpic2],
};
const createComponent = ({
epics = mockEpics,
timeframe = mockTimeframeMonths,
......@@ -225,6 +232,9 @@ describe('EpicsListSectionComponent', () => {
});
it('renders epic-item when roadmapBufferedRendering is `false`', () => {
// Destroy the wrapper created in the beforeEach above
wrapper.destroy();
const wrapperFlagOff = createComponent({
roadmapBufferedRendering: false,
});
......@@ -254,4 +264,22 @@ describe('EpicsListSectionComponent', () => {
expect(wrapper.find('.epic-scroll-bottom-shadow').exists()).toBe(true);
});
});
it('expands to show sub-epics when epic is toggled', done => {
const epic = mockEpics[0];
wrapper = createComponent();
expect(wrapper.findAll(EpicItem).length).toBe(mockEpics.length);
wrapper.vm.toggleIsEpicExpanded(epic.id);
Vue.nextTick()
.then(() => {
const expected = mockEpics.length + epic.children.edges.length;
expect(wrapper.findAll(EpicItem).length).toBe(expected);
})
.then(done)
.catch(done.fail);
});
});
......@@ -102,10 +102,6 @@ export const mockEpic = {
startDate: new Date('2017-07-10'),
originalStartDate: new Date('2017-07-10'),
endDate: new Date('2018-06-02'),
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
webUrl: '/groups/gitlab-org/-/epics/1',
};
......@@ -122,6 +118,52 @@ export const mockRawEpic = {
web_url: '/groups/gitlab-org/marketing/-/epics/2',
};
export const mockFormattedChildEpic1 = {
id: 50,
iid: 52,
description: null,
title: 'Marketing child epic 1',
groupId: 56,
groupName: 'Marketing',
groupFullName: 'Gitlab Org / Marketing',
startDate: new Date(2017, 10, 1),
originalStartDate: new Date(2017, 5, 26),
endDate: new Date(2018, 2, 10),
originalEndDate: new Date(2018, 2, 10),
startDateOutOfRange: true,
endDateOutOfRange: false,
webUrl: '/groups/gitlab-org/marketing/-/epics/5',
newEpic: undefined,
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
isChildEpic: true,
};
export const mockFormattedChildEpic2 = {
id: 51,
iid: 53,
description: null,
title: 'Marketing child epic 2',
groupId: 56,
groupName: 'Marketing',
groupFullName: 'Gitlab Org / Marketing',
startDate: new Date(2017, 10, 1),
originalStartDate: new Date(2017, 5, 26),
endDate: new Date(2018, 2, 10),
originalEndDate: new Date(2018, 2, 10),
startDateOutOfRange: true,
endDateOutOfRange: false,
webUrl: '/groups/gitlab-org/marketing/-/epics/6',
newEpic: undefined,
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
isChildEpic: true,
};
export const mockFormattedEpic = {
id: 41,
iid: 2,
......@@ -138,6 +180,12 @@ export const mockFormattedEpic = {
endDateOutOfRange: false,
webUrl: '/groups/gitlab-org/marketing/-/epics/2',
newEpic: undefined,
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
isChildEpic: false,
isChildEpicShowing: false,
};
export const rawEpics = [
......
......@@ -181,6 +181,10 @@ describe('Roadmap Vuex Actions', () => {
Object.assign({}, mockRawEpic, {
start_date: '2017-12-31',
end_date: '2018-2-15',
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
}),
],
},
......@@ -209,7 +213,19 @@ describe('Roadmap Vuex Actions', () => {
it('should set formatted epics array and epicId to IDs array in state based on provided epics list when timeframe was extended', done => {
testAction(
actions.receiveEpicsSuccess,
{ rawEpics: [mockRawEpic], newEpic: true, timeframeExtended: true },
{
rawEpics: [
{
...mockRawEpic,
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
},
],
newEpic: true,
timeframeExtended: true,
},
state,
[
{ type: types.UPDATE_EPIC_IDS, payload: mockRawEpic.id },
......
......@@ -17,3 +17,37 @@ describe('extractGroupEpics', () => {
);
});
});
describe('addIsChildEpicTrueProperty', () => {
it('adds `isChildEpic` property with value `true`', () => {
const obj = {
title: 'Lorem ipsum dolar sit',
};
const newObj = epicUtils.addIsChildEpicTrueProperty(obj);
expect(newObj.isChildEpic).toBe(true);
});
});
describe('generateKey', () => {
it('returns epic namespaced key for an epic object', () => {
const obj = {
id: 3,
title: 'Lorem ipsum dolar sit',
isChildEpic: false,
};
expect(epicUtils.generateKey(obj)).toBe('epic-3');
});
it('returns child-epic- namespaced key for a child epic object', () => {
const obj = {
id: 3,
title: 'Lorem ipsum dolar sit',
isChildEpic: true,
};
expect(epicUtils.generateKey(obj)).toBe('child-epic-3');
});
});
......@@ -76,6 +76,11 @@ msgid_plural "%d changed files"
msgstr[0] ""
msgstr[1] ""
msgid "%d child epic"
msgid_plural "%d child epics"
msgstr[0] ""
msgstr[1] ""
msgid "%d code quality issue"
msgid_plural "%d code quality issues"
msgstr[0] ""
......
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