Commit 28d849ea authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '213401-add-hierarchy-depth-to-roadmaps' into 'master'

Add hierarchy depth to roadmaps

See merge request gitlab-org/gitlab!29105
parents 94736861 5d7a7b11
...@@ -434,6 +434,7 @@ img.emoji { ...@@ -434,6 +434,7 @@ img.emoji {
.append-bottom-20 { margin-bottom: 20px; } .append-bottom-20 { margin-bottom: 20px; }
.append-bottom-default { margin-bottom: $gl-padding; } .append-bottom-default { margin-bottom: $gl-padding; }
.prepend-bottom-32 { margin-bottom: 32px; } .prepend-bottom-32 { margin-bottom: 32px; }
.ml-10 { margin-left: 4.5rem; }
.inline { display: inline-block; } .inline { display: inline-block; }
.center { text-align: center; } .center { text-align: center; }
.block { display: block; } .block { display: block; }
......
...@@ -23,7 +23,7 @@ You can click the chevron **{chevron-down}** next to the epic title to expand an ...@@ -23,7 +23,7 @@ You can click the chevron **{chevron-down}** next to the epic title to expand an
On top of the milestone bars, you can see their title. When you hover a milestone bar or title, a popover appears with its title, start date and due date. On top of the milestone bars, you can see their title. When you hover a milestone bar or title, a popover appears with its title, start date and due date.
![roadmap view](img/roadmap_view_v12_10.png) ![roadmap view](img/roadmap_view_v13_0.png)
A dropdown menu 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.
......
<script> <script>
import { delay } from 'lodash'; import { delay } from 'lodash';
import epicItemDetails from './epic_item_details.vue'; import EpicItemDetails from './epic_item_details.vue';
import epicItemTimeline from './epic_item_timeline.vue'; import EpicItemTimeline from './epic_item_timeline.vue';
import CommonMixin from '../mixins/common_mixin'; import CommonMixin from '../mixins/common_mixin';
...@@ -10,8 +10,8 @@ import { EPIC_HIGHLIGHT_REMOVE_AFTER } from '../constants'; ...@@ -10,8 +10,8 @@ import { EPIC_HIGHLIGHT_REMOVE_AFTER } from '../constants';
export default { export default {
components: { components: {
epicItemDetails, EpicItemDetails,
epicItemTimeline, EpicItemTimeline,
}, },
mixins: [CommonMixin], mixins: [CommonMixin],
props: { props: {
...@@ -36,6 +36,22 @@ export default { ...@@ -36,6 +36,22 @@ export default {
required: false, required: false,
default: 0, default: 0,
}, },
childLevel: {
type: Number,
required: true,
},
childrenEpics: {
type: Object,
required: true,
},
childrenFlags: {
type: Object,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
},
}, },
computed: { computed: {
/** /**
...@@ -59,6 +75,12 @@ export default { ...@@ -59,6 +75,12 @@ export default {
} }
return this.epic.endDate; return this.epic.endDate;
}, },
isChildrenEmpty() {
return this.childrenEpics[this.epic.id] && this.childrenEpics[this.epic.id].length === 0;
},
hasChildrenToShow() {
return this.childrenFlags[this.epic.id].itemExpanded && this.childrenEpics[this.epic.id];
},
}, },
updated() { updated() {
this.removeHighlight(); this.removeHighlight();
...@@ -89,20 +111,38 @@ export default { ...@@ -89,20 +111,38 @@ export default {
</script> </script>
<template> <template>
<div :class="{ 'newly-added-epic': epic.newEpic }" class="epics-list-item clearfix"> <div class="epic-item-container">
<epic-item-details <div :class="{ 'newly-added-epic': epic.newEpic }" class="epics-list-item clearfix">
:epic="epic" <epic-item-details
:current-group-id="currentGroupId" :epic="epic"
:timeframe-string="timeframeString(epic)" :current-group-id="currentGroupId"
/> :timeframe-string="timeframeString(epic)"
<epic-item-timeline :child-level="childLevel"
v-for="(timeframeItem, index) in timeframe" :children-flags="childrenFlags"
:key="index" :has-filters-applied="hasFiltersApplied"
:is-children-empty="isChildrenEmpty"
/>
<epic-item-timeline
v-for="(timeframeItem, index) in timeframe"
:key="index"
:preset-type="presetType"
:timeframe="timeframe"
:timeframe-item="timeframeItem"
:epic="epic"
:client-width="clientWidth"
/>
</div>
<epic-item-container
v-if="hasChildrenToShow"
:preset-type="presetType" :preset-type="presetType"
:timeframe="timeframe" :timeframe="timeframe"
:timeframe-item="timeframeItem" :current-group-id="currentGroupId"
:epic="epic"
:client-width="clientWidth" :client-width="clientWidth"
:children="childrenEpics[epic.id] || []"
:child-level="childLevel + 1"
:children-epics="childrenEpics"
:children-flags="childrenFlags"
:has-filters-applied="hasFiltersApplied"
/> />
</div> </div>
</template> </template>
<script>
import { generateKey } from '../utils/epic_utils';
export default {
props: {
presetType: {
type: String,
required: true,
},
timeframe: {
type: Array,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
clientWidth: {
type: Number,
required: false,
default: 0,
},
children: {
type: Array,
required: true,
},
childLevel: {
type: Number,
required: true,
},
childrenEpics: {
type: Object,
required: true,
},
childrenFlags: {
type: Object,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
},
},
methods: {
generateKey,
},
};
</script>
<template>
<div class="epic-list-item-container">
<epic-item
v-for="child in children"
:key="generateKey(child)"
:preset-type="presetType"
:epic="child"
:timeframe="timeframe"
:current-group-id="currentGroupId"
:client-width="clientWidth"
:child-level="childLevel"
:children-epics="childrenEpics"
:children-flags="childrenFlags"
:has-filters-applied="hasFiltersApplied"
/>
</div>
</template>
<script> <script>
import { GlButton, GlIcon, GlTooltip } from '@gitlab/ui'; import { GlButton, GlIcon, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { __, n__ } from '~/locale'; import { __, n__ } from '~/locale';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { EPIC_LEVEL_MARGIN } from '../constants';
export default { export default {
components: { components: {
GlButton, GlButton,
GlIcon, GlIcon,
GlLoadingIcon,
GlTooltip, GlTooltip,
}, },
props: { props: {
...@@ -22,69 +24,144 @@ export default { ...@@ -22,69 +24,144 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
childLevel: {
type: Number,
required: true,
},
childrenFlags: {
type: Object,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
},
isChildrenEmpty: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
itemId() {
return this.epic.id;
},
isEpicGroupDifferent() { isEpicGroupDifferent() {
return this.currentGroupId !== this.epic.groupId; return this.currentGroupId !== this.epic.groupId;
}, },
isExpandIconHidden() { isExpandIconHidden() {
return this.epic.isChildEpic || !this.epic.children?.edges?.length; return !this.epic.hasChildren;
},
isEmptyChildrenWithFilter() {
return (
this.childrenFlags[this.itemId].itemExpanded &&
this.hasFiltersApplied &&
this.isChildrenEmpty
);
}, },
expandIconName() { expandIconName() {
return this.epic.isChildEpicShowing ? 'chevron-down' : 'chevron-right'; if (this.isEmptyChildrenWithFilter) {
return 'information-o';
}
return this.childrenFlags[this.itemId].itemExpanded ? 'chevron-down' : 'chevron-right';
},
infoSearchLabel() {
return __('No child epics match applied filters');
}, },
expandIconLabel() { expandIconLabel() {
return this.epic.isChildEpicShowing ? __('Collapse child epics') : __('Expand child epics'); if (this.isEmptyChildrenWithFilter) {
return this.infoSearchLabel;
}
return this.childrenFlags[this.itemId].itemExpanded
? __('Collapse child epics')
: __('Expand child epics');
},
childrenFetchInProgress() {
return this.epic.hasChildren && this.childrenFlags[this.itemId].itemChildrenFetchInProgress;
}, },
childEpicsCount() { childEpicsCount() {
return this.epic.isChildEpic ? '-' : this.epic.children?.edges?.length || 0; const { openedEpics = 0, closedEpics = 0 } = this.epic.descendantCounts;
return openedEpics + closedEpics;
}, },
childEpicsCountText() { childEpicsCountText() {
return Number.isInteger(this.childEpicsCount) return Number.isInteger(this.childEpicsCount)
? n__(`%d child epic`, `%d child epics`, this.childEpicsCount) ? n__(`%d child epic`, `%d child epics`, this.childEpicsCount)
: ''; : '';
}, },
childEpicsSearchText() {
return __('Some child epics may be hidden due to applied filters');
},
childMarginClassname() {
return EPIC_LEVEL_MARGIN[this.childLevel];
},
}, },
methods: { methods: {
toggleIsEpicExpanded() { toggleIsEpicExpanded() {
eventHub.$emit('toggleIsEpicExpanded', this.epic.id); if (!this.isEmptyChildrenWithFilter) {
eventHub.$emit('toggleIsEpicExpanded', this.epic);
}
}, },
}, },
}; };
</script> </script>
<template> <template>
<div class="epic-details-cell d-flex align-items-start p-2" data-qa-selector="epic_details_cell"> <div class="epic-details-cell" data-qa-selector="epic_details_cell">
<gl-button
:class="{ invisible: isExpandIconHidden }"
variant="link"
:aria-label="expandIconLabel"
@click="toggleIsEpicExpanded"
>
<gl-icon :name="expandIconName" class="text-secondary" aria-hidden="true" />
</gl-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
ref="childEpicsCount" class="d-flex align-items-start p-2"
:class="{ invisible: epic.isChildEpic }" :class="[epic.isChildEpic ? childMarginClassname : '']"
class="d-flex text-secondary text-nowrap"
> >
<gl-icon name="epic" class="align-text-bottom mr-1" aria-hidden="true" /> <span ref="expandCollapseInfo">
<p class="m-0" :aria-label="childEpicsCountText">{{ childEpicsCount }}</p> <gl-button
:class="{ invisible: isExpandIconHidden }"
variant="link"
:aria-label="expandIconLabel"
@click="toggleIsEpicExpanded"
>
<gl-icon
v-if="!childrenFetchInProgress"
:name="expandIconName"
class="text-secondary"
aria-hidden="true"
/>
<gl-loading-icon v-if="childrenFetchInProgress" size="sm" />
</gl-button>
</span>
<gl-tooltip
v-if="isEmptyChildrenWithFilter"
:target="() => $refs.expandCollapseInfo"
boundary="viewport"
offset="80"
placement="topright"
>
{{ infoSearchLabel }}
</gl-tooltip>
<div class="overflow-hidden flex-grow-1 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 && !epic.hasParent"
:title="epic.groupFullName"
class="epic-group"
>
{{ epic.groupName }}
</p>
<span v-if="isEpicGroupDifferent && !epic.hasParent" class="mx-1" aria-hidden="true"
>&middot;</span
>
<p class="epic-timeframe" :title="timeframeString">{{ timeframeString }}</p>
</div>
</div>
<div ref="childEpicsCount" 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>
<gl-tooltip :target="() => $refs.childEpicsCount">
<span :class="{ bold: hasFiltersApplied }">{{ childEpicsCountText }}</span>
<span v-if="hasFiltersApplied" class="d-block">{{ childEpicsSearchText }}</span>
</gl-tooltip>
</div> </div>
<gl-tooltip v-if="!epic.isChildEpic" :target="() => $refs.childEpicsCount">
{{ childEpicsCountText }}
</gl-tooltip>
</div> </div>
</template> </template>
...@@ -37,6 +37,10 @@ export default { ...@@ -37,6 +37,10 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
hasFiltersApplied: {
type: Boolean,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -48,7 +52,7 @@ export default { ...@@ -48,7 +52,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['bufferSize']), ...mapState(['bufferSize', 'epicIid', 'childrenEpics', 'childrenFlags', 'epicIds']),
emptyRowContainerVisible() { emptyRowContainerVisible() {
return this.epics.length < this.bufferSize; return this.epics.length < this.bufferSize;
}, },
...@@ -62,15 +66,31 @@ export default { ...@@ -62,15 +66,31 @@ export default {
left: `${this.offsetLeft}px`, left: `${this.offsetLeft}px`,
}; };
}, },
displayedEpics() { findEpicsMatchingFilter() {
return this.epics.reduce((acc, epic) => {
if (!epic.hasParent || (epic.hasParent && this.epicIds.indexOf(epic.parent.id) < 0)) {
acc.push(epic);
}
return acc;
}, []);
},
findParentEpics() {
return this.epics.reduce((acc, epic) => { return this.epics.reduce((acc, epic) => {
acc.push(epic); if (!epic.hasParent) {
if (epic.isChildEpicShowing) { acc.push(epic);
acc.push(...epic.children.edges);
} }
return acc; return acc;
}, []); }, []);
}, },
displayedEpics() {
// If roadmap is accessed from epic, return all epics
if (this.epicIid) {
return this.epics;
}
// If a search is being performed, add child as parent if parent doesn't match the search
return this.hasFiltersApplied ? this.findEpicsMatchingFilter : this.findParentEpics;
},
}, },
mounted() { mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll); eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
...@@ -84,7 +104,7 @@ export default { ...@@ -84,7 +104,7 @@ export default {
window.removeEventListener('resize', this.syncClientWidth); window.removeEventListener('resize', this.syncClientWidth);
}, },
methods: { methods: {
...mapActions(['setBufferSize', 'toggleExpandedEpic']), ...mapActions(['setBufferSize', 'toggleEpic']),
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));
...@@ -141,8 +161,8 @@ export default { ...@@ -141,8 +161,8 @@ export default {
}, },
}; };
}, },
toggleIsEpicExpanded(epicId) { toggleIsEpicExpanded(epic) {
this.toggleExpandedEpic(epicId); this.toggleEpic({ parentItem: epic });
}, },
generateKey, generateKey,
}, },
...@@ -174,6 +194,10 @@ export default { ...@@ -174,6 +194,10 @@ export default {
:timeframe="timeframe" :timeframe="timeframe"
:current-group-id="currentGroupId" :current-group-id="currentGroupId"
:client-width="clientWidth" :client-width="clientWidth"
:child-level="0"
:children-epics="childrenEpics"
:children-flags="childrenFlags"
:has-filters-applied="hasFiltersApplied"
/> />
</template> </template>
<div <div
......
...@@ -136,6 +136,7 @@ export default { ...@@ -136,6 +136,7 @@ export default {
:milestones="milestones" :milestones="milestones"
:timeframe="timeframe" :timeframe="timeframe"
:current-group-id="currentGroupId" :current-group-id="currentGroupId"
:has-filters-applied="hasFiltersApplied"
@onScrollToStart="handleScrollToExtend" @onScrollToStart="handleScrollToExtend"
@onScrollToEnd="handleScrollToExtend" @onScrollToEnd="handleScrollToExtend"
/> />
......
...@@ -38,6 +38,10 @@ export default { ...@@ -38,6 +38,10 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
hasFiltersApplied: {
type: Boolean,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -112,6 +116,7 @@ export default { ...@@ -112,6 +116,7 @@ export default {
:epics="epics" :epics="epics"
:timeframe="timeframe" :timeframe="timeframe"
:current-group-id="currentGroupId" :current-group-id="currentGroupId"
:has-filters-applied="hasFiltersApplied"
/> />
</div> </div>
</template> </template>
...@@ -50,3 +50,10 @@ export const PRESET_DEFAULTS = { ...@@ -50,3 +50,10 @@ export const PRESET_DEFAULTS = {
export const PAST_DATE = new Date(new Date().getFullYear() - 100, 0, 1); export const PAST_DATE = new Date(new Date().getFullYear() - 100, 0, 1);
export const FUTURE_DATE = new Date(new Date().getFullYear() + 100, 0, 1); export const FUTURE_DATE = new Date(new Date().getFullYear() + 100, 0, 1);
export const EPIC_LEVEL_MARGIN = {
1: 'ml-4',
2: 'ml-6',
3: 'ml-8',
4: 'ml-10',
};
...@@ -8,27 +8,18 @@ fragment BaseEpic on Epic { ...@@ -8,27 +8,18 @@ fragment BaseEpic on Epic {
startDate startDate
dueDate dueDate
hasChildren hasChildren
hasParent
descendantWeightSum { descendantWeightSum {
closedIssues closedIssues
openedIssues openedIssues
} }
descendantCounts {
openedEpics
closedEpics
}
group { group {
name name
fullName fullName
}
}
fragment EpicNode on Epic {
...BaseEpic
state
reference(full: true)
createdAt
closedAt
relationPath
createdAt
hasChildren
hasIssues
group {
fullPath fullPath
} }
} }
...@@ -7,6 +7,9 @@ query epicChildEpics( ...@@ -7,6 +7,9 @@ query epicChildEpics(
$sort: EpicSort $sort: EpicSort
$startDate: Time $startDate: Time
$dueDate: Time $dueDate: Time
$labelName: [String!] = []
$authorUsername: String = ""
$search: String = ""
) { ) {
group(fullPath: $fullPath) { group(fullPath: $fullPath) {
id id
...@@ -15,32 +18,18 @@ query epicChildEpics( ...@@ -15,32 +18,18 @@ query epicChildEpics(
id id
title title
hasChildren hasChildren
children(state: $state, sort: $sort, startDate: $startDate, endDate: $dueDate) { children(
state: $state
sort: $sort
startDate: $startDate
endDate: $dueDate
labelName: $labelName
authorUsername: $authorUsername
search: $search
) {
edges { edges {
node { node {
id ...BaseEpic
title
description
state
webUrl
startDate
dueDate
hasChildren
descendantWeightSum {
closedIssues
openedIssues
}
group {
name
fullName
}
children {
edges {
node {
...EpicNode
}
}
}
} }
} }
} }
......
...@@ -25,13 +25,7 @@ query groupEpics( ...@@ -25,13 +25,7 @@ query groupEpics(
edges { edges {
node { node {
...BaseEpic ...BaseEpic
children { parent { id }
edges {
node {
...EpicNode
}
}
}
} }
} }
} }
......
...@@ -3,6 +3,9 @@ import { mapActions } from 'vuex'; ...@@ -3,6 +3,9 @@ import { mapActions } from 'vuex';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import EpicItem from './components/epic_item.vue';
import EpicItemContainer from './components/epic_item_container.vue';
import { import {
parseBoolean, parseBoolean,
urlParamsToObject, urlParamsToObject,
...@@ -38,6 +41,9 @@ export default () => { ...@@ -38,6 +41,9 @@ export default () => {
}); });
} }
Vue.component('epic-item', EpicItem);
Vue.component('epic-item-container', EpicItemContainer);
return new Vue({ return new Vue({
el, el,
store: createStore(), store: createStore(),
......
...@@ -65,10 +65,25 @@ const fetchGroupEpics = ( ...@@ -65,10 +65,25 @@ const fetchGroupEpics = (
}); });
}; };
export const fetchChildrenEpics = (state, { parentItem }) => {
const { iid } = parentItem;
const { fullPath, filterParams } = state;
return epicUtils.gqClient
.query({
query: epicChildEpics,
variables: { iid, fullPath, ...filterParams },
})
.then(({ data }) => {
const edges = data?.group?.epic?.children?.edges || [];
return epicUtils.extractGroupEpics(edges);
});
};
export const requestEpics = ({ commit }) => commit(types.REQUEST_EPICS); export const requestEpics = ({ commit }) => commit(types.REQUEST_EPICS);
export const requestEpicsForTimeframe = ({ commit }) => commit(types.REQUEST_EPICS_FOR_TIMEFRAME); export const requestEpicsForTimeframe = ({ commit }) => commit(types.REQUEST_EPICS_FOR_TIMEFRAME);
export const receiveEpicsSuccess = ( export const receiveEpicsSuccess = (
{ commit, state, getters }, { commit, dispatch, state, getters },
{ rawEpics, newEpic, timeframeExtended }, { rawEpics, newEpic, timeframeExtended },
) => { ) => {
const epics = rawEpics.reduce((filteredEpics, epic) => { const epics = rawEpics.reduce((filteredEpics, epic) => {
...@@ -79,21 +94,6 @@ export const receiveEpicsSuccess = ( ...@@ -79,21 +94,6 @@ export const receiveEpicsSuccess = (
); );
formattedEpic.isChildEpic = false; 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
...@@ -115,6 +115,7 @@ export const receiveEpicsSuccess = ( ...@@ -115,6 +115,7 @@ export const receiveEpicsSuccess = (
sortEpics(updatedEpics, state.sortedBy); sortEpics(updatedEpics, state.sortedBy);
commit(types.RECEIVE_EPICS_FOR_TIMEFRAME_SUCCESS, updatedEpics); commit(types.RECEIVE_EPICS_FOR_TIMEFRAME_SUCCESS, updatedEpics);
} else { } else {
dispatch('initItemChildrenFlags', { epics });
commit(types.RECEIVE_EPICS_SUCCESS, epics); commit(types.RECEIVE_EPICS_SUCCESS, epics);
} }
}; };
...@@ -123,6 +124,35 @@ export const receiveEpicsFailure = ({ commit }) => { ...@@ -123,6 +124,35 @@ export const receiveEpicsFailure = ({ commit }) => {
flash(s__('GroupRoadmap|Something went wrong while fetching epics')); flash(s__('GroupRoadmap|Something went wrong while fetching epics'));
}; };
export const requestChildrenEpics = ({ commit }, { parentItemId }) => {
commit(types.REQUEST_CHILDREN_EPICS, { parentItemId });
};
export const receiveChildrenSuccess = (
{ commit, dispatch, getters },
{ parentItemId, rawChildren },
) => {
const children = rawChildren.reduce((filteredChildren, epic) => {
const formattedChild = roadmapItemUtils.formatRoadmapItemDetails(
epic,
getters.timeframeStartDate,
getters.timeframeEndDate,
);
formattedChild.isChildEpic = true;
// Exclude any Epic that has invalid dates
if (formattedChild.startDate.getTime() <= formattedChild.endDate.getTime()) {
filteredChildren.push(formattedChild);
}
return filteredChildren;
}, []);
dispatch('expandEpic', {
parentItemId,
});
dispatch('initItemChildrenFlags', { epics: children });
commit(types.RECEIVE_CHILDREN_SUCCESS, { parentItemId, children });
};
export const fetchEpics = ({ state, dispatch }) => { export const fetchEpics = ({ state, dispatch }) => {
dispatch('requestEpics'); dispatch('requestEpics');
...@@ -168,6 +198,39 @@ export const extendTimeframe = ({ commit, state, getters }, { extendAs }) => { ...@@ -168,6 +198,39 @@ export const extendTimeframe = ({ commit, state, getters }, { extendAs }) => {
} }
}; };
export const initItemChildrenFlags = ({ commit }, data) =>
commit(types.INIT_EPIC_CHILDREN_FLAGS, data);
export const expandEpic = ({ commit }, { parentItemId }) =>
commit(types.EXPAND_EPIC, { parentItemId });
export const collapseEpic = ({ commit }, { parentItemId }) =>
commit(types.COLLAPSE_EPIC, { parentItemId });
export const toggleEpic = ({ state, dispatch }, { parentItem }) => {
const parentItemId = parentItem.id;
if (!state.childrenFlags[parentItemId].itemExpanded) {
if (!state.childrenEpics[parentItemId]) {
dispatch('requestChildrenEpics', { parentItemId });
fetchChildrenEpics(state, { parentItem })
.then(rawChildren => {
dispatch('receiveChildrenSuccess', {
parentItemId,
rawChildren,
});
})
.catch(() => dispatch('receiveEpicsFailure'));
} else {
dispatch('expandEpic', {
parentItemId,
});
}
} else {
dispatch('collapseEpic', {
parentItemId,
});
}
};
/** /**
* For epics that have no start or end date, this function updates their start and end dates * 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. * so that the epic bars get longer to appear infinitely scrolling.
...@@ -284,8 +347,5 @@ export const refreshMilestoneDates = ({ commit, state, getters }) => { ...@@ -284,8 +347,5 @@ 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 () => {};
...@@ -13,6 +13,12 @@ export const RECEIVE_EPICS_FOR_TIMEFRAME_SUCCESS = 'RECEIVE_EPICS_FOR_TIMEFRAME_ ...@@ -13,6 +13,12 @@ export const RECEIVE_EPICS_FOR_TIMEFRAME_SUCCESS = 'RECEIVE_EPICS_FOR_TIMEFRAME_
export const RECEIVE_EPICS_FAILURE = 'RECEIVE_EPICS_FAILURE'; export const RECEIVE_EPICS_FAILURE = 'RECEIVE_EPICS_FAILURE';
export const REQUEST_CHILDREN_EPICS = 'REQUEST_CHILDREN_EPICS';
export const RECEIVE_CHILDREN_SUCCESS = 'RECEIVE_CHILDREN_SUCCESS';
export const INIT_EPIC_CHILDREN_FLAGS = 'INIT_EPIC_CHILDREN_FLAGS';
export const EXPAND_EPIC = 'EXPAND_EPIC';
export const COLLAPSE_EPIC = 'COLLAPSE_EPIC';
export const PREPEND_TIMEFRAME = 'PREPEND_TIMEFRAME'; export const PREPEND_TIMEFRAME = 'PREPEND_TIMEFRAME';
export const APPEND_TIMEFRAME = 'APPEND_TIMEFRAME'; export const APPEND_TIMEFRAME = 'APPEND_TIMEFRAME';
...@@ -24,5 +30,3 @@ export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS'; ...@@ -24,5 +30,3 @@ 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';
import Vue from 'vue';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
...@@ -40,6 +42,35 @@ export default { ...@@ -40,6 +42,35 @@ export default {
state.epicsFetchInProgress = false; state.epicsFetchInProgress = false;
state.epicsFetchForTimeframeInProgress = false; state.epicsFetchForTimeframeInProgress = false;
state.epicsFetchFailure = true; state.epicsFetchFailure = true;
Object.keys(state.childrenEpics).forEach(id => {
Vue.set(state.childrenFlags, id, {
itemChildrenFetchInProgress: false,
});
});
},
[types.REQUEST_CHILDREN_EPICS](state, { parentItemId }) {
state.childrenFlags[parentItemId].itemChildrenFetchInProgress = true;
},
[types.RECEIVE_CHILDREN_SUCCESS](state, { parentItemId, children }) {
Vue.set(state.childrenEpics, parentItemId, children);
state.childrenFlags[parentItemId].itemChildrenFetchInProgress = false;
},
[types.INIT_EPIC_CHILDREN_FLAGS](state, { epics }) {
epics.forEach(item => {
Vue.set(state.childrenFlags, item.id, {
itemExpanded: false,
itemChildrenFetchInProgress: false,
});
});
},
[types.EXPAND_EPIC](state, { parentItemId }) {
state.childrenFlags[parentItemId].itemExpanded = true;
},
[types.COLLAPSE_EPIC](state, { parentItemId }) {
state.childrenFlags[parentItemId].itemExpanded = false;
}, },
[types.PREPEND_TIMEFRAME](state, extendedTimeframe) { [types.PREPEND_TIMEFRAME](state, extendedTimeframe) {
...@@ -76,9 +107,4 @@ export default { ...@@ -76,9 +107,4 @@ 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;
},
}; };
...@@ -9,6 +9,8 @@ export default () => ({ ...@@ -9,6 +9,8 @@ export default () => ({
// Data // Data
epicIid: '', epicIid: '',
epics: [], epics: [],
childrenEpics: {},
childrenFlags: {},
visibleEpics: [], visibleEpics: [],
epicIds: [], epicIds: [],
currentGroupId: -1, currentGroupId: -1,
......
---
title: Add hierarchy depth to roadmaps
merge_request: 29105
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')).to have_content('Child epic B') expect(find('.epic-item-container:nth-child(1) .epics-list-item .epic-title')).to have_content('Child epic B')
expect(find('.epics-list-item:nth-child(2) .epic-title')).to have_content('Child epic A') expect(find('.epic-item-container:nth-child(2) .epics-list-item .epic-title')).to have_content('Child epic A')
end end
end end
end end
......
...@@ -139,15 +139,15 @@ describe 'epics list', :js do ...@@ -139,15 +139,15 @@ describe 'epics list', :js do
page.within('.content-wrapper .content') do page.within('.content-wrapper .content') do
page.within('.epics-list-section') do page.within('.epics-list-section') do
page.within('div.epics-list-item:nth-child(1)') do page.within('div.epic-item-container:nth-child(1) div.epics-list-item') do
expect(page).to have_content(epic1.title) expect(page).to have_content(epic1.title)
end end
page.within('div.epics-list-item:nth-child(2)') do page.within('div.epic-item-container:nth-child(2) div.epics-list-item') do
expect(page).to have_content(epic3.title) expect(page).to have_content(epic3.title)
end end
page.within('div.epics-list-item:nth-child(3)') do page.within('div.epic-item-container:nth-child(3) div.epics-list-item') do
expect(page).to have_content(epic2.title) expect(page).to have_content(epic2.title)
end end
end end
......
import { mount } from '@vue/test-utils';
import EpicItem from 'ee/roadmap/components/epic_item.vue';
import EpicItemContainer from 'ee/roadmap/components/epic_item_container.vue';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import {
mockTimeframeInitialDate,
mockGroupId,
mockFormattedChildEpic1,
} from 'ee_jest/roadmap/mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId,
children = [],
childLevel = 0,
childrenEpics = {},
childrenFlags = { '1': { itemExpanded: false } },
hasFiltersApplied = false,
} = {}) => {
return mount(EpicItemContainer, {
stubs: {
'epic-item': EpicItem,
},
propsData: {
presetType,
timeframe,
currentGroupId,
children,
childLevel,
childrenEpics,
childrenFlags,
hasFiltersApplied,
},
});
};
describe('EpicItemContainer', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders epic list container', () => {
expect(wrapper.classes('epic-list-item-container')).toBe(true);
});
it('renders one Epic item element per child', () => {
wrapper = createComponent({
children: [mockFormattedChildEpic1],
childrenFlags: {
'1': { itemExpanded: true },
'50': { itemExpanded: false },
},
});
expect(wrapper.find(EpicItem).exists()).toBe(true);
expect(wrapper.findAll(EpicItem).length).toBe(wrapper.vm.children.length);
});
});
});
...@@ -9,16 +9,24 @@ import { ...@@ -9,16 +9,24 @@ import {
mockFormattedChildEpic1, mockFormattedChildEpic1,
} from 'ee_jest/roadmap/mock_data'; } from 'ee_jest/roadmap/mock_data';
const createComponent = ( const createComponent = ({
epic = mockFormattedEpic, epic = mockFormattedEpic,
currentGroupId = mockGroupId, currentGroupId = mockGroupId,
timeframeString = 'Jul 10, 2017 – Jun 2, 2018', timeframeString = 'Jul 10, 2017 – Jun 2, 2018',
) => { childLevel = 0,
childrenFlags = { '41': { itemExpanded: false } },
hasFiltersApplied = false,
isChildrenEmpty = false,
} = {}) => {
return shallowMount(EpicItemDetails, { return shallowMount(EpicItemDetails, {
propsData: { propsData: {
epic, epic,
currentGroupId, currentGroupId,
timeframeString, timeframeString,
childLevel,
childrenFlags,
hasFiltersApplied,
isChildrenEmpty,
}, },
}); });
}; };
...@@ -34,16 +42,16 @@ const getChildEpicsCount = wrapper => wrapper.find({ ref: 'childEpicsCount' }); ...@@ -34,16 +42,16 @@ const getChildEpicsCount = wrapper => wrapper.find({ ref: 'childEpicsCount' });
describe('EpicItemDetails', () => { describe('EpicItemDetails', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); });
describe('epic title', () => { describe('epic title', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('is displayed', () => { it('is displayed', () => {
expect(getTitle(wrapper).text()).toBe(mockFormattedEpic.title); expect(getTitle(wrapper).text()).toBe(mockFormattedEpic.title);
}); });
...@@ -59,13 +67,18 @@ describe('EpicItemDetails', () => { ...@@ -59,13 +67,18 @@ describe('EpicItemDetails', () => {
beforeEach(() => { beforeEach(() => {
epic = { epic = {
id: '41',
mockFormattedEpic, mockFormattedEpic,
groupId: 1, groupId: 1,
groupName: 'Bar', groupName: 'Bar',
groupFullName: 'Foo / Bar', groupFullName: 'Foo / Bar',
descendantCounts: {
closedIssues: 3,
openedIssues: 2,
},
}; };
wrapper = createComponent(epic, 2); wrapper.setProps({ epic, currentGroupId: 2 });
}); });
it('is displayed', () => { it('is displayed', () => {
...@@ -88,7 +101,7 @@ describe('EpicItemDetails', () => { ...@@ -88,7 +101,7 @@ describe('EpicItemDetails', () => {
groupFullName: 'Foo / Bar', groupFullName: 'Foo / Bar',
}; };
wrapper = createComponent(epic, 1); wrapper.setProps({ epic, currentGroupId: 1 });
}); });
it('is hidden', () => { it('is hidden', () => {
...@@ -99,17 +112,32 @@ describe('EpicItemDetails', () => { ...@@ -99,17 +112,32 @@ describe('EpicItemDetails', () => {
describe('timeframe', () => { describe('timeframe', () => {
it('is displayed', () => { it('is displayed', () => {
wrapper = createComponent();
const timeframe = wrapper.find('.epic-timeframe'); const timeframe = wrapper.find('.epic-timeframe');
expect(timeframe.text()).toBe('Jul 10, 2017 – Jun 2, 2018'); expect(timeframe.text()).toBe('Jul 10, 2017 – Jun 2, 2018');
}); });
}); });
describe('childMarginClassname', () => {
it('childMarginClassname returns class for level 1 child is childLevel is 1', () => {
wrapper.setProps({ childLevel: 1 });
expect(wrapper.vm.childMarginClassname).toEqual('ml-4');
});
it('childMarginClassname returns class for level 2 child is childLevel is 2', () => {
wrapper.setProps({ childLevel: 2 });
expect(wrapper.vm.childMarginClassname).toEqual('ml-6');
});
});
describe('epic', () => { describe('epic', () => {
describe('expand icon', () => { describe('expand icon', () => {
it('is hidden when epic has no child epics', () => { it('is hidden when epic has no child epics', () => {
wrapper = createComponent(); const epic = {
...mockFormattedEpic,
hasChildren: false,
};
wrapper = createComponent({ epic });
expect(getExpandIconButton(wrapper).classes()).toContain('invisible'); expect(getExpandIconButton(wrapper).classes()).toContain('invisible');
}); });
...@@ -117,11 +145,12 @@ describe('EpicItemDetails', () => { ...@@ -117,11 +145,12 @@ describe('EpicItemDetails', () => {
it('is shown when epic has child epics', () => { it('is shown when epic has child epics', () => {
const epic = { const epic = {
...mockFormattedEpic, ...mockFormattedEpic,
hasChildren: true,
children: { children: {
edges: [mockFormattedChildEpic1], edges: [mockFormattedChildEpic1],
}, },
}; };
wrapper = createComponent(epic); wrapper = createComponent({ epic });
expect(getExpandIconButton(wrapper).classes()).not.toContain('invisible'); expect(getExpandIconButton(wrapper).classes()).not.toContain('invisible');
}); });
...@@ -135,13 +164,35 @@ describe('EpicItemDetails', () => { ...@@ -135,13 +164,35 @@ describe('EpicItemDetails', () => {
it('shows "chevron-down" icon when child epics are expanded', () => { it('shows "chevron-down" icon when child epics are expanded', () => {
const epic = { const epic = {
...mockFormattedEpic, ...mockFormattedEpic,
isChildEpicShowing: true, hasChildren: true,
}; };
wrapper = createComponent(epic); wrapper = createComponent({
epic,
childrenFlags: {
'41': { itemExpanded: true },
},
});
expect(wrapper.find(GlIcon).attributes('name')).toBe('chevron-down'); expect(wrapper.find(GlIcon).attributes('name')).toBe('chevron-down');
}); });
it('shows "information-o" icon when child epics are expanded but no children are returned due to applied filters', () => {
const epic = {
...mockFormattedEpic,
hasChildren: true,
};
wrapper = createComponent({
epic,
childrenFlags: {
'41': { itemExpanded: true },
},
hasFiltersApplied: true,
isChildrenEmpty: true,
});
expect(wrapper.find(GlIcon).attributes('name')).toBe('information-o');
});
it('has "Expand child epics" label when child epics are not expanded', () => { it('has "Expand child epics" label when child epics are not expanded', () => {
wrapper = createComponent(); wrapper = createComponent();
...@@ -151,17 +202,41 @@ describe('EpicItemDetails', () => { ...@@ -151,17 +202,41 @@ describe('EpicItemDetails', () => {
it('has "Collapse child epics" label when child epics are expanded', () => { it('has "Collapse child epics" label when child epics are expanded', () => {
const epic = { const epic = {
...mockFormattedEpic, ...mockFormattedEpic,
isChildEpicShowing: true, hasChildren: true,
}; };
wrapper = createComponent(epic); wrapper = createComponent({
epic,
childrenFlags: {
'41': { itemExpanded: true },
},
});
expect(getExpandIconButton(wrapper).attributes('aria-label')).toBe('Collapse child epics'); expect(getExpandIconButton(wrapper).attributes('aria-label')).toBe('Collapse child epics');
}); });
it('has "No child epics match applied filters" label when child epics are expanded', () => {
const epic = {
...mockFormattedEpic,
hasChildren: true,
};
wrapper = createComponent({
epic,
childrenFlags: {
'41': { itemExpanded: true },
},
hasFiltersApplied: true,
isChildrenEmpty: true,
});
expect(getExpandIconButton(wrapper).attributes('aria-label')).toBe(
'No child epics match applied filters',
);
});
it('emits toggleIsEpicExpanded event when clicked', () => { it('emits toggleIsEpicExpanded event when clicked', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
const id = 42; const id = 41;
const epic = { const epic = {
...mockFormattedEpic, ...mockFormattedEpic,
id, id,
...@@ -169,11 +244,11 @@ describe('EpicItemDetails', () => { ...@@ -169,11 +244,11 @@ describe('EpicItemDetails', () => {
edges: [mockFormattedChildEpic1], edges: [mockFormattedChildEpic1],
}, },
}; };
wrapper = createComponent(epic); wrapper = createComponent({ epic });
getExpandIconButton(wrapper).vm.$emit('click'); getExpandIconButton(wrapper).vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('toggleIsEpicExpanded', id); expect(eventHub.$emit).toHaveBeenCalledWith('toggleIsEpicExpanded', epic);
}); });
it('is hidden when it is child epic', () => { it('is hidden when it is child epic', () => {
...@@ -181,7 +256,7 @@ describe('EpicItemDetails', () => { ...@@ -181,7 +256,7 @@ describe('EpicItemDetails', () => {
...mockFormattedEpic, ...mockFormattedEpic,
isChildEpic: true, isChildEpic: true,
}; };
wrapper = createComponent(epic); wrapper = createComponent({ epic });
expect(getExpandIconButton(wrapper).classes()).toContain('invisible'); expect(getExpandIconButton(wrapper).classes()).toContain('invisible');
}); });
...@@ -194,14 +269,25 @@ describe('EpicItemDetails', () => { ...@@ -194,14 +269,25 @@ describe('EpicItemDetails', () => {
children: { children: {
edges: [mockFormattedChildEpic1, mockFormattedChildEpic2], edges: [mockFormattedChildEpic1, mockFormattedChildEpic2],
}, },
descendantCounts: {
openedEpics: 0,
closedEpics: 2,
},
}; };
wrapper = createComponent(epic); wrapper = createComponent({ epic });
expect(getChildEpicsCount(wrapper).text()).toBe('2'); expect(getChildEpicsCount(wrapper).text()).toBe('2');
}); });
it('shows the count as 0 when there are no child epics', () => { it('shows the count as 0 when there are no child epics', () => {
wrapper = createComponent(); const epic = {
...mockFormattedEpic,
descendantCounts: {
openedEpics: 0,
closedEpics: 0,
},
};
wrapper = createComponent({ epic });
expect(getChildEpicsCount(wrapper).text()).toBe('0'); expect(getChildEpicsCount(wrapper).text()).toBe('0');
}); });
...@@ -212,20 +298,32 @@ describe('EpicItemDetails', () => { ...@@ -212,20 +298,32 @@ describe('EpicItemDetails', () => {
children: { children: {
edges: [mockFormattedChildEpic1], edges: [mockFormattedChildEpic1],
}, },
descendantCounts: {
openedEpics: 0,
closedEpics: 1,
},
}; };
wrapper = createComponent(epic); wrapper = createComponent({ epic });
expect(wrapper.find(GlTooltip).text()).toBe('1 child epic'); expect(wrapper.find(GlTooltip).text()).toBe('1 child epic');
}); });
it('is hidden when it is a child epic', () => { it('has a tooltip with the count and explanation if search is being performed', () => {
const epic = { const epic = {
...mockFormattedEpic, ...mockFormattedEpic,
isChildEpic: true, children: {
edges: [mockFormattedChildEpic1],
},
descendantCounts: {
openedEpics: 0,
closedEpics: 1,
},
}; };
wrapper = createComponent(epic); wrapper = createComponent({ epic, hasFiltersApplied: true });
expect(getChildEpicsCount(wrapper).classes()).toContain('invisible'); expect(wrapper.find(GlTooltip).text()).toBe(
'1 child epic Some child epics may be hidden due to applied filters',
);
}); });
}); });
}); });
......
import Vue from 'vue'; import { mount } from '@vue/test-utils';
import _ from 'lodash'; import { delay } from 'lodash';
import epicItemComponent from 'ee/roadmap/components/epic_item.vue'; import EpicItemComponent from 'ee/roadmap/components/epic_item.vue';
import EpicItemContainer from 'ee/roadmap/components/epic_item_container.vue';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils'; import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants'; import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'helpers/vue_mount_component_helper'; import {
import { mockTimeframeInitialDate, mockEpic, mockGroupId } from 'ee_jest/roadmap/mock_data'; mockTimeframeInitialDate,
mockEpic,
mockGroupId,
mockFormattedChildEpic1,
} from 'ee_jest/roadmap/mock_data';
jest.mock('lodash/delay', () => jest.mock('lodash/delay', () =>
jest.fn(func => { jest.fn(func => {
...@@ -26,31 +31,43 @@ const createComponent = ({ ...@@ -26,31 +31,43 @@ const createComponent = ({
epic = mockEpic, epic = mockEpic,
timeframe = mockTimeframeMonths, timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId, currentGroupId = mockGroupId,
childLevel = 0,
childrenEpics = {},
childrenFlags = { '1': { itemExpanded: false } },
hasFiltersApplied = false,
}) => { }) => {
const Component = Vue.extend(epicItemComponent); return mount(EpicItemComponent, {
stubs: {
return mountComponent(Component, { 'epic-item-container': EpicItemContainer,
presetType, 'epic-item': EpicItemComponent,
epic, },
timeframe, propsData: {
currentGroupId, presetType,
epic,
timeframe,
currentGroupId,
childLevel,
childrenEpics,
childrenFlags,
hasFiltersApplied,
},
}); });
}; };
describe('EpicItemComponent', () => { describe('EpicItemComponent', () => {
let vm; let wrapper;
beforeEach(() => { beforeEach(() => {
vm = createComponent({}); wrapper = createComponent({});
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
}); });
describe('startDate', () => { describe('startDate', () => {
it('returns Epic.startDate when start date is within range', () => { it('returns Epic.startDate when start date is within range', () => {
expect(vm.startDate).toBe(mockEpic.startDate); expect(wrapper.vm.startDate).toBe(mockEpic.startDate);
}); });
it('returns Epic.originalStartDate when start date is out of range', () => { it('returns Epic.originalStartDate when start date is out of range', () => {
...@@ -59,15 +76,15 @@ describe('EpicItemComponent', () => { ...@@ -59,15 +76,15 @@ describe('EpicItemComponent', () => {
startDateOutOfRange: true, startDateOutOfRange: true,
originalStartDate: mockStartDate, originalStartDate: mockStartDate,
}); });
vm = createComponent({ epic }); wrapper = createComponent({ epic });
expect(vm.startDate).toBe(mockStartDate); expect(wrapper.vm.startDate).toBe(mockStartDate);
}); });
}); });
describe('endDate', () => { describe('endDate', () => {
it('returns Epic.endDate when end date is within range', () => { it('returns Epic.endDate when end date is within range', () => {
expect(vm.endDate).toBe(mockEpic.endDate); expect(wrapper.vm.endDate).toBe(mockEpic.endDate);
}); });
it('returns Epic.originalEndDate when end date is out of range', () => { it('returns Epic.originalEndDate when end date is out of range', () => {
...@@ -76,33 +93,33 @@ describe('EpicItemComponent', () => { ...@@ -76,33 +93,33 @@ describe('EpicItemComponent', () => {
endDateOutOfRange: true, endDateOutOfRange: true,
originalEndDate: mockEndDate, originalEndDate: mockEndDate,
}); });
vm = createComponent({ epic }); wrapper = createComponent({ epic });
expect(vm.endDate).toBe(mockEndDate); expect(wrapper.vm.endDate).toBe(mockEndDate);
}); });
}); });
describe('timeframeString', () => { describe('timeframeString', () => {
it('returns timeframe string correctly when both start and end dates are defined', () => { it('returns timeframe string correctly when both start and end dates are defined', () => {
expect(vm.timeframeString(mockEpic)).toBe('Jul 10, 2017 – Jun 2, 2018'); expect(wrapper.vm.timeframeString(mockEpic)).toBe('Jul 10, 2017 – Jun 2, 2018');
}); });
it('returns timeframe string correctly when only start date is defined', () => { it('returns timeframe string correctly when only start date is defined', () => {
const epic = Object.assign({}, mockEpic, { const epic = Object.assign({}, mockEpic, {
endDateUndefined: true, endDateUndefined: true,
}); });
vm = createComponent({ epic }); wrapper = createComponent({ epic });
expect(vm.timeframeString(epic)).toBe('Jul 10, 2017 – No end date'); expect(wrapper.vm.timeframeString(epic)).toBe('Jul 10, 2017 – No end date');
}); });
it('returns timeframe string correctly when only end date is defined', () => { it('returns timeframe string correctly when only end date is defined', () => {
const epic = Object.assign({}, mockEpic, { const epic = Object.assign({}, mockEpic, {
startDateUndefined: true, startDateUndefined: true,
}); });
vm = createComponent({ epic }); wrapper = createComponent({ epic });
expect(vm.timeframeString(epic)).toBe('No start date – Jun 2, 2018'); expect(wrapper.vm.timeframeString(epic)).toBe('No start date – Jun 2, 2018');
}); });
it('returns timeframe string with hidden year for start date when both start and end dates are from same year', () => { it('returns timeframe string with hidden year for start date when both start and end dates are from same year', () => {
...@@ -110,40 +127,59 @@ describe('EpicItemComponent', () => { ...@@ -110,40 +127,59 @@ describe('EpicItemComponent', () => {
startDate: new Date(2018, 0, 1), startDate: new Date(2018, 0, 1),
endDate: new Date(2018, 3, 1), endDate: new Date(2018, 3, 1),
}); });
vm = createComponent({ epic }); wrapper = createComponent({ epic });
expect(vm.timeframeString(epic)).toBe('Jan 1 – Apr 1, 2018'); expect(wrapper.vm.timeframeString(epic)).toBe('Jan 1 – Apr 1, 2018');
}); });
}); });
describe('methods', () => { describe('methods', () => {
describe('removeHighlight', () => { describe('removeHighlight', () => {
it('should call _.delay after 3 seconds with a callback function which would set `epic.newEpic` to false when it is true already', done => { it('should wait 3 seconds before toggling `epic.newEpic` from true to false', () => {
vm.epic.newEpic = true; wrapper.setProps({
epic: {
vm.removeHighlight(); ...wrapper.vm.epic,
newEpic: true,
vm.$nextTick() },
.then(() => { });
expect(_.delay).toHaveBeenCalledWith(expect.any(Function), 3000);
}) wrapper.vm.removeHighlight();
.then(done)
.catch(done.fail); return wrapper.vm.$nextTick().then(() => {
expect(delay).toHaveBeenCalledWith(expect.any(Function), 3000);
});
}); });
}); });
}); });
describe('template', () => { describe('template', () => {
it('renders component container element class `epics-list-item`', () => { it('renders Epic item container', () => {
expect(vm.$el.classList.contains('epics-list-item')).toBeTruthy(); expect(wrapper.find('.epics-list-item').exists()).toBe(true);
}); });
it('renders Epic item details element with class `epic-details-cell`', () => { it('renders Epic item details element with class `epic-details-cell`', () => {
expect(vm.$el.querySelector('.epic-details-cell')).not.toBeNull(); expect(wrapper.find('.epic-details-cell').exists()).toBe(true);
}); });
it('renders Epic timeline element with class `epic-timeline-cell`', () => { it('renders Epic timeline element with class `epic-timeline-cell`', () => {
expect(vm.$el.querySelector('.epic-timeline-cell')).not.toBeNull(); expect(wrapper.find('.epic-timeline-cell').exists()).toBe(true);
});
it('does not render Epic item container element with class `epic-list-item-container` if epic is not expanded', () => {
expect(wrapper.find('.epic-list-item-container').exists()).toBe(false);
});
it('renders Epic item container element with class `epic-list-item-container` if epic has children and is expanded', () => {
wrapper = createComponent({
childrenEpics: {
'1': [mockFormattedChildEpic1],
},
childrenFlags: {
'1': { itemExpanded: true },
'50': { itemExpanded: false },
},
});
expect(wrapper.find('.epic-list-item-container').exists()).toBe(true);
}); });
}); });
}); });
...@@ -36,9 +36,8 @@ store.dispatch('receiveEpicsSuccess', { rawEpics }); ...@@ -36,9 +36,8 @@ store.dispatch('receiveEpicsSuccess', { rawEpics });
const mockEpics = store.state.epics; const mockEpics = store.state.epics;
store.state.epics[0].children = { store.state.childrenEpics[mockEpics[0].id] = [mockFormattedChildEpic1, mockFormattedChildEpic2];
edges: [mockFormattedChildEpic1, mockFormattedChildEpic2],
};
const localVue = createLocalVue(); const localVue = createLocalVue();
const createComponent = ({ const createComponent = ({
...@@ -47,6 +46,7 @@ const createComponent = ({ ...@@ -47,6 +46,7 @@ const createComponent = ({
currentGroupId = mockGroupId, currentGroupId = mockGroupId,
presetType = PRESET_TYPES.MONTHS, presetType = PRESET_TYPES.MONTHS,
roadmapBufferedRendering = true, roadmapBufferedRendering = true,
hasFiltersApplied = false,
} = {}) => { } = {}) => {
return shallowMount(EpicsListSection, { return shallowMount(EpicsListSection, {
localVue, localVue,
...@@ -60,6 +60,7 @@ const createComponent = ({ ...@@ -60,6 +60,7 @@ const createComponent = ({
epics, epics,
timeframe, timeframe,
currentGroupId, currentGroupId,
hasFiltersApplied,
}, },
provide: { provide: {
glFeatures: { roadmapBufferedRendering }, glFeatures: { roadmapBufferedRendering },
...@@ -117,6 +118,28 @@ describe('EpicsListSectionComponent', () => { ...@@ -117,6 +118,28 @@ describe('EpicsListSectionComponent', () => {
expect(wrapper.vm.shadowCellStyles.left).toBe('0px'); expect(wrapper.vm.shadowCellStyles.left).toBe('0px');
}); });
}); });
describe('displayedEpics', () => {
beforeAll(() => {
store.state.epicIds = ['1', '2', '3'];
});
it('returns findParentEpics method by default', () => {
expect(wrapper.vm.displayedEpics).toEqual(wrapper.vm.findParentEpics);
});
it('returns findEpicsMatchingFilter method if filtered is applied', () => {
wrapper.setProps({
hasFiltersApplied: true,
});
expect(wrapper.vm.displayedEpics).toEqual(wrapper.vm.findEpicsMatchingFilter);
});
it('returns all epics if epicIid is specified', () => {
store.state.epicIid = '23';
expect(wrapper.vm.displayedEpics).toEqual(mockEpics);
});
});
}); });
describe('methods', () => { describe('methods', () => {
...@@ -276,21 +299,15 @@ describe('EpicsListSectionComponent', () => { ...@@ -276,21 +299,15 @@ describe('EpicsListSectionComponent', () => {
}); });
}); });
it('expands to show child epics when epic is toggled', done => { it('expands to show child epics when epic is toggled', () => {
const epic = mockEpics[0]; const epic = mockEpics[0];
expect(wrapper.findAll(EpicItem)).toHaveLength(mockEpics.length); expect(store.state.childrenFlags[epic.id].itemExpanded).toBe(false);
wrapper.vm.toggleIsEpicExpanded(epic.id);
wrapper.vm wrapper.vm.toggleIsEpicExpanded(epic);
.$nextTick()
.then(() => {
const expected = mockEpics.length + epic.children.edges.length;
expect(wrapper.findAll(EpicItem)).toHaveLength(expected); return wrapper.vm.$nextTick().then(() => {
}) expect(store.state.childrenFlags[epic.id].itemExpanded).toBe(true);
.then(done) });
.catch(done.fail);
}); });
}); });
...@@ -24,6 +24,7 @@ const createComponent = ( ...@@ -24,6 +24,7 @@ const createComponent = (
timeframe = mockTimeframeMonths, timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId, currentGroupId = mockGroupId,
defaultInnerHeight = 0, defaultInnerHeight = 0,
hasFiltersApplied = false,
}, },
el, el,
) => { ) => {
...@@ -32,6 +33,7 @@ const createComponent = ( ...@@ -32,6 +33,7 @@ const createComponent = (
const store = createStore(); const store = createStore();
store.dispatch('setInitialData', { store.dispatch('setInitialData', {
defaultInnerHeight, defaultInnerHeight,
childrenFlags: { '1': { itemExpanded: false } },
}); });
return mountComponentWithStore(Component, { return mountComponentWithStore(Component, {
...@@ -43,6 +45,7 @@ const createComponent = ( ...@@ -43,6 +45,7 @@ const createComponent = (
milestones, milestones,
timeframe, timeframe,
currentGroupId, currentGroupId,
hasFiltersApplied,
}, },
}); });
}; };
......
...@@ -18,6 +18,11 @@ export const mockSvgPath = '/foo/bar.svg'; ...@@ -18,6 +18,11 @@ export const mockSvgPath = '/foo/bar.svg';
export const mockTimeframeInitialDate = new Date(2018, 0, 1); export const mockTimeframeInitialDate = new Date(2018, 0, 1);
const defaultDescendantCounts = {
openedEpics: 0,
closedEpics: 0,
};
export const mockTimeframeQuartersPrepend = [ export const mockTimeframeQuartersPrepend = [
{ {
year: 2016, year: 2016,
...@@ -103,6 +108,10 @@ export const mockEpic = { ...@@ -103,6 +108,10 @@ export const mockEpic = {
originalStartDate: new Date('2017-07-10'), originalStartDate: new Date('2017-07-10'),
endDate: new Date('2018-06-02'), endDate: new Date('2018-06-02'),
webUrl: '/groups/gitlab-org/-/epics/1', webUrl: '/groups/gitlab-org/-/epics/1',
descendantCounts: {
openedEpics: 3,
closedEpics: 2,
},
}; };
export const mockRawEpic = { export const mockRawEpic = {
...@@ -116,6 +125,10 @@ export const mockRawEpic = { ...@@ -116,6 +125,10 @@ export const mockRawEpic = {
start_date: '2017-6-26', start_date: '2017-6-26',
end_date: '2018-03-10', end_date: '2018-03-10',
web_url: '/groups/gitlab-org/marketing/-/epics/2', web_url: '/groups/gitlab-org/marketing/-/epics/2',
descendantCounts: {
openedEpics: 3,
closedEpics: 2,
},
}; };
export const mockFormattedChildEpic1 = { export const mockFormattedChildEpic1 = {
...@@ -138,6 +151,7 @@ export const mockFormattedChildEpic1 = { ...@@ -138,6 +151,7 @@ export const mockFormattedChildEpic1 = {
closedIssues: 3, closedIssues: 3,
openedIssues: 2, openedIssues: 2,
}, },
descendantCounts: defaultDescendantCounts,
isChildEpic: true, isChildEpic: true,
}; };
...@@ -184,8 +198,11 @@ export const mockFormattedEpic = { ...@@ -184,8 +198,11 @@ export const mockFormattedEpic = {
closedIssues: 3, closedIssues: 3,
openedIssues: 2, openedIssues: 2,
}, },
descendantCounts: {
openedEpics: 3,
closedEpics: 2,
},
isChildEpic: false, isChildEpic: false,
isChildEpicShowing: false,
}; };
export const rawEpics = [ export const rawEpics = [
...@@ -200,6 +217,11 @@ export const rawEpics = [ ...@@ -200,6 +217,11 @@ export const rawEpics = [
start_date: '2017-12-26', start_date: '2017-12-26',
end_date: '2018-03-10', end_date: '2018-03-10',
web_url: '/groups/gitlab-org/marketing/-/epics/2', web_url: '/groups/gitlab-org/marketing/-/epics/2',
descendantCounts: defaultDescendantCounts,
hasParent: true,
parent: {
id: '40',
},
}, },
{ {
id: 40, id: 40,
...@@ -212,6 +234,8 @@ export const rawEpics = [ ...@@ -212,6 +234,8 @@ export const rawEpics = [
start_date: '2017-12-25', start_date: '2017-12-25',
end_date: '2018-03-09', end_date: '2018-03-09',
web_url: '/groups/gitlab-org/marketing/-/epics/1', web_url: '/groups/gitlab-org/marketing/-/epics/1',
descendantCounts: defaultDescendantCounts,
hasParent: false,
}, },
{ {
id: 39, id: 39,
...@@ -224,6 +248,8 @@ export const rawEpics = [ ...@@ -224,6 +248,8 @@ export const rawEpics = [
start_date: '2017-04-02', start_date: '2017-04-02',
end_date: '2017-11-30', end_date: '2017-11-30',
web_url: '/groups/gitlab-org/-/epics/12', web_url: '/groups/gitlab-org/-/epics/12',
descendantCounts: defaultDescendantCounts,
hasParent: false,
}, },
{ {
id: 38, id: 38,
...@@ -236,6 +262,8 @@ export const rawEpics = [ ...@@ -236,6 +262,8 @@ export const rawEpics = [
start_date: '2018-01-15', start_date: '2018-01-15',
end_date: '2020-01-03', end_date: '2020-01-03',
web_url: '/groups/gitlab-org/-/epics/11', web_url: '/groups/gitlab-org/-/epics/11',
descendantCounts: defaultDescendantCounts,
hasParent: false,
}, },
{ {
id: 37, id: 37,
...@@ -248,6 +276,8 @@ export const rawEpics = [ ...@@ -248,6 +276,8 @@ export const rawEpics = [
start_date: '2018-01-01', start_date: '2018-01-01',
end_date: '2018-01-31', end_date: '2018-01-31',
web_url: '/groups/gitlab-org/-/epics/10', web_url: '/groups/gitlab-org/-/epics/10',
descendantCounts: defaultDescendantCounts,
hasParent: false,
}, },
{ {
id: 35, id: 35,
...@@ -260,6 +290,8 @@ export const rawEpics = [ ...@@ -260,6 +290,8 @@ export const rawEpics = [
start_date: '2017-09-04', start_date: '2017-09-04',
end_date: null, end_date: null,
web_url: '/groups/gitlab-org/-/epics/8', web_url: '/groups/gitlab-org/-/epics/8',
descendantCounts: defaultDescendantCounts,
hasParent: false,
}, },
{ {
id: 33, id: 33,
...@@ -272,6 +304,8 @@ export const rawEpics = [ ...@@ -272,6 +304,8 @@ export const rawEpics = [
start_date: '2017-11-27', start_date: '2017-11-27',
end_date: null, end_date: null,
web_url: '/groups/gitlab-org/-/epics/6', web_url: '/groups/gitlab-org/-/epics/6',
descendantCounts: defaultDescendantCounts,
hasParent: false,
}, },
{ {
id: 4, id: 4,
...@@ -285,6 +319,8 @@ export const rawEpics = [ ...@@ -285,6 +319,8 @@ export const rawEpics = [
start_date: '2018-01-01', start_date: '2018-01-01',
end_date: '2018-02-02', end_date: '2018-02-02',
web_url: '/groups/gitlab-org/-/epics/4', web_url: '/groups/gitlab-org/-/epics/4',
descendantCounts: defaultDescendantCounts,
hasParent: false,
}, },
{ {
id: 3, id: 3,
...@@ -298,6 +334,11 @@ export const rawEpics = [ ...@@ -298,6 +334,11 @@ export const rawEpics = [
start_date: '2017-12-01', start_date: '2017-12-01',
end_date: '2018-03-26', end_date: '2018-03-26',
web_url: '/groups/gitlab-org/-/epics/3', web_url: '/groups/gitlab-org/-/epics/3',
descendantCounts: defaultDescendantCounts,
hasParent: true,
parent: {
id: '40',
},
}, },
{ {
id: 2, id: 2,
...@@ -311,6 +352,8 @@ export const rawEpics = [ ...@@ -311,6 +352,8 @@ export const rawEpics = [
start_date: '2017-11-26', start_date: '2017-11-26',
end_date: '2018-03-22', end_date: '2018-03-22',
web_url: '/groups/gitlab-org/-/epics/2', web_url: '/groups/gitlab-org/-/epics/2',
descendantCounts: defaultDescendantCounts,
hasParent: false,
}, },
{ {
id: 1, id: 1,
...@@ -325,6 +368,8 @@ export const rawEpics = [ ...@@ -325,6 +368,8 @@ export const rawEpics = [
start_date: '2017-07-10', start_date: '2017-07-10',
end_date: '2018-06-02', end_date: '2018-06-02',
web_url: '/groups/gitlab-org/-/epics/1', web_url: '/groups/gitlab-org/-/epics/1',
descendantCounts: defaultDescendantCounts,
hasParent: false,
}, },
{ {
id: 22, id: 22,
...@@ -337,6 +382,8 @@ export const rawEpics = [ ...@@ -337,6 +382,8 @@ export const rawEpics = [
start_date: '2018-12-26', start_date: '2018-12-26',
end_date: '2018-03-10', end_date: '2018-03-10',
web_url: '/groups/gitlab-org/marketing/-/epics/22', web_url: '/groups/gitlab-org/marketing/-/epics/22',
descendantCounts: defaultDescendantCounts,
hasParent: false,
}, },
]; ];
...@@ -443,6 +490,20 @@ export const mockEpicChildEpicsQueryResponse = { ...@@ -443,6 +490,20 @@ export const mockEpicChildEpicsQueryResponse = {
}, },
}; };
export const mockEpicChildEpicsQueryResponseFormatted = {
data: {
group: {
id: 'gid://gitlab/Group/2',
name: 'Gitlab Org',
epic: {
id: 'gid://gitlab/Epic/1',
title: 'Error omnis quos consequatur',
children: [mockFormattedChildEpic1, mockFormattedChildEpic2],
},
},
},
};
export const rawMilestones = [ export const rawMilestones = [
{ {
id: 'gid://gitlab/Milestone/40', id: 'gid://gitlab/Milestone/40',
......
...@@ -24,6 +24,7 @@ import { ...@@ -24,6 +24,7 @@ import {
mockGroupEpicsQueryResponse, mockGroupEpicsQueryResponse,
mockGroupEpicsQueryResponseFormatted, mockGroupEpicsQueryResponseFormatted,
mockGroupMilestonesQueryResponse, mockGroupMilestonesQueryResponse,
mockEpicChildEpicsQueryResponse,
rawMilestones, rawMilestones,
mockMilestone, mockMilestone,
mockFormattedMilestone, mockFormattedMilestone,
...@@ -112,6 +113,10 @@ describe('Roadmap Vuex Actions', () => { ...@@ -112,6 +113,10 @@ describe('Roadmap Vuex Actions', () => {
closedIssues: 3, closedIssues: 3,
openedIssues: 2, openedIssues: 2,
}, },
descendantCounts: {
openedEpics: 3,
closedEpics: 2,
},
}), }),
], ],
}, },
...@@ -132,7 +137,23 @@ describe('Roadmap Vuex Actions', () => { ...@@ -132,7 +137,23 @@ describe('Roadmap Vuex Actions', () => {
], ],
}, },
], ],
[], [
{
type: 'initItemChildrenFlags',
payload: {
epics: [
Object.assign({}, mockFormattedEpic, {
startDateOutOfRange: false,
endDateOutOfRange: false,
startDate: new Date(2017, 11, 31),
originalStartDate: new Date(2017, 11, 31),
endDate: new Date(2018, 1, 15),
originalEndDate: new Date(2018, 1, 15),
}),
],
},
},
],
); );
}); });
...@@ -337,6 +358,246 @@ describe('Roadmap Vuex Actions', () => { ...@@ -337,6 +358,246 @@ describe('Roadmap Vuex Actions', () => {
}); });
}); });
describe('requestChildrenEpics', () => {
const parentItemId = '41';
it('should set `itemChildrenFetchInProgress` in childrenFlags for parentItem to true', () => {
return testAction(
actions.requestChildrenEpics,
{ parentItemId },
state,
[{ type: 'REQUEST_CHILDREN_EPICS', payload: { parentItemId } }],
[],
);
});
});
describe('receiveChildrenSuccess', () => {
it('should set formatted epic children array in state based on provided epic children list', () => {
return testAction(
actions.receiveChildrenSuccess,
{
parentItemId: '41',
rawChildren: [
Object.assign({}, mockRawEpic, {
start_date: '2017-12-31',
end_date: '2018-2-15',
descendantWeightSum: {
closedIssues: 3,
openedIssues: 2,
},
descendantCounts: {
openedEpics: 3,
closedEpics: 2,
},
}),
],
},
state,
[
{
type: types.RECEIVE_CHILDREN_SUCCESS,
payload: {
parentItemId: '41',
children: [
Object.assign({}, mockFormattedEpic, {
startDateOutOfRange: false,
endDateOutOfRange: false,
startDate: new Date(2017, 11, 31),
originalStartDate: new Date(2017, 11, 31),
endDate: new Date(2018, 1, 15),
originalEndDate: new Date(2018, 1, 15),
isChildEpic: true,
}),
],
},
},
],
[
{
type: 'expandEpic',
payload: { parentItemId: '41' },
},
{
type: 'initItemChildrenFlags',
payload: {
epics: [
Object.assign({}, mockFormattedEpic, {
startDateOutOfRange: false,
endDateOutOfRange: false,
startDate: new Date(2017, 11, 31),
originalStartDate: new Date(2017, 11, 31),
endDate: new Date(2018, 1, 15),
originalEndDate: new Date(2018, 1, 15),
isChildEpic: true,
}),
],
},
},
],
);
});
});
describe('initItemChildrenFlags', () => {
it('should set `state.childrenFlags` for every item in provided children param', () => {
testAction(
actions.initItemChildrenFlags,
{ children: [{ id: '1' }] },
{},
[{ type: types.INIT_EPIC_CHILDREN_FLAGS, payload: { children: [{ id: '1' }] } }],
[],
);
});
});
describe('expandEpic', () => {
const parentItemId = '41';
it('should set `itemExpanded` to true on state.childrenFlags', () => {
testAction(
actions.expandEpic,
{ parentItemId },
{},
[{ type: types.EXPAND_EPIC, payload: { parentItemId } }],
[],
);
});
});
describe('collapseEpic', () => {
const parentItemId = '41';
it('should set `itemExpanded` to false on state.childrenFlags', () => {
testAction(
actions.collapseEpic,
{ parentItemId },
{},
[{ type: types.COLLAPSE_EPIC, payload: { parentItemId } }],
[],
);
});
});
describe('toggleEpic', () => {
const parentItem = mockFormattedEpic;
it('should dispatch `requestChildrenEpics` action when parent is not expanded and does not have children in state', () => {
state.childrenFlags[parentItem.id] = {
itemExpanded: false,
};
testAction(
actions.toggleEpic,
{ parentItem },
state,
[],
[
{
type: 'requestChildrenEpics',
payload: { parentItemId: parentItem.id },
},
],
);
});
it('should dispatch `receiveChildrenSuccess` on request success', () => {
jest.spyOn(epicUtils.gqClient, 'query').mockReturnValue(
Promise.resolve({
data: mockEpicChildEpicsQueryResponse.data,
}),
);
state.childrenFlags[parentItem.id] = {
itemExpanded: false,
};
const children = epicUtils.extractGroupEpics(
mockEpicChildEpicsQueryResponse.data.group.epic.children.edges,
);
testAction(
actions.toggleEpic,
{ parentItem },
state,
[],
[
{
type: 'requestChildrenEpics',
payload: { parentItemId: parentItem.id },
},
{
type: 'receiveChildrenSuccess',
payload: {
parentItemId: parentItem.id,
rawChildren: children,
},
},
],
);
});
it('should dispatch `receiveEpicsFailure` on request failure', () => {
jest.spyOn(epicUtils.gqClient, 'query').mockReturnValue(Promise.reject());
state.childrenFlags[parentItem.id] = {
itemExpanded: false,
};
testAction(
actions.toggleEpic,
{ parentItem },
state,
[],
[
{
type: 'requestChildrenEpics',
payload: { parentItemId: parentItem.id },
},
{
type: 'receiveEpicsFailure',
},
],
);
});
it('should dispatch `expandEpic` when a parent item is not expanded but does have children present in state', () => {
state.childrenFlags[parentItem.id] = {
itemExpanded: false,
};
state.childrenEpics[parentItem.id] = ['foo'];
testAction(
actions.toggleEpic,
{ parentItem },
state,
[],
[
{
type: 'expandEpic',
payload: { parentItemId: parentItem.id },
},
],
);
});
it('should dispatch `collapseEpic` when a parent item is expanded', () => {
state.childrenFlags[parentItem.id] = {
itemExpanded: true,
};
testAction(
actions.toggleEpic,
{ parentItem },
state,
[],
[
{
type: 'collapseEpic',
payload: { parentItemId: parentItem.id },
},
],
);
});
});
describe('setBufferSize', () => { describe('setBufferSize', () => {
it('should set bufferSize in store state', () => { it('should set bufferSize in store state', () => {
return testAction( return testAction(
...@@ -511,17 +772,4 @@ describe('Roadmap Vuex Actions', () => { ...@@ -511,17 +772,4 @@ 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,8 +5,6 @@ import defaultState from 'ee/roadmap/store/state'; ...@@ -5,8 +5,6 @@ import defaultState from 'ee/roadmap/store/state';
import { mockGroupId, basePath, epicsPath, mockSortedBy } from 'ee_jest/roadmap/mock_data'; import { mockGroupId, basePath, epicsPath, mockSortedBy } from 'ee_jest/roadmap/mock_data';
const getEpic = (epicId, epics) => epics.find(e => e.id === epicId);
describe('Roadmap Store Mutations', () => { describe('Roadmap Store Mutations', () => {
let state; let state;
...@@ -116,6 +114,77 @@ describe('Roadmap Store Mutations', () => { ...@@ -116,6 +114,77 @@ describe('Roadmap Store Mutations', () => {
}); });
}); });
describe('REQUEST_CHILDREN_EPICS', () => {
const parentItemId = '1';
it('should set `itemChildrenFetchInProgress` to true for provided `parentItem` param within state.childrenFlags', () => {
state.childrenFlags[parentItemId] = {};
mutations[types.REQUEST_CHILDREN_EPICS](state, { parentItemId });
expect(state.childrenFlags[parentItemId]).toHaveProperty('itemChildrenFetchInProgress', true);
});
});
describe('RECEIVE_CHILDREN_SUCCESS', () => {
const parentItemId = '1';
const children = [{ id: 1 }, { id: 2 }];
it('should set provided `children` and `itemChildrenFetchInProgress` to false for provided `parentItem` param within state.childrenFlags', () => {
state.childrenFlags[parentItemId] = {};
mutations[types.RECEIVE_CHILDREN_SUCCESS](state, { parentItemId, children });
expect(state.childrenEpics[parentItemId]).toEqual(children);
expect(state.childrenFlags[parentItemId]).toHaveProperty(
'itemChildrenFetchInProgress',
false,
);
});
});
describe('INIT_EPIC_CHILDREN_FLAGS', () => {
it('should set flags in `state.childrenFlags` for each epic', () => {
const epics = [
{
id: '1',
},
{
id: '2',
},
];
mutations[types.INIT_EPIC_CHILDREN_FLAGS](state, { epics });
epics.forEach(item => {
expect(state.childrenFlags[item.id]).toMatchObject({
itemExpanded: false,
itemChildrenFetchInProgress: false,
});
});
});
});
describe('EXPAND_EPIC', () => {
it('should toggle collapsed epic to an expanded epic', () => {
const parentItemId = '1';
state.childrenFlags[parentItemId] = {};
mutations[types.EXPAND_EPIC](state, { parentItemId });
expect(state.childrenFlags[parentItemId]).toHaveProperty('itemExpanded', true);
});
});
describe('COLLAPSE_EPIC', () => {
it('should toggle expanded epic to a collapsed epic', () => {
const parentItemId = '2';
state.childrenFlags[parentItemId] = {};
mutations[types.COLLAPSE_EPIC](state, { parentItemId });
expect(state.childrenFlags[parentItemId]).toHaveProperty('itemExpanded', false);
});
});
describe('PREPEND_TIMEFRAME', () => { describe('PREPEND_TIMEFRAME', () => {
it('Should set extendedTimeframe to provided extendedTimeframe param and prepend it to timeframe array in state', () => { it('Should set extendedTimeframe to provided extendedTimeframe param and prepend it to timeframe array in state', () => {
state.timeframe.push('foo'); state.timeframe.push('foo');
...@@ -197,30 +266,4 @@ describe('Roadmap Store Mutations', () => { ...@@ -197,30 +266,4 @@ 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);
});
});
}); });
...@@ -13760,6 +13760,9 @@ msgstr "" ...@@ -13760,6 +13760,9 @@ msgstr ""
msgid "No changes between %{ref_start}%{source_branch}%{ref_end} and %{ref_start}%{target_branch}%{ref_end}" msgid "No changes between %{ref_start}%{source_branch}%{ref_end} and %{ref_start}%{target_branch}%{ref_end}"
msgstr "" msgstr ""
msgid "No child epics match applied filters"
msgstr ""
msgid "No connection could be made to a Gitaly Server, please check your logs!" msgid "No connection could be made to a Gitaly Server, please check your logs!"
msgstr "" msgstr ""
...@@ -19181,6 +19184,9 @@ msgstr "" ...@@ -19181,6 +19184,9 @@ msgstr ""
msgid "Snowplow" msgid "Snowplow"
msgstr "" msgstr ""
msgid "Some child epics may be hidden due to applied filters"
msgstr ""
msgid "Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead." msgid "Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead."
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