Commit d1eec7ce authored by Eulyeon Ko's avatar Eulyeon Ko Committed by Paul Slaughter

Allow Hiding/Collapsing of Milestone header on Roadmap

https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34357
parent 69b34501
...@@ -74,9 +74,7 @@ export default { ...@@ -74,9 +74,7 @@ export default {
if (this.isEmptyChildrenWithFilter) { if (this.isEmptyChildrenWithFilter) {
return this.infoSearchLabel; return this.infoSearchLabel;
} }
return this.childrenFlags[this.itemId].itemExpanded return this.childrenFlags[this.itemId].itemExpanded ? __('Collapse') : __('Expand');
? __('Collapse child epics')
: __('Expand child epics');
}, },
childrenFetchInProgress() { childrenFetchInProgress() {
return this.epic.hasChildren && this.childrenFlags[this.itemId].itemChildrenFetchInProgress; return this.epic.hasChildren && this.childrenFlags[this.itemId].itemChildrenFetchInProgress;
...@@ -108,9 +106,12 @@ export default { ...@@ -108,9 +106,12 @@ export default {
</script> </script>
<template> <template>
<div class="epic-details-cell" data-qa-selector="epic_details_cell">
<div <div
class="d-flex align-items-start p-2" class="epic-details-cell gl-display-flex gl-flex-direction-column gl-justify-content-center"
data-qa-selector="epic_details_cell"
>
<div
class="gl-display-flex align-items-start gl-px-3 gl-mb-1"
:class="[epic.isChildEpic ? childMarginClassname : '']" :class="[epic.isChildEpic ? childMarginClassname : '']"
> >
<span ref="expandCollapseInfo"> <span ref="expandCollapseInfo">
...@@ -130,38 +131,44 @@ export default { ...@@ -130,38 +131,44 @@ export default {
</gl-button> </gl-button>
</span> </span>
<gl-tooltip <gl-tooltip
v-if="isEmptyChildrenWithFilter" v-if="!isExpandIconHidden"
ref="expandIconTooltip"
triggers="hover"
:target="() => $refs.expandCollapseInfo" :target="() => $refs.expandCollapseInfo"
boundary="viewport" boundary="viewport"
offset="80" offset="15"
placement="topright" placement="topright"
> >
{{ infoSearchLabel }} {{ expandIconLabel }}
</gl-tooltip> </gl-tooltip>
<div class="overflow-hidden flex-grow-1 mx-2"> <div class="overflow-hidden flex-grow-1 mx-2">
<a :href="epic.webUrl" :title="epic.title" class="epic-title d-block text-body bold"> <a
:href="epic.webUrl"
:title="epic.title"
class="epic-title gl-mt-1 d-block text-body bold"
>
{{ epic.title }} {{ epic.title }}
</a> </a>
<div class="epic-group-timeframe d-flex text-secondary"> <div class="epic-group-timeframe d-flex text-secondary">
<p <span
v-if="isEpicGroupDifferent && !epic.hasParent" v-if="isEpicGroupDifferent && !epic.hasParent"
:title="epic.groupFullName" :title="epic.groupFullName"
class="epic-group" class="epic-group"
> >
{{ epic.groupName }} {{ epic.groupName }}
</p> </span>
<span v-if="isEpicGroupDifferent && !epic.hasParent" class="mx-1" aria-hidden="true" <span v-if="isEpicGroupDifferent && !epic.hasParent" class="mx-1" aria-hidden="true"
>&middot;</span >&middot;</span
> >
<p class="epic-timeframe" :title="timeframeString">{{ timeframeString }}</p> <span class="epic-timeframe" :title="timeframeString">{{ timeframeString }}</span>
</div> </div>
</div> </div>
<template v-if="allowSubEpics"> <template v-if="allowSubEpics">
<div ref="childEpicsCount" class="d-flex text-secondary text-nowrap"> <div ref="childEpicsCount" class="gl-mt-1 d-flex text-secondary text-nowrap">
<gl-icon name="epic" class="align-text-bottom mr-1" aria-hidden="true" /> <gl-icon name="epic" class="align-text-bottom mr-1" aria-hidden="true" />
<p class="m-0" :aria-label="childEpicsCountText">{{ childEpicsCount }}</p> <p class="m-0" :aria-label="childEpicsCountText">{{ childEpicsCount }}</p>
</div> </div>
<gl-tooltip :target="() => $refs.childEpicsCount"> <gl-tooltip ref="childEpicsCountTooltip" :target="() => $refs.childEpicsCount">
<span :class="{ bold: hasFiltersApplied }">{{ childEpicsCountText }}</span> <span :class="{ bold: hasFiltersApplied }">{{ childEpicsCountText }}</span>
<span v-if="hasFiltersApplied" class="d-block">{{ childEpicsSearchText }}</span> <span v-if="hasFiltersApplied" class="d-block">{{ childEpicsSearchText }}</span>
</gl-tooltip> </gl-tooltip>
......
...@@ -24,6 +24,10 @@ export default { ...@@ -24,6 +24,10 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
milestonesExpanded: {
type: Boolean,
required: true,
},
}, },
}; };
</script> </script>
...@@ -33,10 +37,12 @@ export default { ...@@ -33,10 +37,12 @@ export default {
<span <span
v-for="timeframeItem in timeframe" v-for="timeframeItem in timeframe"
:key="timeframeItem.id" :key="timeframeItem.id"
class="milestone-timeline-cell d-table-cell position-relative border-right border-bottom" class="milestone-timeline-cell gl-display-table-cell gl-relative border-right border-bottom"
:class="{ 'milestone-timeline-cell-empty': !milestonesExpanded }"
data-qa-selector="milestone_timeline_cell" data-qa-selector="milestone_timeline_cell"
> >
<current-day-indicator :preset-type="presetType" :timeframe-item="timeframeItem" /> <current-day-indicator :preset-type="presetType" :timeframe-item="timeframeItem" />
<template v-if="milestonesExpanded">
<milestone-item <milestone-item
v-for="milestone in milestones" v-for="milestone in milestones"
:key="milestone.id" :key="milestone.id"
...@@ -46,6 +52,7 @@ export default { ...@@ -46,6 +52,7 @@ export default {
:timeframe-item="timeframeItem" :timeframe-item="timeframeItem"
:current-group-id="currentGroupId" :current-group-id="currentGroupId"
/> />
</template>
</span> </span>
</div> </div>
</template> </template>
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { __, n__ } from '~/locale';
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { EPIC_DETAILS_CELL_WIDTH, EPIC_ITEM_HEIGHT, TIMELINE_CELL_MIN_WIDTH } from '../constants'; import { EPIC_DETAILS_CELL_WIDTH, EPIC_ITEM_HEIGHT, TIMELINE_CELL_MIN_WIDTH } from '../constants';
import MilestoneTimeline from './milestone_timeline.vue'; import MilestoneTimeline from './milestone_timeline.vue';
const EXPAND_BUTTON_EXPANDED = {
name: 'chevron-down',
iconLabel: __('Collapse milestones'),
tooltip: __('Collapse'),
};
const EXPAND_BUTTON_COLLAPSED = {
name: 'chevron-right',
iconLabel: __('Expand milestones'),
tooltip: __('Expand'),
};
export default { export default {
components: { components: {
MilestoneTimeline, MilestoneTimeline,
GlButton,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
}, },
props: { props: {
presetType: { presetType: {
...@@ -31,6 +50,7 @@ export default { ...@@ -31,6 +50,7 @@ export default {
offsetLeft: 0, offsetLeft: 0,
showBottomShadow: false, showBottomShadow: false,
roadmapShellEl: null, roadmapShellEl: null,
milestonesExpanded: true,
}; };
}, },
computed: { computed: {
...@@ -48,6 +68,17 @@ export default { ...@@ -48,6 +68,17 @@ export default {
left: `${this.offsetLeft}px`, left: `${this.offsetLeft}px`,
}; };
}, },
expandButton() {
return this.milestonesExpanded ? EXPAND_BUTTON_EXPANDED : EXPAND_BUTTON_COLLAPSED;
},
milestonesCount() {
return this.milestones.length;
},
milestonesCountText() {
return Number.isInteger(this.milestonesCount)
? n__(`%d milestone`, `%d milestones`, this.milestonesCount)
: '';
},
}, },
mounted() { mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll); eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
...@@ -76,23 +107,59 @@ export default { ...@@ -76,23 +107,59 @@ export default {
handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) { handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) {
this.showBottomShadow = Math.ceil(scrollTop) + clientHeight < scrollHeight; this.showBottomShadow = Math.ceil(scrollTop) + clientHeight < scrollHeight;
}, },
toggleMilestonesExpanded() {
this.milestonesExpanded = !this.milestonesExpanded;
},
}, },
}; };
</script> </script>
<template> <template>
<div :style="sectionContainerStyles" class="milestones-list-section d-table">
<div <div
class="milestones-list-title d-table-cell bold border-bottom align-top position-sticky pt-2 pl-3" :style="sectionContainerStyles"
class="milestones-list-section gl-display-table"
:class="{ 'milestones-list-section-collapsed': !milestonesExpanded }"
> >
<div
class="milestones-list-title gl-display-table-cell border-bottom gl-vertical-align-top position-sticky gl-p-3"
>
<div class="gl-display-flex gl-align-items-center">
<span
v-gl-tooltip.hover.topright="{
title: expandButton.tooltip,
offset: 15,
boundary: 'viewport',
}"
data-testid="expandButton"
>
<gl-button
:aria-label="expandButton.iconLabel"
variant="link"
@click="toggleMilestonesExpanded"
>
<gl-icon :name="expandButton.name" class="text-secondary" aria-hidden="true" />
</gl-button>
</span>
<div class="gl-overflow-hidden gl-flex-grow-1 gl-mx-3 gl-font-weight-bold">
{{ __('Milestones') }} {{ __('Milestones') }}
</div> </div>
<div class="milestones-list-items d-table-cell"> <div
v-gl-tooltip="milestonesCountText"
class="gl-display-flex gl-align-items-center gl-justify-content-center text-secondary gl-white-space-nowrap"
data-testid="count"
>
<gl-icon name="clock" class="gl-mr-2" aria-hidden="true" />
<span :aria-label="milestonesCountText">{{ milestonesCount }}</span>
</div>
</div>
</div>
<div class="milestones-list-items gl-display-table-cell">
<milestone-timeline <milestone-timeline
:preset-type="presetType" :preset-type="presetType"
:timeframe="timeframe" :timeframe="timeframe"
:milestones="milestones" :milestones="milestones"
:current-group-id="currentGroupId" :current-group-id="currentGroupId"
:milestones-expanded="milestonesExpanded"
/> />
</div> </div>
<div v-show="showBottomShadow" :style="shadowCellStyles" class="scroll-bottom-shadow"></div> <div v-show="showBottomShadow" :style="shadowCellStyles" class="scroll-bottom-shadow"></div>
......
$header-item-height: 60px; $header-item-height: 60px;
$item-height: 50px; $item-height: 50px;
$milestones-collapsed-height: 38px;
$details-cell-width: 320px; $details-cell-width: 320px;
$timeline-cell-width: 180px; $timeline-cell-width: 180px;
$border-style: 1px solid $border-gray-normal; $border-style: 1px solid $border-gray-normal;
...@@ -408,11 +409,19 @@ html.group-epics-roadmap-html { ...@@ -408,11 +409,19 @@ html.group-epics-roadmap-html {
} }
.milestones-list-section { .milestones-list-section {
&.milestones-list-section-collapsed {
height: $milestones-collapsed-height;
}
.milestones-list-items { .milestones-list-items {
.milestone-timeline-cell { .milestone-timeline-cell {
width: $timeline-cell-width; width: $timeline-cell-width;
} }
.milestone-timeline-cell-empty {
height: $milestones-collapsed-height;
}
.timeline-bar-wrapper { .timeline-bar-wrapper {
height: 32px; height: 32px;
color: $gray-700; color: $gray-700;
......
---
title: Allow Hiding/Collapsing of Milestone header on Roadmap
merge_request: 34357
author:
type: added
import { GlButton, GlIcon, GlTooltip } from '@gitlab/ui'; import { GlButton, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import createStore from 'ee/roadmap/store'; import createStore from 'ee/roadmap/store';
import EpicItemDetails from 'ee/roadmap/components/epic_item_details.vue'; import EpicItemDetails from 'ee/roadmap/components/epic_item_details.vue';
...@@ -10,70 +10,87 @@ import { ...@@ -10,70 +10,87 @@ import {
mockFormattedChildEpic1, mockFormattedChildEpic1,
} from 'ee_jest/roadmap/mock_data'; } from 'ee_jest/roadmap/mock_data';
let store; describe('EpicItemDetails', () => {
let wrapper;
const createComponent = ({ let store;
epic = mockFormattedEpic,
currentGroupId = mockGroupId, beforeEach(() => {
timeframeString = 'Jul 10, 2017 – Jun 2, 2018', store = createStore();
childLevel = 0, });
childrenFlags = { '41': { itemExpanded: false } },
hasFiltersApplied = false, afterEach(() => {
isChildrenEmpty = false, wrapper.destroy();
} = {}) => { wrapper = null;
return shallowMount(EpicItemDetails, { });
const createWrapper = (props = {}) => {
wrapper = shallowMount(EpicItemDetails, {
store, store,
propsData: { propsData: {
epic, epic: mockFormattedEpic,
currentGroupId, currentGroupId: mockGroupId,
timeframeString, timeframeString: 'Jul 10, 2017 – Jun 2, 2018',
childLevel, childLevel: 0,
childrenFlags, childrenFlags: { '41': { itemExpanded: false } },
hasFiltersApplied, hasFiltersApplied: false,
isChildrenEmpty, isChildrenEmpty: false,
...props,
}, },
}); });
}; };
const getTitle = wrapper => wrapper.find('.epic-title'); const getTitle = () => wrapper.find('.epic-title');
const getGroupName = wrapper => wrapper.find('.epic-group'); const getGroupName = () => wrapper.find('.epic-group');
const getExpandIconButton = wrapper => wrapper.find(GlButton); const getChildMarginClassName = () => wrapper.vm.childMarginClassname;
const getChildEpicsCount = wrapper => wrapper.find({ ref: 'childEpicsCount' }); const getExpandIconButton = () => wrapper.find(GlButton);
describe('EpicItemDetails', () => { const getExpandIconTooltip = () => wrapper.find({ ref: 'expandIconTooltip' });
let wrapper;
beforeEach(() => { const getChildEpicsCount = () => wrapper.find({ ref: 'childEpicsCount' });
store = createStore();
wrapper = createComponent(); const getChildEpicsCountTooltip = () => wrapper.find({ ref: 'childEpicsCountTooltip' });
const getExpandButtonData = () => ({
icon: wrapper.find(GlIcon).attributes('name'),
iconLabel: getExpandIconButton().attributes('aria-label'),
tooltip: getExpandIconTooltip().text(),
}); });
afterEach(() => { const getEpicTitleData = () => ({
wrapper.destroy(); title: getTitle().text(),
wrapper = null; link: getTitle().attributes('href'),
});
const getEpicGroupNameData = () => ({
groupName: getGroupName().text(),
title: getGroupName().attributes('title'),
});
const createMockEpic = epic => ({
...mockFormattedEpic,
...epic,
}); });
describe('epic title', () => { describe('epic title', () => {
it('is displayed', () => { beforeEach(() => {
expect(getTitle(wrapper).text()).toBe(mockFormattedEpic.title); createWrapper();
}); });
it('contains a link to the epic', () => { it('is displayed with a link to the epic', () => {
expect(getTitle(wrapper).attributes('href')).toBe(mockFormattedEpic.webUrl); expect(getEpicTitleData()).toEqual({
title: mockFormattedEpic.title,
link: mockFormattedEpic.webUrl,
});
}); });
}); });
describe('epic group name', () => { describe('epic group name', () => {
describe('when the epic group ID is different from the current group ID', () => { const epic = {
let epic;
beforeEach(() => {
epic = {
id: '41', id: '41',
mockFormattedEpic, ...mockFormattedEpic,
groupId: 1, groupId: 1,
groupName: 'Bar', groupName: 'Bar',
groupFullName: 'Foo / Bar', groupFullName: 'Foo / Bar',
...@@ -83,40 +100,27 @@ describe('EpicItemDetails', () => { ...@@ -83,40 +100,27 @@ describe('EpicItemDetails', () => {
}, },
}; };
wrapper.setProps({ epic, currentGroupId: 2 }); describe('when the epic group ID is different from the current group ID', () => {
it('is displayed and set to the title attribute', () => {
createWrapper({ epic, currentGroupId: 2 });
expect(getEpicGroupNameData()).toEqual({
groupName: epic.groupName,
title: epic.groupFullName,
}); });
it('is displayed', () => {
expect(getGroupName(wrapper).text()).toContain(epic.groupName);
});
it('is set to the title attribute', () => {
expect(getGroupName(wrapper).attributes('title')).toBe(epic.groupFullName);
}); });
}); });
describe('when the epic group ID is the same as the current group ID', () => { describe('when the epic group ID is the same as the current group ID', () => {
let epic;
beforeEach(() => {
epic = {
...mockFormattedEpic,
groupId: 1,
groupName: 'Bar',
groupFullName: 'Foo / Bar',
};
wrapper.setProps({ epic, currentGroupId: 1 });
});
it('is hidden', () => { it('is hidden', () => {
expect(getGroupName(wrapper).exists()).toBe(false); createWrapper({ epic, currentGroupId: 1 });
expect(getGroupName().exists()).toBe(false);
}); });
}); });
}); });
describe('timeframe', () => { describe('timeframe', () => {
it('is displayed', () => { it('is displayed', () => {
createWrapper();
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');
...@@ -125,13 +129,13 @@ describe('EpicItemDetails', () => { ...@@ -125,13 +129,13 @@ describe('EpicItemDetails', () => {
describe('childMarginClassname', () => { describe('childMarginClassname', () => {
it('childMarginClassname returns class for level 1 child is childLevel is 1', () => { it('childMarginClassname returns class for level 1 child is childLevel is 1', () => {
wrapper.setProps({ childLevel: 1 }); createWrapper({ childLevel: 1 });
expect(wrapper.vm.childMarginClassname).toEqual('ml-4'); expect(getChildMarginClassName()).toEqual('ml-4');
}); });
it('childMarginClassname returns class for level 2 child is childLevel is 2', () => { it('childMarginClassname returns class for level 2 child is childLevel is 2', () => {
wrapper.setProps({ childLevel: 2 }); createWrapper({ childLevel: 2 });
expect(wrapper.vm.childMarginClassname).toEqual('ml-6'); expect(getChildMarginClassName()).toEqual('ml-6');
}); });
}); });
...@@ -141,204 +145,152 @@ describe('EpicItemDetails', () => { ...@@ -141,204 +145,152 @@ describe('EpicItemDetails', () => {
}); });
describe('expand icon', () => { describe('expand icon', () => {
it('is hidden when epic has no child epics', () => { it('is hidden when it is child epic', () => {
const epic = { const epic = createMockEpic({
...mockFormattedEpic, isChildEpic: true,
hasChildren: false, });
}; createWrapper({ epic });
wrapper = createComponent({ epic }); expect(getExpandIconButton().classes()).toContain('invisible');
});
expect(getExpandIconButton(wrapper).classes()).toContain('invisible'); describe('when epic has no child epics', () => {
beforeEach(() => {
const epic = createMockEpic({
hasChildren: false,
descendantCounts: {
openedEpics: 0,
closedEpics: 0,
},
});
createWrapper({ epic });
});
it('is hidden', () => {
expect(getExpandIconButton().classes()).toContain('invisible');
});
describe('child epics count', () => {
it('shows the count as 0', () => {
expect(getChildEpicsCount().text()).toBe('0');
});
});
}); });
it('is shown when epic has child epics', () => { describe('when epic has child epics', () => {
const epic = { let epic;
...mockFormattedEpic, beforeEach(() => {
epic = createMockEpic({
id: 41,
hasChildren: true, hasChildren: true,
children: { children: {
edges: [mockFormattedChildEpic1], edges: [mockFormattedChildEpic1],
}, },
}; descendantCounts: {
wrapper = createComponent({ epic }); openedEpics: 0,
closedEpics: 1,
expect(getExpandIconButton(wrapper).classes()).not.toContain('invisible'); },
});
createWrapper({ epic });
}); });
it('shows "chevron-right" icon when child epics are not expanded', () => { it('is shown', () => {
wrapper = createComponent(); expect(getExpandIconButton().classes()).not.toContain('invisible');
});
expect(wrapper.find(GlIcon).attributes('name')).toBe('chevron-right'); it('emits toggleIsEpicExpanded event when clicked', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
getExpandIconButton().vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('toggleIsEpicExpanded', epic);
}); });
it('shows "chevron-down" icon when child epics are expanded', () => { describe('when child epics are expanded', () => {
const epic = { const childrenFlags = {
...mockFormattedEpic,
hasChildren: true,
};
wrapper = createComponent({
epic,
childrenFlags: {
'41': { itemExpanded: true }, '41': { itemExpanded: true },
}, };
beforeEach(() => {
createWrapper({ epic, childrenFlags });
}); });
expect(wrapper.find(GlIcon).attributes('name')).toBe('chevron-down'); it('shows collapse button', () => {
expect(getExpandButtonData()).toEqual({
icon: 'chevron-down',
iconLabel: 'Collapse',
tooltip: 'Collapse',
});
}); });
it('shows "information-o" icon when child epics are expanded but no children are returned due to applied filters', () => { describe('when filters are applied', () => {
const epic = { beforeEach(() => {
...mockFormattedEpic, createWrapper({
hasChildren: true,
};
wrapper = createComponent({
epic, epic,
childrenFlags: { childrenFlags,
'41': { itemExpanded: true },
},
hasFiltersApplied: true, hasFiltersApplied: true,
isChildrenEmpty: 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('shows child epics match filters button', () => {
wrapper = createComponent(); expect(getExpandButtonData()).toEqual({
icon: 'information-o',
expect(getExpandIconButton(wrapper).attributes('aria-label')).toBe('Expand child epics'); iconLabel: 'No child epics match applied filters',
tooltip: 'No child epics match applied filters',
});
}); });
it('has "Collapse child epics" label when child epics are expanded', () => {
const epic = {
...mockFormattedEpic,
hasChildren: true,
};
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', () => { describe('when child epics are not expanded', () => {
const epic = { beforeEach(() => {
...mockFormattedEpic, const childrenFlags = {
hasChildren: true, '41': { itemExpanded: false },
}; };
wrapper = createComponent({ createWrapper({
epic, epic,
childrenFlags: { 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('shows expand button', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); expect(getExpandButtonData()).toEqual({
icon: 'chevron-right',
const id = 41; iconLabel: 'Expand',
const epic = { tooltip: 'Expand',
...mockFormattedEpic,
id,
children: {
edges: [mockFormattedChildEpic1],
},
};
wrapper = createComponent({ epic });
getExpandIconButton(wrapper).vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('toggleIsEpicExpanded', epic);
}); });
it('is hidden when it is child epic', () => {
const epic = {
...mockFormattedEpic,
isChildEpic: true,
};
wrapper = createComponent({ epic });
expect(getExpandIconButton(wrapper).classes()).toContain('invisible');
}); });
}); });
describe('child epics count', () => { describe('child epics count', () => {
it('shows the correct count of child epics', () => { it('has a tooltip with the count', () => {
const epic = { createWrapper({ epic });
...mockFormattedEpic, expect(getChildEpicsCountTooltip().text()).toBe('1 child epic');
children: {
edges: [mockFormattedChildEpic1, mockFormattedChildEpic2],
},
descendantCounts: {
openedEpics: 0,
closedEpics: 2,
},
};
wrapper = createComponent({ epic });
expect(getChildEpicsCount(wrapper).text()).toBe('2');
}); });
it('shows the count as 0 when there are no child epics', () => { it('has a tooltip with the count and explanation if search is being performed', () => {
const epic = { createWrapper({ epic, hasFiltersApplied: true });
...mockFormattedEpic, expect(getChildEpicsCountTooltip().text()).toBe(
descendantCounts: { '1 child epic Some child epics may be hidden due to applied filters',
openedEpics: 0, );
closedEpics: 0,
},
};
wrapper = createComponent({ epic });
expect(getChildEpicsCount(wrapper).text()).toBe('0');
}); });
it('has a tooltip with the count', () => { it('does not render if the user license does not support child epics', () => {
const epic = { store.state.allowSubEpics = false;
...mockFormattedEpic, createWrapper({ epic });
children: { expect(getChildEpicsCount().exists()).toBe(false);
edges: [mockFormattedChildEpic1],
},
descendantCounts: {
openedEpics: 0,
closedEpics: 1,
},
};
wrapper = createComponent({ epic });
expect(wrapper.find(GlTooltip).text()).toBe('1 child epic');
}); });
it('has a tooltip with the count and explanation if search is being performed', () => { it('shows the correct count of child epics', () => {
const epic = { epic = createMockEpic({
...mockFormattedEpic,
children: { children: {
edges: [mockFormattedChildEpic1], edges: [mockFormattedChildEpic1, mockFormattedChildEpic2],
}, },
descendantCounts: { descendantCounts: {
openedEpics: 0, openedEpics: 0,
closedEpics: 1, closedEpics: 2,
}, },
};
wrapper = createComponent({ epic, hasFiltersApplied: true });
expect(wrapper.find(GlTooltip).text()).toBe(
'1 child epic Some child epics may be hidden due to applied filters',
);
}); });
createWrapper({ epic });
it('does not render if the user license does not support child epics', () => { expect(getChildEpicsCount().text()).toBe('2');
store.state.allowSubEpics = false; });
wrapper = createComponent(); });
expect(getChildEpicsCount(wrapper).exists()).toBe(false);
}); });
}); });
}); });
......
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import milestoneTimelineComponent from 'ee/roadmap/components/milestone_timeline.vue'; import MilestoneTimelineComponent from 'ee/roadmap/components/milestone_timeline.vue';
import MilestoneItem from 'ee/roadmap/components/milestone_item.vue'; import MilestoneItem from 'ee/roadmap/components/milestone_item.vue';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils'; import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
...@@ -11,24 +10,6 @@ import { mockTimeframeInitialDate, mockMilestone2, mockGroupId } from 'ee_jest/r ...@@ -11,24 +10,6 @@ import { mockTimeframeInitialDate, mockMilestone2, mockGroupId } from 'ee_jest/r
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate); const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
timeframe = mockTimeframeMonths,
milestones = [mockMilestone2],
currentGroupId = mockGroupId,
} = {}) => {
const Component = Vue.extend(milestoneTimelineComponent);
return shallowMount(Component, {
propsData: {
presetType,
timeframe,
milestones,
currentGroupId,
},
});
};
describe('MilestoneTimelineComponent', () => { describe('MilestoneTimelineComponent', () => {
let wrapper; let wrapper;
...@@ -36,17 +17,39 @@ describe('MilestoneTimelineComponent', () => { ...@@ -36,17 +17,39 @@ describe('MilestoneTimelineComponent', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('template', () => { const createWrapper = (props = {}) => {
it('renders component container element with class `milestone-timeline-cell`', () => { wrapper = shallowMount(MilestoneTimelineComponent, {
wrapper = createComponent(); propsData: {
presetType: PRESET_TYPES.MONTHS,
expect(wrapper.find('.milestone-timeline-cell').exists()).toBe(true); timeframe: mockTimeframeMonths,
milestones: [mockMilestone2],
currentGroupId: mockGroupId,
milestonesExpanded: true,
...props,
},
});
};
const findMilestoneTimelineCell = () => wrapper.find('.milestone-timeline-cell');
const findMilestoneItem = () => wrapper.find(MilestoneItem);
describe.each`
props | hasCellEmpty | hasMilestoneItem
${{}} | ${false} | ${true}
${{ milestonesExpanded: false }} | ${true} | ${false}
`('with $props', ({ props, hasCellEmpty, hasMilestoneItem }) => {
beforeEach(() => {
createWrapper(props);
}); });
it('renders MilestoneItem component', () => { it(`renders timeline cell with empty class = ${hasCellEmpty}`, () => {
wrapper = createComponent(); expect(findMilestoneTimelineCell().classes('milestone-timeline-cell-empty')).toBe(
hasCellEmpty,
);
});
expect(wrapper.find(MilestoneItem).exists()).toBe(true); it(`renders MilestoneItem component = ${hasMilestoneItem}`, () => {
expect(findMilestoneItem().exists()).toBe(hasMilestoneItem);
}); });
}); });
}); });
import { GlIcon, GlButton } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import milestonesListSectionComponent from 'ee/roadmap/components/milestones_list_section.vue'; import milestonesListSectionComponent from 'ee/roadmap/components/milestones_list_section.vue';
import MilestoneTimeline from 'ee/roadmap/components/milestone_timeline.vue'; import MilestoneTimeline from 'ee/roadmap/components/milestone_timeline.vue';
...@@ -9,57 +10,72 @@ import { ...@@ -9,57 +10,72 @@ import {
TIMELINE_CELL_MIN_WIDTH, TIMELINE_CELL_MIN_WIDTH,
} from 'ee/roadmap/constants'; } from 'ee/roadmap/constants';
import { mockTimeframeInitialDate, mockGroupId, rawMilestones } from 'ee_jest/roadmap/mock_data'; import { mockTimeframeInitialDate, mockGroupId, rawMilestones } from 'ee_jest/roadmap/mock_data';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate); const initializeStore = mockTimeframeMonths => {
const store = createStore(); const store = createStore();
store.dispatch('setInitialData', { store.dispatch('setInitialData', {
currentGroupId: mockGroupId, currentGroupId: mockGroupId,
presetType: PRESET_TYPES.MONTHS, presetType: PRESET_TYPES.MONTHS,
timeframe: mockTimeframeMonths, timeframe: mockTimeframeMonths,
}); });
store.dispatch('receiveMilestonesSuccess', { rawMilestones });
store.dispatch('receiveMilestonesSuccess', { rawMilestones }); return store;
};
const mockMilestones = store.state.milestones;
const createComponent = ({ describe('MilestonesListSectionComponent', () => {
milestones = mockMilestones, let wrapper;
timeframe = mockTimeframeMonths, let store;
currentGroupId = mockGroupId,
presetType = PRESET_TYPES.MONTHS, const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
} = {}) => { const findMilestoneCount = () => wrapper.find('[data-testid="count"]');
const findMilestoneCountTooltip = () => getBinding(findMilestoneCount().element, 'gl-tooltip');
const findExpandButtonContainer = () => wrapper.find('[data-testid="expandButton"]');
const findExpandButtonData = () => {
const container = findExpandButtonContainer();
return {
icon: container.find(GlIcon).attributes('name'),
iconLabel: container.find(GlButton).attributes('aria-label'),
tooltip: getBinding(container.element, 'gl-tooltip').value.title,
};
};
const createWrapper = (props = {}) => {
const localVue = createLocalVue(); const localVue = createLocalVue();
wrapper = shallowMount(milestonesListSectionComponent, {
return shallowMount(milestonesListSectionComponent, {
localVue, localVue,
store, store,
stubs: { stubs: {
MilestoneTimeline: false, MilestoneTimeline: false,
}, },
propsData: { propsData: {
presetType, milestones: store.state.milestones,
milestones, timeframe: mockTimeframeMonths,
timeframe, currentGroupId: mockGroupId,
currentGroupId, presetType: PRESET_TYPES.MONTHS,
...props,
},
directives: {
GlTooltip: createMockDirective(),
}, },
}); });
}; };
describe('MilestonesListSectionComponent', () => {
let wrapper;
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); store = initializeStore(mockTimeframeMonths);
createWrapper();
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('data', () => { describe('data', () => {
it('returns default data props', () => { it('returns default data props', () => {
expect(wrapper.vm.offsetLeft).toBe(0); expect(wrapper.vm.offsetLeft).toBe(0);
expect(wrapper.vm.roadmapShellEl).toBeDefined(); expect(wrapper.vm.roadmapShellEl).toBeDefined();
expect(wrapper.vm.milestonesExpanded).toBe(true);
}); });
}); });
...@@ -134,5 +150,41 @@ describe('MilestonesListSectionComponent', () => { ...@@ -134,5 +150,41 @@ describe('MilestonesListSectionComponent', () => {
expect(wrapper.find('.scroll-bottom-shadow').exists()).toBe(true); expect(wrapper.find('.scroll-bottom-shadow').exists()).toBe(true);
}); });
it('show the correct count of milestones ', () => {
expect(findMilestoneCount().text()).toBe('2');
});
it('has a tooltip with the correct count of milestones', () => {
expect(findMilestoneCountTooltip().value).toBe('2 milestones');
});
describe('milestone expand/collapse button', () => {
it('is rendered', () => {
expect(findExpandButtonData()).toEqual({
icon: 'chevron-down',
iconLabel: 'Collapse milestones',
tooltip: 'Collapse',
});
});
});
});
describe('when the milestone list is expanded', () => {
beforeEach(() => {
findExpandButtonContainer()
.find(GlButton)
.vm.$emit('click');
return wrapper.vm.$nextTick();
});
it('shows "chevron-right" icon when the milestone toggle button is clicked', () => {
expect(findExpandButtonData()).toEqual({
icon: 'chevron-right',
iconLabel: 'Expand milestones',
tooltip: 'Expand',
});
});
}); });
}); });
...@@ -199,6 +199,11 @@ msgid_plural "%d metrics" ...@@ -199,6 +199,11 @@ msgid_plural "%d metrics"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "%d milestone"
msgid_plural "%d milestones"
msgstr[0] ""
msgstr[1] ""
msgid "%d minute" msgid "%d minute"
msgid_plural "%d minutes" msgid_plural "%d minutes"
msgstr[0] "" msgstr[0] ""
...@@ -5689,7 +5694,7 @@ msgstr "" ...@@ -5689,7 +5694,7 @@ msgstr ""
msgid "Collapse approvers" msgid "Collapse approvers"
msgstr "" msgstr ""
msgid "Collapse child epics" msgid "Collapse milestones"
msgstr "" msgstr ""
msgid "Collapse replies" msgid "Collapse replies"
...@@ -9296,15 +9301,15 @@ msgstr "" ...@@ -9296,15 +9301,15 @@ msgstr ""
msgid "Expand approvers" msgid "Expand approvers"
msgstr "" msgstr ""
msgid "Expand child epics"
msgstr ""
msgid "Expand down" msgid "Expand down"
msgstr "" msgstr ""
msgid "Expand dropdown" msgid "Expand dropdown"
msgstr "" msgstr ""
msgid "Expand milestones"
msgstr ""
msgid "Expand sidebar" msgid "Expand sidebar"
msgstr "" msgstr ""
......
...@@ -2,13 +2,21 @@ export const getKey = name => `$_gl_jest_${name}`; ...@@ -2,13 +2,21 @@ export const getKey = name => `$_gl_jest_${name}`;
export const getBinding = (el, name) => el[getKey(name)]; export const getBinding = (el, name) => el[getKey(name)];
export const createMockDirective = () => ({ const writeBindingToElement = (el, { name, value, arg, modifiers }) => {
bind(el, { name, value, arg, modifiers }) {
el[getKey(name)] = { el[getKey(name)] = {
value, value,
arg, arg,
modifiers, modifiers,
}; };
};
export const createMockDirective = () => ({
bind(el, binding) {
writeBindingToElement(el, binding);
},
update(el, binding) {
writeBindingToElement(el, binding);
}, },
unbind(el, { name }) { unbind(el, { name }) {
......
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