Commit a09380da authored by Phil Hughes's avatar Phil Hughes

Merge branch '7077-frontend-expand-epics-in-roadmap' into 'master'

Add ability to expand epics in roadmap

See merge request gitlab-org/gitlab!23600
parents a47e7c0e 62975376
......@@ -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 **{chevron-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 { GlNewButton, GlIcon, GlTooltip } from '@gitlab/ui';
import { __, n__ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
GlNewButton,
GlIcon,
GlTooltip,
},
props: {
epic: {
type: Object,
......@@ -18,20 +27,64 @@ export default {
isEpicGroupDifferent() {
return this.currentGroupId !== this.epic.groupId;
},
isExpandIconHidden() {
return this.epic.isChildEpic || !this.epic.children?.edges?.length;
},
expandIconName() {
return this.epic.isChildEpicShowing ? 'chevron-down' : 'chevron-right';
},
expandIconLabel() {
return this.epic.isChildEpicShowing ? __('Collapse child epics') : __('Expand child epics');
},
childEpicsCount() {
return this.epic.isChildEpic ? '-' : this.epic.children?.edges?.length || 0;
},
childEpicsCountText() {
return Number.isInteger(this.childEpicsCount)
? n__(`%d child epic`, `%d child epics`, this.childEpicsCount)
: '';
},
},
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 align-items-start p-2" data-qa-selector="epic_details_cell">
<gl-new-button
:class="{ invisible: isExpandIconHidden }"
variant="link"
:aria-label="expandIconLabel"
@click="toggleIsEpicExpanded"
>
<gl-icon :name="expandIconName" class="text-secondary" aria-hidden="true" />
</gl-new-button>
<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 d-flex text-secondary">
<p v-if="isEpicGroupDifferent" :title="epic.groupFullName" class="epic-group">
{{ epic.groupName }}
</p>
<span class="mx-1" aria-hidden="true">&middot;</span>
<p class="epic-timeframe" :title="timeframeString">{{ timeframeString }}</p>
</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="{ invisible: epic.isChildEpic }"
class="d-flex text-secondary text-nowrap"
>
<gl-icon name="epic" class="align-text-bottom mr-1" aria-hidden="true" />
<p class="m-0" :aria-label="childEpicsCountText">{{ childEpicsCount }}</p>
</div>
</span>
<gl-tooltip v-if="!epic.isChildEpic" :target="() => $refs.childEpicsCount">
{{ childEpicsCountText }}
</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) {
......@@ -147,6 +142,11 @@ export default {
)
: 0;
},
epicWeightPercentageText() {
return sprintf(__(`%{percentage}%% weight completed`), {
percentage: this.epicWeightPercentage,
});
},
popoverWeightText() {
if (this.epic.descendantWeightSum) {
return sprintf(__('%{completedWeight} of %{totalWeight} weight completed'), {
......@@ -157,6 +157,9 @@ export default {
return __('- of - weight completed');
},
},
methods: {
generateKey,
},
};
</script>
......@@ -166,34 +169,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-child-epic': epic.isChildEpic }"
class="epic-bar rounded"
>
<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 m-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" />
{{ epicWeightPercentage }}%
</span>
<div v-if="!isTimelineBarSmall" class="d-flex align-items-center">
<gl-progress-bar
class="epic-bar-progress flex-grow-1 mr-1"
:value="epicWeightPercentage"
aria-hidden="true"
/>
<div class="gl-font-size-small d-flex align-items-center text-nowrap">
<icon class="append-right-2" :size="12" name="weight" />
<p class="m-0" :aria-label="epicWeightPercentageText">{{ epicWeightPercentage }}%</p>
</div>
</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,18 +62,29 @@ export default {
left: `${this.offsetLeft}px`,
};
},
displayedEpics() {
return this.epics.reduce((acc, epic) => {
acc.push(epic);
if (epic.isChildEpicShowing) {
acc.push(...epic.children.edges);
}
return acc;
}, []);
},
},
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: {
...mapActions(['setBufferSize']),
...mapActions(['setBufferSize', 'toggleExpandedEpic']),
initMounted() {
this.roadmapShellEl = this.$root.$el && this.$root.$el.firstChild;
this.setBufferSize(Math.ceil((window.innerHeight - this.$el.offsetTop) / EPIC_ITEM_HEIGHT));
......@@ -130,6 +141,10 @@ export default {
},
};
},
toggleIsEpicExpanded(epicId) {
this.toggleExpandedEpic(epicId);
},
generateKey,
},
};
</script>
......@@ -150,9 +165,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
}
}
}
}
}
}
}
......
......@@ -57,14 +57,9 @@ export const fetchGroupEpics = (
variables,
})
.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
? data?.group?.epic?.children?.edges || []
: data?.group?.epics?.edges || [];
return epicUtils.extractGroupEpics(edges);
});
......@@ -82,6 +77,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(childEpic =>
roadmapItemUtils.formatRoadmapItemDetails(
childEpic,
getters.timeframeStartDate,
getters.timeframeEndDate,
),
);
}
// Exclude any Epic that has invalid dates
// or is already present in Roadmap timeline
if (
......@@ -134,6 +147,11 @@ export const fetchEpicsForTimeframe = ({ 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;
......@@ -150,14 +168,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(childEpic =>
roadmapItemUtils.processRoadmapItemDates(
childEpic,
getters.timeframeStartDate,
getters.timeframeEndDate,
),
);
}
return roadmapItemUtils.processRoadmapItemDates(
epic,
getters.timeframeStartDate,
getters.timeframeEndDate,
),
);
);
});
commit(types.SET_EPICS, epics);
};
......@@ -252,5 +284,8 @@ export const refreshMilestoneDates = ({ commit, state, getters }) => {
export const setBufferSize = ({ commit }, bufferSize) => commit(types.SET_BUFFER_SIZE, bufferSize);
export const toggleExpandedEpic = ({ commit }, epicId) =>
commit(types.TOGGLE_EXPANDED_EPIC, epicId);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -24,3 +24,5 @@ export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE';
export const SET_BUFFER_SIZE = 'SET_BUFFER_SIZE';
export const TOGGLE_EXPANDED_EPIC = 'TOGGLE_EXPANDED_EPIC';
......@@ -76,4 +76,9 @@ export default {
[types.SET_BUFFER_SIZE](state, bufferSize) {
state.bufferSize = bufferSize;
},
[types.TOGGLE_EXPANDED_EPIC](state, epicId) {
const epic = state.epics.find(e => e.id === epicId);
epic.isChildEpicShowing = !epic.isChildEpicShowing;
},
};
......@@ -7,18 +7,23 @@ export const gqClient = createGqClient(
},
);
export const flattenGroupProperty = ({ node: epicNode }) => ({
...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,11 @@ 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-group-timeframe {
color: $gray-700;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.epic-group:hover {
cursor: pointer;
}
.epic-title,
.epic-group-timeframe {
@include text-truncate;
}
.epic-timeline-cell {
......@@ -359,7 +330,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 +342,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 +357,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;
......
---
title: Add ability to expand epics in roadmap
merge_request: 23600
author:
type: added
......@@ -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 { GlNewButton, 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 getExpandIconButton = wrapper => wrapper.find(GlNewButton);
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();
wrapper = null;
});
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 child epics', () => {
wrapper = createComponent();
expect(epicTitleEl).not.toBeNull();
expect(epicTitleEl.getAttribute('href')).toBe(mockEpic.webUrl);
expect(epicTitleEl.innerText.trim()).toBe(mockEpic.title);
});
expect(getExpandIconButton(wrapper).classes()).toContain('invisible');
});
it('is shown when epic has child epics', () => {
const epic = {
...mockFormattedEpic,
children: {
edges: [mockFormattedChildEpic1],
},
};
wrapper = createComponent(epic);
expect(getExpandIconButton(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 "chevron-right" icon when child epics are not expanded', () => {
wrapper = createComponent();
expect(wrapper.find(GlIcon).attributes('name')).toBe('chevron-right');
});
it('shows "chevron-down" icon when child epics are expanded', () => {
const epic = {
...mockFormattedEpic,
isChildEpicShowing: true,
};
wrapper = createComponent(epic);
expect(wrapper.find(GlIcon).attributes('name')).toBe('chevron-down');
});
it('has "Expand child epics" label when child epics are not expanded', () => {
wrapper = createComponent();
expect(getExpandIconButton(wrapper).attributes('aria-label')).toBe('Expand child epics');
});
it('has "Collapse child epics" label when child epics are expanded', () => {
const epic = {
...mockFormattedEpic,
isChildEpicShowing: true,
};
wrapper = createComponent(epic);
expect(getExpandIconButton(wrapper).attributes('aria-label')).toBe('Collapse child epics');
});
it('emits toggleIsEpicExpanded event when clicked', () => {
spyOn(eventHub, '$emit');
const id = 42;
const epic = {
...mockFormattedEpic,
id,
children: {
edges: [mockFormattedChildEpic1],
},
};
wrapper = createComponent(epic);
getExpandIconButton(wrapper).vm.$emit('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 child epic', () => {
const epic = {
...mockFormattedEpic,
isChildEpic: true,
};
wrapper = createComponent(epic);
expect(getExpandIconButton(wrapper).classes()).toContain('invisible');
});
});
it('renders Epic timeframe', () => {
vm = createComponent();
const epicTimeframeEl = vm.$el.querySelector('.epic-group-timeframe .epic-timeframe');
describe('child epics count', () => {
it('shows the correct count of child 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 child 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 child 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();
wrapper = null;
});
describe('computed', () => {
describe('epic bar', () => {
beforeEach(() => {
vm = createComponent({});
wrapper = 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(),
}),
);
});
it('shows the title', () => {
expect(getEpicBar(wrapper).text()).toContain(mockFormattedEpic.title);
});
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(),
}),
);
});
it('shows the progress bar with correct value', () => {
expect(wrapper.find(GlProgressBar).attributes('value')).toBe('60');
});
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,
}),
});
expect(vm.epicTotalWeight).toBe(undefined);
});
it('shows the percentage', () => {
expect(getEpicBar(wrapper).text()).toContain('60%');
});
describe('epicWeightPercentage', () => {
it('returns the correct percentage of completed to total weights', () => {
vm = 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);
});
});
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,
}),
});
expect(vm.popoverWeightText).toBe('- of - weight completed');
});
it('contains a link to the epic', () => {
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),
});
expect(vm.$el.querySelector('span.current-day-indicator')).not.toBeNull();
});
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', () => {
wrapper = createComponent();
expect(vm.$el.querySelector('.epic-bar-wrapper .epic-bar')).not.toBeNull();
expect(wrapper.find(GlPopover).text()).toContain('3 of 5 weight completed');
});
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),
}),
it('shows the weight completed with no numbers when there is no weights information', () => {
wrapper = createComponent({
epic: {
...mockFormattedEpic,
descendantWeightSum: undefined,
},
});
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);
});
});
......@@ -10,6 +10,8 @@ import {
TIMELINE_CELL_MIN_WIDTH,
} from 'ee/roadmap/constants';
import {
mockFormattedChildEpic1,
mockFormattedChildEpic2,
mockTimeframeInitialDate,
mockGroupId,
rawEpics,
......@@ -34,6 +36,10 @@ store.dispatch('receiveEpicsSuccess', { rawEpics });
const mockEpics = store.state.epics;
store.state.epics[0].children = {
edges: [mockFormattedChildEpic1, mockFormattedChildEpic2],
};
const createComponent = ({
epics = mockEpics,
timeframe = mockTimeframeMonths,
......@@ -71,6 +77,7 @@ describe('EpicsListSectionComponent', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('data', () => {
......@@ -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,23 @@ describe('EpicsListSectionComponent', () => {
expect(wrapper.find('.epic-scroll-bottom-shadow').exists()).toBe(true);
});
});
it('expands to show child 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);
wrapper.vm
.$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 = [
......
......@@ -182,6 +182,10 @@ describe('Roadmap Vuex Actions', () => {
Object.assign({}, mockRawEpic, {
start_date: '2017-12-31',
end_date: '2018-2-15',
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
}),
],
},
......@@ -210,7 +214,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 },
......@@ -613,4 +629,17 @@ describe('Roadmap Vuex Actions', () => {
);
});
});
describe('toggleExpandedEpic', () => {
it('should perform TOGGLE_EXPANDED_EPIC mutation with epic ID payload', done => {
testAction(
actions.toggleExpandedEpic,
10,
state,
[{ type: types.TOGGLE_EXPANDED_EPIC, payload: 10 }],
[],
done,
);
});
});
});
......@@ -5,6 +5,8 @@ import defaultState from 'ee/roadmap/store/state';
import { mockGroupId, basePath, epicsPath, mockSortedBy } from '../mock_data';
const getEpic = (epicId, epics) => epics.find(e => e.id === epicId);
describe('Roadmap Store Mutations', () => {
let state;
......@@ -195,4 +197,30 @@ describe('Roadmap Store Mutations', () => {
expect(state.bufferSize).toBe(bufferSize);
});
});
describe('TOGGLE_EXPANDED_EPIC', () => {
it('should toggle collapsed epic to an expanded epic', () => {
const epicId = 1;
const epics = [
{ id: 1, title: 'Collapsed epic', isChildEpicShowing: false },
{ id: 2, title: 'Expanded epic', isChildEpicShowing: true },
];
mutations[types.TOGGLE_EXPANDED_EPIC]({ ...state, epics }, epicId);
expect(getEpic(epicId, epics).isChildEpicShowing).toBe(true);
});
it('should toggle expanded epic to a collapsed epic', () => {
const epicId = 2;
const epics = [
{ id: 1, title: 'Collapsed epic', isChildEpicShowing: false },
{ id: 2, title: 'Expanded epic', isChildEpicShowing: true },
];
mutations[types.TOGGLE_EXPANDED_EPIC]({ ...state, epics }, epicId);
expect(getEpic(epicId, epics).isChildEpicShowing).toBe(false);
});
});
});
......@@ -17,3 +17,48 @@ describe('extractGroupEpics', () => {
);
});
});
describe('addIsChildEpicTrueProperty', () => {
const title = 'Lorem ipsum dolar sit';
const description = 'Beatae suscipit dolorum nihil quidem est accusamus';
const obj = {
title,
description,
};
let newObj;
beforeEach(() => {
newObj = epicUtils.addIsChildEpicTrueProperty(obj);
});
it('adds `isChildEpic` property with value `true`', () => {
expect(newObj.isChildEpic).toBe(true);
});
it('has original properties in returned object', () => {
expect(newObj.title).toBe(title);
expect(newObj.description).toBe(description);
});
});
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] ""
......@@ -390,6 +395,9 @@ msgstr ""
msgid "%{openedIssues} open, %{closedIssues} closed"
msgstr ""
msgid "%{percentage}%% weight completed"
msgstr ""
msgid "%{percent}%% complete"
msgstr ""
......@@ -4986,6 +4994,9 @@ msgstr ""
msgid "Collapse approvers"
msgstr ""
msgid "Collapse child epics"
msgstr ""
msgid "Collapse sidebar"
msgstr ""
......@@ -8307,6 +8318,9 @@ msgstr ""
msgid "Expand approvers"
msgstr ""
msgid "Expand child epics"
msgstr ""
msgid "Expand down"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment