Commit 5d7a7b11 authored by Florie Guibert's avatar Florie Guibert Committed by Kushal Pandya

Add hierarchy depth to roadmaps

- Epic tree view on roadmap
- Async loading of children epic
- Tree view when filter is applied
parent f92e0749
......@@ -434,6 +434,7 @@ img.emoji {
.append-bottom-20 { margin-bottom: 20px; }
.append-bottom-default { margin-bottom: $gl-padding; }
.prepend-bottom-32 { margin-bottom: 32px; }
.ml-10 { margin-left: 4.5rem; }
.inline { display: inline-block; }
.center { text-align: center; }
.block { display: block; }
......
......@@ -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.
![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.
......
<script>
import { delay } from 'lodash';
import epicItemDetails from './epic_item_details.vue';
import epicItemTimeline from './epic_item_timeline.vue';
import EpicItemDetails from './epic_item_details.vue';
import EpicItemTimeline from './epic_item_timeline.vue';
import CommonMixin from '../mixins/common_mixin';
......@@ -10,8 +10,8 @@ import { EPIC_HIGHLIGHT_REMOVE_AFTER } from '../constants';
export default {
components: {
epicItemDetails,
epicItemTimeline,
EpicItemDetails,
EpicItemTimeline,
},
mixins: [CommonMixin],
props: {
......@@ -36,6 +36,22 @@ export default {
required: false,
default: 0,
},
childLevel: {
type: Number,
required: true,
},
childrenEpics: {
type: Object,
required: true,
},
childrenFlags: {
type: Object,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
},
},
computed: {
/**
......@@ -59,6 +75,12 @@ export default {
}
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() {
this.removeHighlight();
......@@ -89,20 +111,38 @@ export default {
</script>
<template>
<div :class="{ 'newly-added-epic': epic.newEpic }" class="epics-list-item clearfix">
<epic-item-details
:epic="epic"
:current-group-id="currentGroupId"
:timeframe-string="timeframeString(epic)"
/>
<epic-item-timeline
v-for="(timeframeItem, index) in timeframe"
:key="index"
<div class="epic-item-container">
<div :class="{ 'newly-added-epic': epic.newEpic }" class="epics-list-item clearfix">
<epic-item-details
:epic="epic"
:current-group-id="currentGroupId"
:timeframe-string="timeframeString(epic)"
:child-level="childLevel"
:children-flags="childrenFlags"
: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"
:timeframe="timeframe"
:timeframe-item="timeframeItem"
:epic="epic"
:current-group-id="currentGroupId"
:client-width="clientWidth"
:children="childrenEpics[epic.id] || []"
:child-level="childLevel + 1"
:children-epics="childrenEpics"
:children-flags="childrenFlags"
:has-filters-applied="hasFiltersApplied"
/>
</div>
</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>
import { GlButton, GlIcon, GlTooltip } from '@gitlab/ui';
import { GlButton, GlIcon, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { __, n__ } from '~/locale';
import eventHub from '../event_hub';
import { EPIC_LEVEL_MARGIN } from '../constants';
export default {
components: {
GlButton,
GlIcon,
GlLoadingIcon,
GlTooltip,
},
props: {
......@@ -22,69 +24,144 @@ export default {
type: String,
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: {
itemId() {
return this.epic.id;
},
isEpicGroupDifferent() {
return this.currentGroupId !== this.epic.groupId;
},
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() {
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() {
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() {
return this.epic.isChildEpic ? '-' : this.epic.children?.edges?.length || 0;
const { openedEpics = 0, closedEpics = 0 } = this.epic.descendantCounts;
return openedEpics + closedEpics;
},
childEpicsCountText() {
return Number.isInteger(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: {
toggleIsEpicExpanded() {
eventHub.$emit('toggleIsEpicExpanded', this.epic.id);
if (!this.isEmptyChildrenWithFilter) {
eventHub.$emit('toggleIsEpicExpanded', this.epic);
}
},
},
};
</script>
<template>
<div class="epic-details-cell d-flex align-items-start p-2" 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 class="epic-details-cell" data-qa-selector="epic_details_cell">
<div
ref="childEpicsCount"
:class="{ invisible: epic.isChildEpic }"
class="d-flex text-secondary text-nowrap"
class="d-flex align-items-start p-2"
:class="[epic.isChildEpic ? childMarginClassname : '']"
>
<gl-icon name="epic" class="align-text-bottom mr-1" aria-hidden="true" />
<p class="m-0" :aria-label="childEpicsCountText">{{ childEpicsCount }}</p>
<span ref="expandCollapseInfo">
<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>
<gl-tooltip v-if="!epic.isChildEpic" :target="() => $refs.childEpicsCount">
{{ childEpicsCountText }}
</gl-tooltip>
</div>
</template>
......@@ -37,6 +37,10 @@ export default {
type: Number,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
},
},
data() {
return {
......@@ -48,7 +52,7 @@ export default {
};
},
computed: {
...mapState(['bufferSize']),
...mapState(['bufferSize', 'epicIid', 'childrenEpics', 'childrenFlags', 'epicIds']),
emptyRowContainerVisible() {
return this.epics.length < this.bufferSize;
},
......@@ -62,15 +66,31 @@ export default {
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) => {
acc.push(epic);
if (epic.isChildEpicShowing) {
acc.push(...epic.children.edges);
if (!epic.hasParent) {
acc.push(epic);
}
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() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
......@@ -84,7 +104,7 @@ export default {
window.removeEventListener('resize', this.syncClientWidth);
},
methods: {
...mapActions(['setBufferSize', 'toggleExpandedEpic']),
...mapActions(['setBufferSize', 'toggleEpic']),
initMounted() {
this.roadmapShellEl = this.$root.$el && this.$root.$el.firstChild;
this.setBufferSize(Math.ceil((window.innerHeight - this.$el.offsetTop) / EPIC_ITEM_HEIGHT));
......@@ -141,8 +161,8 @@ export default {
},
};
},
toggleIsEpicExpanded(epicId) {
this.toggleExpandedEpic(epicId);
toggleIsEpicExpanded(epic) {
this.toggleEpic({ parentItem: epic });
},
generateKey,
},
......@@ -174,6 +194,10 @@ export default {
:timeframe="timeframe"
:current-group-id="currentGroupId"
:client-width="clientWidth"
:child-level="0"
:children-epics="childrenEpics"
:children-flags="childrenFlags"
:has-filters-applied="hasFiltersApplied"
/>
</template>
<div
......
......@@ -136,6 +136,7 @@ export default {
:milestones="milestones"
:timeframe="timeframe"
:current-group-id="currentGroupId"
:has-filters-applied="hasFiltersApplied"
@onScrollToStart="handleScrollToExtend"
@onScrollToEnd="handleScrollToExtend"
/>
......
......@@ -38,6 +38,10 @@ export default {
type: Number,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
},
},
data() {
return {
......@@ -112,6 +116,7 @@ export default {
:epics="epics"
:timeframe="timeframe"
:current-group-id="currentGroupId"
:has-filters-applied="hasFiltersApplied"
/>
</div>
</template>
......@@ -50,3 +50,10 @@ export const PRESET_DEFAULTS = {
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 EPIC_LEVEL_MARGIN = {
1: 'ml-4',
2: 'ml-6',
3: 'ml-8',
4: 'ml-10',
};
......@@ -8,27 +8,18 @@ fragment BaseEpic on Epic {
startDate
dueDate
hasChildren
hasParent
descendantWeightSum {
closedIssues
openedIssues
}
descendantCounts {
openedEpics
closedEpics
}
group {
name
fullName
}
}
fragment EpicNode on Epic {
...BaseEpic
state
reference(full: true)
createdAt
closedAt
relationPath
createdAt
hasChildren
hasIssues
group {
fullPath
}
}
......@@ -7,6 +7,9 @@ query epicChildEpics(
$sort: EpicSort
$startDate: Time
$dueDate: Time
$labelName: [String!] = []
$authorUsername: String = ""
$search: String = ""
) {
group(fullPath: $fullPath) {
id
......@@ -15,32 +18,18 @@ query epicChildEpics(
id
title
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 {
node {
id
title
description
state
webUrl
startDate
dueDate
hasChildren
descendantWeightSum {
closedIssues
openedIssues
}
group {
name
fullName
}
children {
edges {
node {
...EpicNode
}
}
}
...BaseEpic
}
}
}
......
......@@ -25,13 +25,7 @@ query groupEpics(
edges {
node {
...BaseEpic
children {
edges {
node {
...EpicNode
}
}
}
parent { id }
}
}
}
......
......@@ -3,6 +3,9 @@ import { mapActions } from 'vuex';
import Translate from '~/vue_shared/translate';
import EpicItem from './components/epic_item.vue';
import EpicItemContainer from './components/epic_item_container.vue';
import {
parseBoolean,
urlParamsToObject,
......@@ -38,6 +41,9 @@ export default () => {
});
}
Vue.component('epic-item', EpicItem);
Vue.component('epic-item-container', EpicItemContainer);
return new Vue({
el,
store: createStore(),
......
......@@ -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 requestEpicsForTimeframe = ({ commit }) => commit(types.REQUEST_EPICS_FOR_TIMEFRAME);
export const receiveEpicsSuccess = (
{ commit, state, getters },
{ commit, dispatch, state, getters },
{ rawEpics, newEpic, timeframeExtended },
) => {
const epics = rawEpics.reduce((filteredEpics, epic) => {
......@@ -79,21 +94,6 @@ export const receiveEpicsSuccess = (
);
formattedEpic.isChildEpic = false;
formattedEpic.isChildEpicShowing = false;
// Format child epics
if (formattedEpic.children?.edges?.length > 0) {
formattedEpic.children.edges = formattedEpic.children.edges
.map(epicUtils.flattenGroupProperty)
.map(epicUtils.addIsChildEpicTrueProperty)
.map(childEpic =>
roadmapItemUtils.formatRoadmapItemDetails(
childEpic,
getters.timeframeStartDate,
getters.timeframeEndDate,
),
);
}
// Exclude any Epic that has invalid dates
// or is already present in Roadmap timeline
......@@ -115,6 +115,7 @@ export const receiveEpicsSuccess = (
sortEpics(updatedEpics, state.sortedBy);
commit(types.RECEIVE_EPICS_FOR_TIMEFRAME_SUCCESS, updatedEpics);
} else {
dispatch('initItemChildrenFlags', { epics });
commit(types.RECEIVE_EPICS_SUCCESS, epics);
}
};
......@@ -123,6 +124,35 @@ export const receiveEpicsFailure = ({ commit }) => {
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 }) => {
dispatch('requestEpics');
......@@ -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
* so that the epic bars get longer to appear infinitely scrolling.
......@@ -284,8 +347,5 @@ export const refreshMilestoneDates = ({ commit, state, getters }) => {
export const setBufferSize = ({ commit }, bufferSize) => commit(types.SET_BUFFER_SIZE, bufferSize);
export const toggleExpandedEpic = ({ commit }, epicId) =>
commit(types.TOGGLE_EXPANDED_EPIC, epicId);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -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 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 APPEND_TIMEFRAME = 'APPEND_TIMEFRAME';
......@@ -24,5 +30,3 @@ export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE';
export const SET_BUFFER_SIZE = 'SET_BUFFER_SIZE';
export const TOGGLE_EXPANDED_EPIC = 'TOGGLE_EXPANDED_EPIC';
import Vue from 'vue';
import * as types from './mutation_types';
export default {
......@@ -40,6 +42,35 @@ export default {
state.epicsFetchInProgress = false;
state.epicsFetchForTimeframeInProgress = false;
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) {
......@@ -76,9 +107,4 @@ export default {
[types.SET_BUFFER_SIZE](state, bufferSize) {
state.bufferSize = bufferSize;
},
[types.TOGGLE_EXPANDED_EPIC](state, epicId) {
const epic = state.epics.find(e => e.id === epicId);
epic.isChildEpicShowing = !epic.isChildEpicShowing;
},
};
......@@ -9,6 +9,8 @@ export default () => ({
// Data
epicIid: '',
epics: [],
childrenEpics: {},
childrenFlags: {},
visibleEpics: [],
epicIds: [],
currentGroupId: -1,
......
---
title: Add hierarchy depth to roadmaps
merge_request: 29105
author:
type: added
......@@ -72,8 +72,8 @@ describe 'Epic show', :js do
page.within('.roadmap-shell .epics-list-section') do
expect(page).not_to have_content(not_child.title)
expect(find('.epics-list-item:nth-child(1) .epic-title')).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(1) .epics-list-item .epic-title')).to have_content('Child epic B')
expect(find('.epic-item-container:nth-child(2) .epics-list-item .epic-title')).to have_content('Child epic A')
end
end
end
......
......@@ -139,15 +139,15 @@ describe 'epics list', :js do
page.within('.content-wrapper .content') 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)
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)
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)
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 {
mockFormattedChildEpic1,
} from 'ee_jest/roadmap/mock_data';
const createComponent = (
const createComponent = ({
epic = mockFormattedEpic,
currentGroupId = mockGroupId,
timeframeString = 'Jul 10, 2017 – Jun 2, 2018',
) => {
childLevel = 0,
childrenFlags = { '41': { itemExpanded: false } },
hasFiltersApplied = false,
isChildrenEmpty = false,
} = {}) => {
return shallowMount(EpicItemDetails, {
propsData: {
epic,
currentGroupId,
timeframeString,
childLevel,
childrenFlags,
hasFiltersApplied,
isChildrenEmpty,
},
});
};
......@@ -34,16 +42,16 @@ const getChildEpicsCount = wrapper => wrapper.find({ ref: 'childEpicsCount' });
describe('EpicItemDetails', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('epic title', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('is displayed', () => {
expect(getTitle(wrapper).text()).toBe(mockFormattedEpic.title);
});
......@@ -59,13 +67,18 @@ describe('EpicItemDetails', () => {
beforeEach(() => {
epic = {
id: '41',
mockFormattedEpic,
groupId: 1,
groupName: 'Bar',
groupFullName: 'Foo / Bar',
descendantCounts: {
closedIssues: 3,
openedIssues: 2,
},
};
wrapper = createComponent(epic, 2);
wrapper.setProps({ epic, currentGroupId: 2 });
});
it('is displayed', () => {
......@@ -88,7 +101,7 @@ describe('EpicItemDetails', () => {
groupFullName: 'Foo / Bar',
};
wrapper = createComponent(epic, 1);
wrapper.setProps({ epic, currentGroupId: 1 });
});
it('is hidden', () => {
......@@ -99,17 +112,32 @@ describe('EpicItemDetails', () => {
describe('timeframe', () => {
it('is displayed', () => {
wrapper = createComponent();
const timeframe = wrapper.find('.epic-timeframe');
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('expand icon', () => {
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');
});
......@@ -117,11 +145,12 @@ describe('EpicItemDetails', () => {
it('is shown when epic has child epics', () => {
const epic = {
...mockFormattedEpic,
hasChildren: true,
children: {
edges: [mockFormattedChildEpic1],
},
};
wrapper = createComponent(epic);
wrapper = createComponent({ epic });
expect(getExpandIconButton(wrapper).classes()).not.toContain('invisible');
});
......@@ -135,13 +164,35 @@ describe('EpicItemDetails', () => {
it('shows "chevron-down" icon when child epics are expanded', () => {
const epic = {
...mockFormattedEpic,
isChildEpicShowing: true,
hasChildren: true,
};
wrapper = createComponent(epic);
wrapper = createComponent({
epic,
childrenFlags: {
'41': { itemExpanded: true },
},
});
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', () => {
wrapper = createComponent();
......@@ -151,17 +202,41 @@ describe('EpicItemDetails', () => {
it('has "Collapse child epics" label when child epics are expanded', () => {
const epic = {
...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');
});
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', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
const id = 42;
const id = 41;
const epic = {
...mockFormattedEpic,
id,
......@@ -169,11 +244,11 @@ describe('EpicItemDetails', () => {
edges: [mockFormattedChildEpic1],
},
};
wrapper = createComponent(epic);
wrapper = createComponent({ epic });
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', () => {
......@@ -181,7 +256,7 @@ describe('EpicItemDetails', () => {
...mockFormattedEpic,
isChildEpic: true,
};
wrapper = createComponent(epic);
wrapper = createComponent({ epic });
expect(getExpandIconButton(wrapper).classes()).toContain('invisible');
});
......@@ -194,14 +269,25 @@ describe('EpicItemDetails', () => {
children: {
edges: [mockFormattedChildEpic1, mockFormattedChildEpic2],
},
descendantCounts: {
openedEpics: 0,
closedEpics: 2,
},
};
wrapper = createComponent(epic);
wrapper = createComponent({ epic });
expect(getChildEpicsCount(wrapper).text()).toBe('2');
});
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');
});
......@@ -212,20 +298,32 @@ describe('EpicItemDetails', () => {
children: {
edges: [mockFormattedChildEpic1],
},
descendantCounts: {
openedEpics: 0,
closedEpics: 1,
},
};
wrapper = createComponent(epic);
wrapper = createComponent({ 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 = {
...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 { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'helpers/vue_mount_component_helper';
import { mockTimeframeInitialDate, mockEpic, mockGroupId } from 'ee_jest/roadmap/mock_data';
import {
mockTimeframeInitialDate,
mockEpic,
mockGroupId,
mockFormattedChildEpic1,
} from 'ee_jest/roadmap/mock_data';
jest.mock('lodash/delay', () =>
jest.fn(func => {
......@@ -26,31 +31,43 @@ const createComponent = ({
epic = mockEpic,
timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId,
childLevel = 0,
childrenEpics = {},
childrenFlags = { '1': { itemExpanded: false } },
hasFiltersApplied = false,
}) => {
const Component = Vue.extend(epicItemComponent);
return mountComponent(Component, {
presetType,
epic,
timeframe,
currentGroupId,
return mount(EpicItemComponent, {
stubs: {
'epic-item-container': EpicItemContainer,
'epic-item': EpicItemComponent,
},
propsData: {
presetType,
epic,
timeframe,
currentGroupId,
childLevel,
childrenEpics,
childrenFlags,
hasFiltersApplied,
},
});
};
describe('EpicItemComponent', () => {
let vm;
let wrapper;
beforeEach(() => {
vm = createComponent({});
wrapper = createComponent({});
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
describe('startDate', () => {
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', () => {
......@@ -59,15 +76,15 @@ describe('EpicItemComponent', () => {
startDateOutOfRange: true,
originalStartDate: mockStartDate,
});
vm = createComponent({ epic });
wrapper = createComponent({ epic });
expect(vm.startDate).toBe(mockStartDate);
expect(wrapper.vm.startDate).toBe(mockStartDate);
});
});
describe('endDate', () => {
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', () => {
......@@ -76,33 +93,33 @@ describe('EpicItemComponent', () => {
endDateOutOfRange: true,
originalEndDate: mockEndDate,
});
vm = createComponent({ epic });
wrapper = createComponent({ epic });
expect(vm.endDate).toBe(mockEndDate);
expect(wrapper.vm.endDate).toBe(mockEndDate);
});
});
describe('timeframeString', () => {
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', () => {
const epic = Object.assign({}, mockEpic, {
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', () => {
const epic = Object.assign({}, mockEpic, {
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', () => {
......@@ -110,40 +127,59 @@ describe('EpicItemComponent', () => {
startDate: new Date(2018, 0, 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('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 => {
vm.epic.newEpic = true;
vm.removeHighlight();
vm.$nextTick()
.then(() => {
expect(_.delay).toHaveBeenCalledWith(expect.any(Function), 3000);
})
.then(done)
.catch(done.fail);
it('should wait 3 seconds before toggling `epic.newEpic` from true to false', () => {
wrapper.setProps({
epic: {
...wrapper.vm.epic,
newEpic: true,
},
});
wrapper.vm.removeHighlight();
return wrapper.vm.$nextTick().then(() => {
expect(delay).toHaveBeenCalledWith(expect.any(Function), 3000);
});
});
});
});
describe('template', () => {
it('renders component container element class `epics-list-item`', () => {
expect(vm.$el.classList.contains('epics-list-item')).toBeTruthy();
it('renders Epic item container', () => {
expect(wrapper.find('.epics-list-item').exists()).toBe(true);
});
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`', () => {
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 });
const mockEpics = store.state.epics;
store.state.epics[0].children = {
edges: [mockFormattedChildEpic1, mockFormattedChildEpic2],
};
store.state.childrenEpics[mockEpics[0].id] = [mockFormattedChildEpic1, mockFormattedChildEpic2];
const localVue = createLocalVue();
const createComponent = ({
......@@ -47,6 +46,7 @@ const createComponent = ({
currentGroupId = mockGroupId,
presetType = PRESET_TYPES.MONTHS,
roadmapBufferedRendering = true,
hasFiltersApplied = false,
} = {}) => {
return shallowMount(EpicsListSection, {
localVue,
......@@ -60,6 +60,7 @@ const createComponent = ({
epics,
timeframe,
currentGroupId,
hasFiltersApplied,
},
provide: {
glFeatures: { roadmapBufferedRendering },
......@@ -117,6 +118,28 @@ describe('EpicsListSectionComponent', () => {
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', () => {
......@@ -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];
expect(wrapper.findAll(EpicItem)).toHaveLength(mockEpics.length);
wrapper.vm.toggleIsEpicExpanded(epic.id);
expect(store.state.childrenFlags[epic.id].itemExpanded).toBe(false);
wrapper.vm
.$nextTick()
.then(() => {
const expected = mockEpics.length + epic.children.edges.length;
wrapper.vm.toggleIsEpicExpanded(epic);
expect(wrapper.findAll(EpicItem)).toHaveLength(expected);
})
.then(done)
.catch(done.fail);
return wrapper.vm.$nextTick().then(() => {
expect(store.state.childrenFlags[epic.id].itemExpanded).toBe(true);
});
});
});
......@@ -24,6 +24,7 @@ const createComponent = (
timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId,
defaultInnerHeight = 0,
hasFiltersApplied = false,
},
el,
) => {
......@@ -32,6 +33,7 @@ const createComponent = (
const store = createStore();
store.dispatch('setInitialData', {
defaultInnerHeight,
childrenFlags: { '1': { itemExpanded: false } },
});
return mountComponentWithStore(Component, {
......@@ -43,6 +45,7 @@ const createComponent = (
milestones,
timeframe,
currentGroupId,
hasFiltersApplied,
},
});
};
......
......@@ -18,6 +18,11 @@ export const mockSvgPath = '/foo/bar.svg';
export const mockTimeframeInitialDate = new Date(2018, 0, 1);
const defaultDescendantCounts = {
openedEpics: 0,
closedEpics: 0,
};
export const mockTimeframeQuartersPrepend = [
{
year: 2016,
......@@ -103,6 +108,10 @@ export const mockEpic = {
originalStartDate: new Date('2017-07-10'),
endDate: new Date('2018-06-02'),
webUrl: '/groups/gitlab-org/-/epics/1',
descendantCounts: {
openedEpics: 3,
closedEpics: 2,
},
};
export const mockRawEpic = {
......@@ -116,6 +125,10 @@ export const mockRawEpic = {
start_date: '2017-6-26',
end_date: '2018-03-10',
web_url: '/groups/gitlab-org/marketing/-/epics/2',
descendantCounts: {
openedEpics: 3,
closedEpics: 2,
},
};
export const mockFormattedChildEpic1 = {
......@@ -138,6 +151,7 @@ export const mockFormattedChildEpic1 = {
closedIssues: 3,
openedIssues: 2,
},
descendantCounts: defaultDescendantCounts,
isChildEpic: true,
};
......@@ -184,8 +198,11 @@ export const mockFormattedEpic = {
closedIssues: 3,
openedIssues: 2,
},
descendantCounts: {
openedEpics: 3,
closedEpics: 2,
},
isChildEpic: false,
isChildEpicShowing: false,
};
export const rawEpics = [
......@@ -200,6 +217,11 @@ export const rawEpics = [
start_date: '2017-12-26',
end_date: '2018-03-10',
web_url: '/groups/gitlab-org/marketing/-/epics/2',
descendantCounts: defaultDescendantCounts,
hasParent: true,
parent: {
id: '40',
},
},
{
id: 40,
......@@ -212,6 +234,8 @@ export const rawEpics = [
start_date: '2017-12-25',
end_date: '2018-03-09',
web_url: '/groups/gitlab-org/marketing/-/epics/1',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
{
id: 39,
......@@ -224,6 +248,8 @@ export const rawEpics = [
start_date: '2017-04-02',
end_date: '2017-11-30',
web_url: '/groups/gitlab-org/-/epics/12',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
{
id: 38,
......@@ -236,6 +262,8 @@ export const rawEpics = [
start_date: '2018-01-15',
end_date: '2020-01-03',
web_url: '/groups/gitlab-org/-/epics/11',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
{
id: 37,
......@@ -248,6 +276,8 @@ export const rawEpics = [
start_date: '2018-01-01',
end_date: '2018-01-31',
web_url: '/groups/gitlab-org/-/epics/10',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
{
id: 35,
......@@ -260,6 +290,8 @@ export const rawEpics = [
start_date: '2017-09-04',
end_date: null,
web_url: '/groups/gitlab-org/-/epics/8',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
{
id: 33,
......@@ -272,6 +304,8 @@ export const rawEpics = [
start_date: '2017-11-27',
end_date: null,
web_url: '/groups/gitlab-org/-/epics/6',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
{
id: 4,
......@@ -285,6 +319,8 @@ export const rawEpics = [
start_date: '2018-01-01',
end_date: '2018-02-02',
web_url: '/groups/gitlab-org/-/epics/4',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
{
id: 3,
......@@ -298,6 +334,11 @@ export const rawEpics = [
start_date: '2017-12-01',
end_date: '2018-03-26',
web_url: '/groups/gitlab-org/-/epics/3',
descendantCounts: defaultDescendantCounts,
hasParent: true,
parent: {
id: '40',
},
},
{
id: 2,
......@@ -311,6 +352,8 @@ export const rawEpics = [
start_date: '2017-11-26',
end_date: '2018-03-22',
web_url: '/groups/gitlab-org/-/epics/2',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
{
id: 1,
......@@ -325,6 +368,8 @@ export const rawEpics = [
start_date: '2017-07-10',
end_date: '2018-06-02',
web_url: '/groups/gitlab-org/-/epics/1',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
{
id: 22,
......@@ -337,6 +382,8 @@ export const rawEpics = [
start_date: '2018-12-26',
end_date: '2018-03-10',
web_url: '/groups/gitlab-org/marketing/-/epics/22',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
];
......@@ -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 = [
{
id: 'gid://gitlab/Milestone/40',
......
......@@ -24,6 +24,7 @@ import {
mockGroupEpicsQueryResponse,
mockGroupEpicsQueryResponseFormatted,
mockGroupMilestonesQueryResponse,
mockEpicChildEpicsQueryResponse,
rawMilestones,
mockMilestone,
mockFormattedMilestone,
......@@ -112,6 +113,10 @@ describe('Roadmap Vuex Actions', () => {
closedIssues: 3,
openedIssues: 2,
},
descendantCounts: {
openedEpics: 3,
closedEpics: 2,
},
}),
],
},
......@@ -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', () => {
});
});
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', () => {
it('should set bufferSize in store state', () => {
return testAction(
......@@ -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';
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', () => {
let state;
......@@ -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', () => {
it('Should set extendedTimeframe to provided extendedTimeframe param and prepend it to timeframe array in state', () => {
state.timeframe.push('foo');
......@@ -197,30 +266,4 @@ describe('Roadmap Store Mutations', () => {
expect(state.bufferSize).toBe(bufferSize);
});
});
describe('TOGGLE_EXPANDED_EPIC', () => {
it('should toggle collapsed epic to an expanded epic', () => {
const epicId = 1;
const epics = [
{ id: 1, title: 'Collapsed epic', isChildEpicShowing: false },
{ id: 2, title: 'Expanded epic', isChildEpicShowing: true },
];
mutations[types.TOGGLE_EXPANDED_EPIC]({ ...state, epics }, epicId);
expect(getEpic(epicId, epics).isChildEpicShowing).toBe(true);
});
it('should toggle expanded epic to a collapsed epic', () => {
const epicId = 2;
const epics = [
{ id: 1, title: 'Collapsed epic', isChildEpicShowing: false },
{ id: 2, title: 'Expanded epic', isChildEpicShowing: true },
];
mutations[types.TOGGLE_EXPANDED_EPIC]({ ...state, epics }, epicId);
expect(getEpic(epicId, epics).isChildEpicShowing).toBe(false);
});
});
});
......@@ -13760,6 +13760,9 @@ msgstr ""
msgid "No changes between %{ref_start}%{source_branch}%{ref_end} and %{ref_start}%{target_branch}%{ref_end}"
msgstr ""
msgid "No child epics match applied filters"
msgstr ""
msgid "No connection could be made to a Gitaly Server, please check your logs!"
msgstr ""
......@@ -19181,6 +19184,9 @@ msgstr ""
msgid "Snowplow"
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."
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