Commit 64f740c4 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '212494-allow-hiding-collapsing-of-milestone-header-on-roadmap' into 'master'

Allow Hiding/Collapsing of Milestone header on Roadmap

See merge request gitlab-org/gitlab!34357
parents ba0e7fd9 d1eec7ce
......@@ -74,9 +74,7 @@ export default {
if (this.isEmptyChildrenWithFilter) {
return this.infoSearchLabel;
}
return this.childrenFlags[this.itemId].itemExpanded
? __('Collapse child epics')
: __('Expand child epics');
return this.childrenFlags[this.itemId].itemExpanded ? __('Collapse') : __('Expand');
},
childrenFetchInProgress() {
return this.epic.hasChildren && this.childrenFlags[this.itemId].itemChildrenFetchInProgress;
......@@ -108,9 +106,12 @@ export default {
</script>
<template>
<div class="epic-details-cell" data-qa-selector="epic_details_cell">
<div
class="epic-details-cell gl-display-flex gl-flex-direction-column gl-justify-content-center"
data-qa-selector="epic_details_cell"
>
<div
class="d-flex align-items-start p-2"
class="gl-display-flex align-items-start gl-px-3 gl-mb-1"
:class="[epic.isChildEpic ? childMarginClassname : '']"
>
<span ref="expandCollapseInfo">
......@@ -130,38 +131,44 @@ export default {
</gl-button>
</span>
<gl-tooltip
v-if="isEmptyChildrenWithFilter"
v-if="!isExpandIconHidden"
ref="expandIconTooltip"
triggers="hover"
:target="() => $refs.expandCollapseInfo"
boundary="viewport"
offset="80"
offset="15"
placement="topright"
>
{{ infoSearchLabel }}
{{ expandIconLabel }}
</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">
<a
:href="epic.webUrl"
:title="epic.title"
class="epic-title gl-mt-1 d-block text-body bold"
>
{{ epic.title }}
</a>
<div class="epic-group-timeframe d-flex text-secondary">
<p
<span
v-if="isEpicGroupDifferent && !epic.hasParent"
:title="epic.groupFullName"
class="epic-group"
>
{{ epic.groupName }}
</p>
</span>
<span v-if="isEpicGroupDifferent && !epic.hasParent" class="mx-1" aria-hidden="true"
>&middot;</span
>
<p class="epic-timeframe" :title="timeframeString">{{ timeframeString }}</p>
<span class="epic-timeframe" :title="timeframeString">{{ timeframeString }}</span>
</div>
</div>
<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" />
<p class="m-0" :aria-label="childEpicsCountText">{{ childEpicsCount }}</p>
</div>
<gl-tooltip :target="() => $refs.childEpicsCount">
<gl-tooltip ref="childEpicsCountTooltip" :target="() => $refs.childEpicsCount">
<span :class="{ bold: hasFiltersApplied }">{{ childEpicsCountText }}</span>
<span v-if="hasFiltersApplied" class="d-block">{{ childEpicsSearchText }}</span>
</gl-tooltip>
......
......@@ -24,6 +24,10 @@ export default {
type: Number,
required: true,
},
milestonesExpanded: {
type: Boolean,
required: true,
},
},
};
</script>
......@@ -33,19 +37,22 @@ export default {
<span
v-for="timeframeItem in timeframe"
: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"
>
<current-day-indicator :preset-type="presetType" :timeframe-item="timeframeItem" />
<milestone-item
v-for="milestone in milestones"
:key="milestone.id"
:preset-type="presetType"
:milestone="milestone"
:timeframe="timeframe"
:timeframe-item="timeframeItem"
:current-group-id="currentGroupId"
/>
<template v-if="milestonesExpanded">
<milestone-item
v-for="milestone in milestones"
:key="milestone.id"
:preset-type="presetType"
:milestone="milestone"
:timeframe="timeframe"
:timeframe-item="timeframeItem"
:current-group-id="currentGroupId"
/>
</template>
</span>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
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 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 {
components: {
MilestoneTimeline,
GlButton,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
presetType: {
......@@ -31,6 +50,7 @@ export default {
offsetLeft: 0,
showBottomShadow: false,
roadmapShellEl: null,
milestonesExpanded: true,
};
},
computed: {
......@@ -48,6 +68,17 @@ export default {
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() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
......@@ -76,23 +107,59 @@ export default {
handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) {
this.showBottomShadow = Math.ceil(scrollTop) + clientHeight < scrollHeight;
},
toggleMilestonesExpanded() {
this.milestonesExpanded = !this.milestonesExpanded;
},
},
};
</script>
<template>
<div :style="sectionContainerStyles" class="milestones-list-section d-table">
<div
:style="sectionContainerStyles"
class="milestones-list-section gl-display-table"
:class="{ 'milestones-list-section-collapsed': !milestonesExpanded }"
>
<div
class="milestones-list-title d-table-cell bold border-bottom align-top position-sticky pt-2 pl-3"
class="milestones-list-title gl-display-table-cell border-bottom gl-vertical-align-top position-sticky gl-p-3"
>
{{ __('Milestones') }}
<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') }}
</div>
<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 d-table-cell">
<div class="milestones-list-items gl-display-table-cell">
<milestone-timeline
:preset-type="presetType"
:timeframe="timeframe"
:milestones="milestones"
:current-group-id="currentGroupId"
:milestones-expanded="milestonesExpanded"
/>
</div>
<div v-show="showBottomShadow" :style="shadowCellStyles" class="scroll-bottom-shadow"></div>
......
$header-item-height: 60px;
$item-height: 50px;
$milestones-collapsed-height: 38px;
$details-cell-width: 320px;
$timeline-cell-width: 180px;
$border-style: 1px solid $border-gray-normal;
......@@ -408,11 +409,19 @@ html.group-epics-roadmap-html {
}
.milestones-list-section {
&.milestones-list-section-collapsed {
height: $milestones-collapsed-height;
}
.milestones-list-items {
.milestone-timeline-cell {
width: $timeline-cell-width;
}
.milestone-timeline-cell-empty {
height: $milestones-collapsed-height;
}
.timeline-bar-wrapper {
height: 32px;
color: $gray-700;
......
---
title: Allow Hiding/Collapsing of Milestone header on Roadmap
merge_request: 34357
author:
type: added
import Vue from 'vue';
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 { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
......@@ -11,24 +10,6 @@ import { mockTimeframeInitialDate, mockMilestone2, mockGroupId } from 'ee_jest/r
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', () => {
let wrapper;
......@@ -36,17 +17,39 @@ describe('MilestoneTimelineComponent', () => {
wrapper.destroy();
});
describe('template', () => {
it('renders component container element with class `milestone-timeline-cell`', () => {
wrapper = createComponent();
expect(wrapper.find('.milestone-timeline-cell').exists()).toBe(true);
const createWrapper = (props = {}) => {
wrapper = shallowMount(MilestoneTimelineComponent, {
propsData: {
presetType: PRESET_TYPES.MONTHS,
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', () => {
wrapper = createComponent();
it(`renders timeline cell with empty class = ${hasCellEmpty}`, () => {
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 milestonesListSectionComponent from 'ee/roadmap/components/milestones_list_section.vue';
import MilestoneTimeline from 'ee/roadmap/components/milestone_timeline.vue';
......@@ -9,57 +10,72 @@ import {
TIMELINE_CELL_MIN_WIDTH,
} from 'ee/roadmap/constants';
import { mockTimeframeInitialDate, mockGroupId, rawMilestones } from 'ee_jest/roadmap/mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const store = createStore();
store.dispatch('setInitialData', {
currentGroupId: mockGroupId,
presetType: PRESET_TYPES.MONTHS,
timeframe: mockTimeframeMonths,
});
store.dispatch('receiveMilestonesSuccess', { rawMilestones });
const mockMilestones = store.state.milestones;
const createComponent = ({
milestones = mockMilestones,
timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId,
presetType = PRESET_TYPES.MONTHS,
} = {}) => {
const localVue = createLocalVue();
return shallowMount(milestonesListSectionComponent, {
localVue,
store,
stubs: {
MilestoneTimeline: false,
},
propsData: {
presetType,
milestones,
timeframe,
currentGroupId,
},
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
const initializeStore = mockTimeframeMonths => {
const store = createStore();
store.dispatch('setInitialData', {
currentGroupId: mockGroupId,
presetType: PRESET_TYPES.MONTHS,
timeframe: mockTimeframeMonths,
});
store.dispatch('receiveMilestonesSuccess', { rawMilestones });
return store;
};
describe('MilestonesListSectionComponent', () => {
let wrapper;
let store;
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();
wrapper = shallowMount(milestonesListSectionComponent, {
localVue,
store,
stubs: {
MilestoneTimeline: false,
},
propsData: {
milestones: store.state.milestones,
timeframe: mockTimeframeMonths,
currentGroupId: mockGroupId,
presetType: PRESET_TYPES.MONTHS,
...props,
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
beforeEach(() => {
wrapper = createComponent();
store = initializeStore(mockTimeframeMonths);
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('data', () => {
it('returns default data props', () => {
expect(wrapper.vm.offsetLeft).toBe(0);
expect(wrapper.vm.roadmapShellEl).toBeDefined();
expect(wrapper.vm.milestonesExpanded).toBe(true);
});
});
......@@ -134,5 +150,41 @@ describe('MilestonesListSectionComponent', () => {
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"
msgstr[0] ""
msgstr[1] ""
msgid "%d milestone"
msgid_plural "%d milestones"
msgstr[0] ""
msgstr[1] ""
msgid "%d minute"
msgid_plural "%d minutes"
msgstr[0] ""
......@@ -5689,7 +5694,7 @@ msgstr ""
msgid "Collapse approvers"
msgstr ""
msgid "Collapse child epics"
msgid "Collapse milestones"
msgstr ""
msgid "Collapse replies"
......@@ -9296,15 +9301,15 @@ msgstr ""
msgid "Expand approvers"
msgstr ""
msgid "Expand child epics"
msgstr ""
msgid "Expand down"
msgstr ""
msgid "Expand dropdown"
msgstr ""
msgid "Expand milestones"
msgstr ""
msgid "Expand sidebar"
msgstr ""
......
......@@ -2,13 +2,21 @@ export const getKey = name => `$_gl_jest_${name}`;
export const getBinding = (el, name) => el[getKey(name)];
const writeBindingToElement = (el, { name, value, arg, modifiers }) => {
el[getKey(name)] = {
value,
arg,
modifiers,
};
};
export const createMockDirective = () => ({
bind(el, { name, value, arg, modifiers }) {
el[getKey(name)] = {
value,
arg,
modifiers,
};
bind(el, binding) {
writeBindingToElement(el, binding);
},
update(el, binding) {
writeBindingToElement(el, binding);
},
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