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 ...@@ -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. > - 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** 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. 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. 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 When you hover over an epic bar, a popover appears with its title, start and due dates, and weight
completed. 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) ![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 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** - Created date
- **Last updated** - Last updated
- **Start date** - Start date
- **Due 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, 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). including the [epics list view](../epics/index.md).
......
<script> <script>
import { GlNewButton, GlIcon, GlTooltip } from '@gitlab/ui';
import { __, n__ } from '~/locale';
import eventHub from '../event_hub';
export default { export default {
components: {
GlNewButton,
GlIcon,
GlTooltip,
},
props: { props: {
epic: { epic: {
type: Object, type: Object,
...@@ -18,20 +27,64 @@ export default { ...@@ -18,20 +27,64 @@ export default {
isEpicGroupDifferent() { isEpicGroupDifferent() {
return this.currentGroupId !== this.epic.groupId; 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> </script>
<template> <template>
<span class="epic-details-cell" data-qa-selector="epic_details_cell"> <div class="epic-details-cell d-flex align-items-start p-2" data-qa-selector="epic_details_cell">
<div class="epic-title"> <gl-new-button
<a :href="epic.webUrl" :title="epic.title" class="epic-url">{{ epic.title }}</a> :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>
<div class="epic-group-timeframe"> <div
<span v-if="isEpicGroupDifferent" :title="epic.groupFullName" class="epic-group" ref="childEpicsCount"
>{{ epic.groupName }} &middot;</span :class="{ invisible: epic.isChildEpic }"
> class="d-flex text-secondary text-nowrap"
<span class="epic-timeframe">{{ timeframeString }}</span> >
<gl-icon name="epic" class="align-text-bottom mr-1" aria-hidden="true" />
<p class="m-0" :aria-label="childEpicsCountText">{{ childEpicsCount }}</p>
</div> </div>
</span> <gl-tooltip v-if="!epic.isChildEpic" :target="() => $refs.childEpicsCount">
{{ childEpicsCountText }}
</gl-tooltip>
</div>
</template> </template>
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { GlPopover, GlProgressBar } from '@gitlab/ui'; import { GlPopover, GlProgressBar } from '@gitlab/ui';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { generateKey } from '../utils/epic_utils';
import CommonMixin from '../mixins/common_mixin'; import CommonMixin from '../mixins/common_mixin';
import QuartersPresetMixin from '../mixins/quarters_preset_mixin'; import QuartersPresetMixin from '../mixins/quarters_preset_mixin';
...@@ -16,7 +17,6 @@ import { ...@@ -16,7 +17,6 @@ import {
PRESET_TYPES, PRESET_TYPES,
SMALL_TIMELINE_BAR, SMALL_TIMELINE_BAR,
TIMELINE_CELL_MIN_WIDTH, TIMELINE_CELL_MIN_WIDTH,
VERY_SMALL_TIMELINE_BAR,
} from '../constants'; } from '../constants';
export default { export default {
...@@ -105,7 +105,7 @@ export default { ...@@ -105,7 +105,7 @@ export default {
} }
return false; return false;
}, },
epicBarInnerStyle() { timelineBarInnerStyle() {
return { return {
maxWidth: `${this.clientWidth - EPIC_DETAILS_CELL_WIDTH}px`, maxWidth: `${this.clientWidth - EPIC_DETAILS_CELL_WIDTH}px`,
}; };
...@@ -122,16 +122,11 @@ export default { ...@@ -122,16 +122,11 @@ export default {
} }
return Infinity; return Infinity;
}, },
showTimelineBarEllipsis() { isTimelineBarSmall() {
return this.timelineBarWidth < SMALL_TIMELINE_BAR; return this.timelineBarWidth < SMALL_TIMELINE_BAR;
}, },
timelineBarEllipsis() { timelineBarTitle() {
if (this.timelineBarWidth < VERY_SMALL_TIMELINE_BAR) { return this.isTimelineBarSmall ? '...' : this.epic.title;
return '.';
} else if (this.timelineBarWidth < SMALL_TIMELINE_BAR) {
return '...';
}
return '';
}, },
epicTotalWeight() { epicTotalWeight() {
if (this.epic.descendantWeightSum) { if (this.epic.descendantWeightSum) {
...@@ -147,6 +142,11 @@ export default { ...@@ -147,6 +142,11 @@ export default {
) )
: 0; : 0;
}, },
epicWeightPercentageText() {
return sprintf(__(`%{percentage}%% weight completed`), {
percentage: this.epicWeightPercentage,
});
},
popoverWeightText() { popoverWeightText() {
if (this.epic.descendantWeightSum) { if (this.epic.descendantWeightSum) {
return sprintf(__('%{completedWeight} of %{totalWeight} weight completed'), { return sprintf(__('%{completedWeight} of %{totalWeight} weight completed'), {
...@@ -157,6 +157,9 @@ export default { ...@@ -157,6 +157,9 @@ export default {
return __('- of - weight completed'); return __('- of - weight completed');
}, },
}, },
methods: {
generateKey,
},
}; };
</script> </script>
...@@ -166,34 +169,33 @@ export default { ...@@ -166,34 +169,33 @@ export default {
<div class="epic-bar-wrapper"> <div class="epic-bar-wrapper">
<a <a
v-if="hasStartDate" v-if="hasStartDate"
:id="`epic-bar-${epic.id}`" :id="generateKey(epic)"
:href="epic.webUrl" :href="epic.webUrl"
:style="timelineBarStyles(epic)" :style="timelineBarStyles(epic)"
class="epic-bar" :class="{ 'epic-bar-child-epic': epic.isChildEpic }"
class="epic-bar rounded"
> >
<div class="epic-bar-inner" :style="epicBarInnerStyle"> <div class="epic-bar-inner px-2 py-1" :style="timelineBarInnerStyle">
<gl-progress-bar <p class="epic-bar-title text-nowrap text-truncate m-0">{{ timelineBarTitle }}</p>
class="epic-bar-progress append-bottom-2"
:value="epicWeightPercentage"
/>
<div v-if="showTimelineBarEllipsis" class="m-0">{{ timelineBarEllipsis }}</div> <div v-if="!isTimelineBarSmall" class="d-flex align-items-center">
<div v-else class="d-flex"> <gl-progress-bar
<span class="flex-grow-1 text-nowrap text-truncate mr-3"> class="epic-bar-progress flex-grow-1 mr-1"
{{ epic.title }} :value="epicWeightPercentage"
</span> aria-hidden="true"
<span class="d-flex align-items-center text-nowrap"> />
<icon class="append-right-2" :size="16" name="weight" /> <div class="gl-font-size-small d-flex align-items-center text-nowrap">
{{ epicWeightPercentage }}% <icon class="append-right-2" :size="12" name="weight" />
</span> <p class="m-0" :aria-label="epicWeightPercentageText">{{ epicWeightPercentage }}%</p>
</div>
</div> </div>
</div> </div>
</a> </a>
<gl-popover <gl-popover
:target="`epic-bar-${epic.id}`" :target="generateKey(epic)"
:title="epic.title" :title="epic.title"
triggers="hover focus" triggers="hover"
placement="right" placement="lefttop"
> >
<p class="text-secondary m-0">{{ timeframeString(epic) }}</p> <p class="text-secondary m-0">{{ timeframeString(epic) }}</p>
<p class="m-0">{{ popoverWeightText }}</p> <p class="m-0">{{ popoverWeightText }}</p>
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import VirtualList from 'vue-virtual-scroll-list'; import VirtualList from 'vue-virtual-scroll-list';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub'; 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'; import { EPIC_DETAILS_CELL_WIDTH, TIMELINE_CELL_MIN_WIDTH, EPIC_ITEM_HEIGHT } from '../constants';
...@@ -62,18 +62,29 @@ export default { ...@@ -62,18 +62,29 @@ export default {
left: `${this.offsetLeft}px`, 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() { mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll); eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$on('toggleIsEpicExpanded', this.toggleIsEpicExpanded);
window.addEventListener('resize', this.syncClientWidth); window.addEventListener('resize', this.syncClientWidth);
this.initMounted(); this.initMounted();
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll); eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
eventHub.$off('toggleIsEpicExpanded', this.toggleIsEpicExpanded);
window.removeEventListener('resize', this.syncClientWidth); window.removeEventListener('resize', this.syncClientWidth);
}, },
methods: { methods: {
...mapActions(['setBufferSize']), ...mapActions(['setBufferSize', 'toggleExpandedEpic']),
initMounted() { initMounted() {
this.roadmapShellEl = this.$root.$el && this.$root.$el.firstChild; this.roadmapShellEl = this.$root.$el && this.$root.$el.firstChild;
this.setBufferSize(Math.ceil((window.innerHeight - this.$el.offsetTop) / EPIC_ITEM_HEIGHT)); this.setBufferSize(Math.ceil((window.innerHeight - this.$el.offsetTop) / EPIC_ITEM_HEIGHT));
...@@ -130,6 +141,10 @@ export default { ...@@ -130,6 +141,10 @@ export default {
}, },
}; };
}, },
toggleIsEpicExpanded(epicId) {
this.toggleExpandedEpic(epicId);
},
generateKey,
}, },
}; };
</script> </script>
...@@ -150,9 +165,9 @@ export default { ...@@ -150,9 +165,9 @@ export default {
</template> </template>
<template v-else> <template v-else>
<epic-item <epic-item
v-for="(epic, index) in epics" v-for="(epic, index) in displayedEpics"
ref="epicItems" ref="epicItems"
:key="index" :key="generateKey(epic)"
:first-epic="index === 0" :first-epic="index === 0"
:preset-type="presetType" :preset-type="presetType"
:epic="epic" :epic="epic"
......
...@@ -14,11 +14,7 @@ export const DAYS_IN_WEEK = 7; ...@@ -14,11 +14,7 @@ export const DAYS_IN_WEEK = 7;
export const PERCENTAGE = 100; export const PERCENTAGE = 100;
export const SMALL_TIMELINE_BAR = 100; export const SMALL_TIMELINE_BAR = 40;
export const VERY_SMALL_TIMELINE_BAR = 27;
export const BUFFER_OVERLAP_SIZE = 20;
export const PRESET_TYPES = { export const PRESET_TYPES = {
QUARTERS: 'QUARTERS', QUARTERS: 'QUARTERS',
......
...@@ -30,6 +30,27 @@ query epicChildEpics( ...@@ -30,6 +30,27 @@ query epicChildEpics(
name name
fullName fullName
} }
children {
edges {
node {
id
title
description
state
webUrl
startDate
dueDate
descendantWeightSum {
closedIssues
openedIssues
}
group {
name
fullName
}
}
}
}
} }
} }
} }
......
...@@ -37,6 +37,27 @@ query groupEpics( ...@@ -37,6 +37,27 @@ query groupEpics(
name name
fullName fullName
} }
children {
edges {
node {
id
title
description
state
webUrl
startDate
dueDate
descendantWeightSum {
closedIssues
openedIssues
}
group {
name
fullName
}
}
}
}
} }
} }
} }
......
...@@ -57,14 +57,9 @@ export const fetchGroupEpics = ( ...@@ -57,14 +57,9 @@ export const fetchGroupEpics = (
variables, variables,
}) })
.then(({ data }) => { .then(({ data }) => {
const { group } = data; const edges = epicIid
let edges; ? data?.group?.epic?.children?.edges || []
: data?.group?.epics?.edges || [];
if (epicIid) {
edges = (group.epic && group.epic.children.edges) || [];
} else {
edges = (group.epics && group.epics.edges) || [];
}
return epicUtils.extractGroupEpics(edges); return epicUtils.extractGroupEpics(edges);
}); });
...@@ -82,6 +77,24 @@ export const receiveEpicsSuccess = ( ...@@ -82,6 +77,24 @@ export const receiveEpicsSuccess = (
getters.timeframeStartDate, getters.timeframeStartDate,
getters.timeframeEndDate, 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 // Exclude any Epic that has invalid dates
// or is already present in Roadmap timeline // or is already present in Roadmap timeline
if ( if (
...@@ -134,6 +147,11 @@ export const fetchEpicsForTimeframe = ({ state, dispatch }, { timeframe }) => { ...@@ -134,6 +147,11 @@ export const fetchEpicsForTimeframe = ({ state, dispatch }, { timeframe }) => {
.catch(() => dispatch('receiveEpicsFailure')); .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 }) => { export const extendTimeframe = ({ commit, state, getters }, { extendAs }) => {
const isExtendTypePrepend = extendAs === EXTEND_AS.PREPEND; const isExtendTypePrepend = extendAs === EXTEND_AS.PREPEND;
...@@ -150,14 +168,28 @@ export const extendTimeframe = ({ commit, state, getters }, { extendAs }) => { ...@@ -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 }) => { export const refreshEpicDates = ({ commit, state, getters }) => {
const epics = state.epics.map(epic => const epics = state.epics.map(epic => {
roadmapItemUtils.processRoadmapItemDates( // 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, epic,
getters.timeframeStartDate, getters.timeframeStartDate,
getters.timeframeEndDate, getters.timeframeEndDate,
), );
); });
commit(types.SET_EPICS, epics); commit(types.SET_EPICS, epics);
}; };
...@@ -252,5 +284,8 @@ export const refreshMilestoneDates = ({ commit, state, getters }) => { ...@@ -252,5 +284,8 @@ export const refreshMilestoneDates = ({ commit, state, getters }) => {
export const setBufferSize = ({ commit }, bufferSize) => commit(types.SET_BUFFER_SIZE, bufferSize); 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 // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -24,3 +24,5 @@ export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS'; ...@@ -24,3 +24,5 @@ export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE'; export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE';
export const SET_BUFFER_SIZE = 'SET_BUFFER_SIZE'; export const SET_BUFFER_SIZE = 'SET_BUFFER_SIZE';
export const TOGGLE_EXPANDED_EPIC = 'TOGGLE_EXPANDED_EPIC';
...@@ -76,4 +76,9 @@ export default { ...@@ -76,4 +76,9 @@ export default {
[types.SET_BUFFER_SIZE](state, bufferSize) { [types.SET_BUFFER_SIZE](state, bufferSize) {
state.bufferSize = 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( ...@@ -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 * Returns array of epics extracted from GraphQL response
* discarding the `edges`->`node` nesting * discarding the `edges`->`node` nesting
* *
* @param {Object} group * @param {Object} edges
*/ */
export const extractGroupEpics = edges => export const extractGroupEpics = edges => edges.map(flattenGroupProperty);
edges.map(({ node, epicNode = node }) => ({
...epicNode, export const addIsChildEpicTrueProperty = obj => ({ ...obj, isChildEpic: true });
// We can get rid of below two lines
// by updating `epic_item_details.vue` export const generateKey = epic => `${epic.isChildEpic ? 'child-epic-' : 'epic-'}${epic.id}`;
// once we move to GraphQL permanently.
groupName: epicNode.group.name,
groupFullName: epicNode.group.fullName,
}));
...@@ -166,7 +166,6 @@ html.group-epics-roadmap-html { ...@@ -166,7 +166,6 @@ html.group-epics-roadmap-html {
.timeline-header-blank, .timeline-header-blank,
.timeline-header-item { .timeline-header-item {
box-sizing: border-box;
float: left; float: left;
height: $header-item-height; height: $header-item-height;
border-bottom: $border-style; border-bottom: $border-style;
...@@ -233,7 +232,7 @@ html.group-epics-roadmap-html { ...@@ -233,7 +232,7 @@ html.group-epics-roadmap-html {
width: $gl-vert-padding; width: $gl-vert-padding;
background-color: $red-500; background-color: $red-500;
border-radius: 50%; border-radius: 50%;
transform: translateX(-2px); transform: translateX(-3px);
} }
} }
...@@ -286,7 +285,6 @@ html.group-epics-roadmap-html { ...@@ -286,7 +285,6 @@ html.group-epics-roadmap-html {
.epic-details-cell, .epic-details-cell,
.epic-timeline-cell { .epic-timeline-cell {
box-sizing: border-box;
float: left; float: left;
height: $item-height; height: $item-height;
border-bottom: $border-style; border-bottom: $border-style;
...@@ -296,8 +294,8 @@ html.group-epics-roadmap-html { ...@@ -296,8 +294,8 @@ html.group-epics-roadmap-html {
position: sticky; position: sticky;
position: -webkit-sticky; position: -webkit-sticky;
left: 0; left: 0;
line-height: 1.3;
width: $details-cell-width; width: $details-cell-width;
padding: $gl-padding-8 $gl-padding;
font-size: $code-font-size; font-size: $code-font-size;
background-color: $white; background-color: $white;
z-index: 10; z-index: 10;
...@@ -305,38 +303,11 @@ html.group-epics-roadmap-html { ...@@ -305,38 +303,11 @@ html.group-epics-roadmap-html {
&::after { &::after {
height: $item-height; height: $item-height;
} }
}
.epic-title, .epic-title,
.epic-group-timeframe { .epic-group-timeframe {
will-change: contents; @include text-truncate;
}
.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-timeline-cell { .epic-timeline-cell {
...@@ -359,7 +330,6 @@ html.group-epics-roadmap-html { ...@@ -359,7 +330,6 @@ html.group-epics-roadmap-html {
top: 5px; top: 5px;
height: 40px; height: 40px;
background-color: $blue-600; background-color: $blue-600;
border-radius: $border-radius-default;
will-change: width, left; will-change: width, left;
z-index: 5; z-index: 5;
...@@ -372,10 +342,13 @@ html.group-epics-roadmap-html { ...@@ -372,10 +342,13 @@ html.group-epics-roadmap-html {
position: sticky; position: sticky;
position: -webkit-sticky; position: -webkit-sticky;
left: $details-cell-width; left: $details-cell-width;
padding: $gl-padding-8;
color: $white; color: $white;
} }
.epic-bar-title {
line-height: 1.2;
}
.epic-bar-progress { .epic-bar-progress {
background-color: $blue-300; background-color: $blue-300;
...@@ -384,6 +357,37 @@ html.group-epics-roadmap-html { ...@@ -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 { .epic-scroll-bottom-shadow {
@include roadmap-scroll-mixin; @include roadmap-scroll-mixin;
position: fixed; 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 ...@@ -72,8 +72,8 @@ describe 'Epic show', :js do
page.within('.roadmap-shell .epics-list-section') do page.within('.roadmap-shell .epics-list-section') do
expect(page).not_to have_content(not_child.title) 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(1) .epic-title')).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(2) .epic-title')).to have_content('Child epic A')
end end
end end
end end
......
...@@ -8,6 +8,7 @@ describe 'epics list', :js do ...@@ -8,6 +8,7 @@ describe 'epics list', :js do
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
stub_feature_flags(unfiltered_epic_aggregates: false)
sign_in(user) sign_in(user)
end end
......
...@@ -24,6 +24,7 @@ describe 'group epic roadmap', :js do ...@@ -24,6 +24,7 @@ describe 'group epic roadmap', :js do
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
stub_feature_flags(unfiltered_epic_aggregates: false)
sign_in(user) sign_in(user)
end 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'; const getGroupName = wrapper => wrapper.find('.epic-group');
import { mockGroupId, mockEpic } from '../mock_data';
const createComponent = (epic = mockEpic, currentGroupId = mockGroupId) => { const getExpandIconButton = wrapper => wrapper.find(GlNewButton);
const Component = Vue.extend(epicItemDetailsComponent);
return mountComponent(Component, { const getChildEpicsCount = wrapper => wrapper.find({ ref: 'childEpicsCount' });
epic,
currentGroupId,
timeframeString: 'Jul 10, 2017 – Jun 2, 2018',
});
};
describe('EpicItemDetailsComponent', () => { describe('EpicItemDetails', () => {
let vm; let wrapper;
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('computed', () => { describe('epic title', () => {
describe('isEpicGroupDifferent', () => { beforeEach(() => {
it('returns true when Epic.groupId is different from currentGroupId', () => { wrapper = createComponent();
const mockEpicItem = Object.assign({}, mockEpic, { groupId: 1 }); });
vm = createComponent(mockEpicItem, 2);
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', () => { describe('when the epic group ID is the same as the current group ID', () => {
const mockEpicItem = Object.assign({}, mockEpic, { groupId: 1 }); let epic;
vm = createComponent(mockEpicItem, 1);
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', () => { describe('timeframe', () => {
it('renders component container element with class `epic-details-cell`', () => { it('is displayed', () => {
vm = createComponent(); 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', () => { describe('epic', () => {
vm = createComponent(); describe('expand icon', () => {
const epicTitleEl = vm.$el.querySelector('.epic-title .epic-url'); it('is hidden when epic has no child epics', () => {
wrapper = createComponent();
expect(epicTitleEl).not.toBeNull(); expect(getExpandIconButton(wrapper).classes()).toContain('invisible');
expect(epicTitleEl.getAttribute('href')).toBe(mockEpic.webUrl); });
expect(epicTitleEl.innerText.trim()).toBe(mockEpic.title);
}); 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', () => { it('shows "chevron-right" icon when child epics are not expanded', () => {
const mockEpicItem = Object.assign({}, mockEpic, { wrapper = createComponent();
groupId: 1,
groupName: 'Bar', expect(wrapper.find(GlIcon).attributes('name')).toBe('chevron-right');
groupFullName: 'Foo / Bar', });
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(); it('is hidden when it is child epic', () => {
expect(epicGroupNameEl.innerText.trim()).toContain(mockEpicItem.groupName); const epic = {
expect(epicGroupNameEl.getAttribute('title')).toBe(mockEpicItem.groupFullName); ...mockFormattedEpic,
isChildEpic: true,
};
wrapper = createComponent(epic);
expect(getExpandIconButton(wrapper).classes()).toContain('invisible');
});
}); });
it('renders Epic timeframe', () => { describe('child epics count', () => {
vm = createComponent(); it('shows the correct count of child epics', () => {
const epicTimeframeEl = vm.$el.querySelector('.epic-group-timeframe .epic-timeframe'); 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(getChildEpicsCount(wrapper).text()).toBe('0');
expect(epicTimeframeEl.innerText.trim()).toBe('Jul 10, 2017 – Jun 2, 2018'); });
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 { GlPopover, GlProgressBar } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import epicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue'; import CurrentDayIndicator from 'ee/roadmap/components/current_day_indicator.vue';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils'; import EpicItemTimeline from 'ee/roadmap/components/epic_item_timeline.vue';
import { PRESET_TYPES } from 'ee/roadmap/constants'; import { PRESET_TYPES } from 'ee/roadmap/constants';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { mockTimeframeInitialDate, mockFormattedEpic } from '../mock_data';
import { mockTimeframeInitialDate, mockEpic } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate); const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ({ const createComponent = ({
epic = mockFormattedEpic,
presetType = PRESET_TYPES.MONTHS, presetType = PRESET_TYPES.MONTHS,
timeframe = mockTimeframeMonths, timeframe = mockTimeframeMonths,
timeframeItem = mockTimeframeMonths[0], timeframeItem = mockTimeframeMonths[0],
epic = mockEpic,
timeframeString = '', timeframeString = '',
}) => { } = {}) => {
const Component = Vue.extend(epicItemTimelineComponent); return shallowMount(EpicItemTimeline, {
propsData: {
return mountComponent(Component, { epic,
presetType, presetType,
timeframe, timeframe,
timeframeItem, timeframeItem,
epic, timeframeString,
timeframeString, },
}); });
}; };
const getEpicBar = wrapper => wrapper.find('.epic-bar');
describe('EpicItemTimelineComponent', () => { describe('EpicItemTimelineComponent', () => {
let vm; let wrapper;
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('computed', () => { describe('epic bar', () => {
beforeEach(() => { beforeEach(() => {
vm = createComponent({}); wrapper = createComponent();
}); });
describe('startDateValues', () => { it('shows the title', () => {
it('returns object containing date parts from epic.startDate', () => { expect(getEpicBar(wrapper).text()).toContain(mockFormattedEpic.title);
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('endDateValues', () => { it('shows the progress bar with correct value', () => {
it('returns object containing date parts from epic.endDate', () => { expect(wrapper.find(GlProgressBar).attributes('value')).toBe('60');
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(),
}),
);
});
}); });
describe('epicTotalWeight', () => { it('shows the percentage', () => {
it('returns the correct percentage of completed to total weights', () => { expect(getEpicBar(wrapper).text()).toContain('60%');
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);
});
}); });
describe('epicWeightPercentage', () => { it('contains a link to the epic', () => {
it('returns the correct percentage of completed to total weights', () => { expect(getEpicBar(wrapper).attributes('href')).toBe(mockFormattedEpic.webUrl);
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');
});
}); });
}); });
describe('template', () => { describe('popover', () => {
it('renders component container element with class `epic-timeline-cell`', () => { it('shows the start and end dates', () => {
vm = createComponent({}); 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', () => { it('shows the weight completed', () => {
const currentDate = new Date(); wrapper = createComponent();
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],
});
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', () => { it('shows the weight completed with no numbers when there is no weights information', () => {
vm = createComponent({ wrapper = createComponent({
epic: Object.assign({}, mockEpic, { epic: {
startDate: mockTimeframeMonths[0], ...mockFormattedEpic,
endDate: new Date(2018, 1, 15), descendantWeightSum: undefined,
}), },
}); });
const timelineBarEl = vm.$el.querySelector('.epic-bar-wrapper .epic-bar');
expect(timelineBarEl.getAttribute('style')).toContain('width'); expect(wrapper.find(GlPopover).text()).toContain('- of - weight completed');
expect(timelineBarEl.getAttribute('style')).toContain('left: 0px;');
}); });
});
it('renders component with the title in the epic bar', () => { it('shows current day indicator element', () => {
vm = createComponent({ wrapper = createComponent();
epic: Object.assign({}, mockEpic, { startDate: mockTimeframeMonths[1] }),
timeframeItem: mockTimeframeMonths[1],
});
expect(vm.$el.querySelector('.epic-bar').textContent).toContain(mockEpic.title); expect(wrapper.find(CurrentDayIndicator).exists()).toBe(true);
});
}); });
}); });
...@@ -10,6 +10,8 @@ import { ...@@ -10,6 +10,8 @@ import {
TIMELINE_CELL_MIN_WIDTH, TIMELINE_CELL_MIN_WIDTH,
} from 'ee/roadmap/constants'; } from 'ee/roadmap/constants';
import { import {
mockFormattedChildEpic1,
mockFormattedChildEpic2,
mockTimeframeInitialDate, mockTimeframeInitialDate,
mockGroupId, mockGroupId,
rawEpics, rawEpics,
...@@ -34,6 +36,10 @@ store.dispatch('receiveEpicsSuccess', { rawEpics }); ...@@ -34,6 +36,10 @@ store.dispatch('receiveEpicsSuccess', { rawEpics });
const mockEpics = store.state.epics; const mockEpics = store.state.epics;
store.state.epics[0].children = {
edges: [mockFormattedChildEpic1, mockFormattedChildEpic2],
};
const createComponent = ({ const createComponent = ({
epics = mockEpics, epics = mockEpics,
timeframe = mockTimeframeMonths, timeframe = mockTimeframeMonths,
...@@ -71,6 +77,7 @@ describe('EpicsListSectionComponent', () => { ...@@ -71,6 +77,7 @@ describe('EpicsListSectionComponent', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('data', () => { describe('data', () => {
...@@ -225,6 +232,9 @@ describe('EpicsListSectionComponent', () => { ...@@ -225,6 +232,9 @@ describe('EpicsListSectionComponent', () => {
}); });
it('renders epic-item when roadmapBufferedRendering is `false`', () => { it('renders epic-item when roadmapBufferedRendering is `false`', () => {
// Destroy the wrapper created in the beforeEach above
wrapper.destroy();
const wrapperFlagOff = createComponent({ const wrapperFlagOff = createComponent({
roadmapBufferedRendering: false, roadmapBufferedRendering: false,
}); });
...@@ -254,4 +264,23 @@ describe('EpicsListSectionComponent', () => { ...@@ -254,4 +264,23 @@ describe('EpicsListSectionComponent', () => {
expect(wrapper.find('.epic-scroll-bottom-shadow').exists()).toBe(true); 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 = { ...@@ -102,10 +102,6 @@ export const mockEpic = {
startDate: new Date('2017-07-10'), startDate: new Date('2017-07-10'),
originalStartDate: new Date('2017-07-10'), originalStartDate: new Date('2017-07-10'),
endDate: new Date('2018-06-02'), endDate: new Date('2018-06-02'),
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
webUrl: '/groups/gitlab-org/-/epics/1', webUrl: '/groups/gitlab-org/-/epics/1',
}; };
...@@ -122,6 +118,52 @@ export const mockRawEpic = { ...@@ -122,6 +118,52 @@ export const mockRawEpic = {
web_url: '/groups/gitlab-org/marketing/-/epics/2', 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 = { export const mockFormattedEpic = {
id: 41, id: 41,
iid: 2, iid: 2,
...@@ -138,6 +180,12 @@ export const mockFormattedEpic = { ...@@ -138,6 +180,12 @@ export const mockFormattedEpic = {
endDateOutOfRange: false, endDateOutOfRange: false,
webUrl: '/groups/gitlab-org/marketing/-/epics/2', webUrl: '/groups/gitlab-org/marketing/-/epics/2',
newEpic: undefined, newEpic: undefined,
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
isChildEpic: false,
isChildEpicShowing: false,
}; };
export const rawEpics = [ export const rawEpics = [
......
...@@ -182,6 +182,10 @@ describe('Roadmap Vuex Actions', () => { ...@@ -182,6 +182,10 @@ describe('Roadmap Vuex Actions', () => {
Object.assign({}, mockRawEpic, { Object.assign({}, mockRawEpic, {
start_date: '2017-12-31', start_date: '2017-12-31',
end_date: '2018-2-15', end_date: '2018-2-15',
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
}), }),
], ],
}, },
...@@ -210,7 +214,19 @@ describe('Roadmap Vuex Actions', () => { ...@@ -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 => { it('should set formatted epics array and epicId to IDs array in state based on provided epics list when timeframe was extended', done => {
testAction( testAction(
actions.receiveEpicsSuccess, actions.receiveEpicsSuccess,
{ rawEpics: [mockRawEpic], newEpic: true, timeframeExtended: true }, {
rawEpics: [
{
...mockRawEpic,
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
},
],
newEpic: true,
timeframeExtended: true,
},
state, state,
[ [
{ type: types.UPDATE_EPIC_IDS, payload: mockRawEpic.id }, { type: types.UPDATE_EPIC_IDS, payload: mockRawEpic.id },
...@@ -613,4 +629,17 @@ describe('Roadmap Vuex Actions', () => { ...@@ -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'; ...@@ -5,6 +5,8 @@ import defaultState from 'ee/roadmap/store/state';
import { mockGroupId, basePath, epicsPath, mockSortedBy } from '../mock_data'; import { mockGroupId, basePath, epicsPath, mockSortedBy } from '../mock_data';
const getEpic = (epicId, epics) => epics.find(e => e.id === epicId);
describe('Roadmap Store Mutations', () => { describe('Roadmap Store Mutations', () => {
let state; let state;
...@@ -195,4 +197,30 @@ describe('Roadmap Store Mutations', () => { ...@@ -195,4 +197,30 @@ describe('Roadmap Store Mutations', () => {
expect(state.bufferSize).toBe(bufferSize); 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', () => { ...@@ -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" ...@@ -76,6 +76,11 @@ msgid_plural "%d changed files"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "%d child epic"
msgid_plural "%d child epics"
msgstr[0] ""
msgstr[1] ""
msgid "%d code quality issue" msgid "%d code quality issue"
msgid_plural "%d code quality issues" msgid_plural "%d code quality issues"
msgstr[0] "" msgstr[0] ""
...@@ -390,6 +395,9 @@ msgstr "" ...@@ -390,6 +395,9 @@ msgstr ""
msgid "%{openedIssues} open, %{closedIssues} closed" msgid "%{openedIssues} open, %{closedIssues} closed"
msgstr "" msgstr ""
msgid "%{percentage}%% weight completed"
msgstr ""
msgid "%{percent}%% complete" msgid "%{percent}%% complete"
msgstr "" msgstr ""
...@@ -4986,6 +4994,9 @@ msgstr "" ...@@ -4986,6 +4994,9 @@ msgstr ""
msgid "Collapse approvers" msgid "Collapse approvers"
msgstr "" msgstr ""
msgid "Collapse child epics"
msgstr ""
msgid "Collapse sidebar" msgid "Collapse sidebar"
msgstr "" msgstr ""
...@@ -8307,6 +8318,9 @@ msgstr "" ...@@ -8307,6 +8318,9 @@ msgstr ""
msgid "Expand approvers" msgid "Expand approvers"
msgstr "" msgstr ""
msgid "Expand child epics"
msgstr ""
msgid "Expand down" msgid "Expand down"
msgstr "" 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