Commit e786fede authored by Florie Guibert's avatar Florie Guibert

Add milestones to roadmap

- Mock milestones

Show milestones in Roadmap

- Add milestones list section to roadmap

Show milestones in Roadmap

- Milestones stack
- Popover on hover milestone
- Support for quarters filters
- Milestone title displays on roadmap

Show milestones in Roadmap

- Dashed lines on hover
- Fix popover alignment on milestone timeline bar

Add Milestones to Roadmap

- Milestones stack without overlapping for weeks and months
- Limited stacking on quarters, without overlap

Add Milestones to Roadmap

- Change empty roadmap conditions

Add milestones to roadmap

- Connect to graphql backend
- Refresh milestone dates

Add milestones to roadmap

- Style fixes
- Tests

Add Milestones in Roadmap

- Remove stacking of milestones

Add Milestones in Roadmap

- Introduce feature flag :milestones_in_roadmap

Add Milestones in Roadmap

- Implement feedback
- Use CSS utility classes
- Clean up CSS
- Migrate karma tests to jest

Add milestones to roadmap

- CSS tweaks
- Better styling for small milestones
parent 982edbcd
<script> <script>
import _ from 'underscore'; import _ from 'underscore';
import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility';
import epicItemDetails from './epic_item_details.vue'; import epicItemDetails from './epic_item_details.vue';
import epicItemTimeline from './epic_item_timeline.vue'; import epicItemTimeline from './epic_item_timeline.vue';
import CommonMixin from '../mixins/common_mixin';
import { EPIC_HIGHLIGHT_REMOVE_AFTER } from '../constants'; import { EPIC_HIGHLIGHT_REMOVE_AFTER } from '../constants';
export default { export default {
...@@ -13,6 +13,7 @@ export default { ...@@ -13,6 +13,7 @@ export default {
epicItemDetails, epicItemDetails,
epicItemTimeline, epicItemTimeline,
}, },
mixins: [CommonMixin],
props: { props: {
presetType: { presetType: {
type: String, type: String,
...@@ -58,35 +59,6 @@ export default { ...@@ -58,35 +59,6 @@ export default {
} }
return this.epic.endDate; return this.epic.endDate;
}, },
/**
* Compose timeframe string to show on UI
* based on start and end date availability
*/
timeframeString() {
if (this.epic.startDateUndefined) {
return sprintf(s__('GroupRoadmap|No start date – %{dateWord}'), {
dateWord: dateInWords(this.endDate, true),
});
} else if (this.epic.endDateUndefined) {
return sprintf(s__('GroupRoadmap|%{dateWord} – No end date'), {
dateWord: dateInWords(this.startDate, true),
});
}
// In case both start and end date fall in same year
// We should hide year from start date
const startDateInWords = dateInWords(
this.startDate,
true,
this.startDate.getFullYear() === this.endDate.getFullYear(),
);
const endDateInWords = dateInWords(this.endDate, true);
return sprintf(s__('GroupRoadmap|%{startDateInWords} – %{endDateInWords}'), {
startDateInWords,
endDateInWords,
});
},
}, },
updated() { updated() {
this.removeHighlight(); this.removeHighlight();
...@@ -121,7 +93,7 @@ export default { ...@@ -121,7 +93,7 @@ export default {
<epic-item-details <epic-item-details
:epic="epic" :epic="epic"
:current-group-id="currentGroupId" :current-group-id="currentGroupId"
:timeframe-string="timeframeString" :timeframe-string="timeframeString(epic)"
/> />
<epic-item-timeline <epic-item-timeline
v-for="(timeframeItem, index) in timeframe" v-for="(timeframeItem, index) in timeframe"
...@@ -130,7 +102,6 @@ export default { ...@@ -130,7 +102,6 @@ export default {
:timeframe="timeframe" :timeframe="timeframe"
:timeframe-item="timeframeItem" :timeframe-item="timeframeItem"
:epic="epic" :epic="epic"
:timeframe-string="timeframeString"
:client-width="clientWidth" :client-width="clientWidth"
/> />
</div> </div>
......
...@@ -45,10 +45,6 @@ export default { ...@@ -45,10 +45,6 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
timeframeString: {
type: String,
required: true,
},
clientWidth: { clientWidth: {
type: Number, type: Number,
required: false, required: false,
...@@ -78,6 +74,27 @@ export default { ...@@ -78,6 +74,27 @@ export default {
time: endDate.getTime(), time: endDate.getTime(),
}; };
}, },
/**
* In case Epic start date is out of range
* we need to use original date instead of proxy date
*/
startDate() {
if (this.epic.startDateOutOfRange) {
return this.epic.originalStartDate;
}
return this.epic.startDate;
},
/**
* In case Epic end date is out of range
* we need to use original date instead of proxy date
*/
endDate() {
if (this.epic.endDateOutOfRange) {
return this.epic.originalEndDate;
}
return this.epic.endDate;
},
hasStartDate() { hasStartDate() {
if (this.presetTypeQuarters) { if (this.presetTypeQuarters) {
return this.hasStartDateForQuarter(); return this.hasStartDateForQuarter();
...@@ -88,30 +105,6 @@ export default { ...@@ -88,30 +105,6 @@ export default {
} }
return false; return false;
}, },
timelineBarStyles() {
let barStyles = {};
if (this.hasStartDate) {
if (this.presetTypeQuarters) {
// CSS properties are a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/24
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
barStyles = `width: ${this.getTimelineBarWidthForQuarters(
this.epic,
)}px; ${this.getTimelineBarStartOffsetForQuarters(this.epic)}`;
} else if (this.presetTypeMonths) {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
barStyles = `width: ${this.getTimelineBarWidthForMonths()}px; ${this.getTimelineBarStartOffsetForMonths(
this.epic,
)}`;
} else if (this.presetTypeWeeks) {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
barStyles = `width: ${this.getTimelineBarWidthForWeeks()}px; ${this.getTimelineBarStartOffsetForWeeks(
this.epic,
)}`;
}
}
return barStyles;
},
epicBarInnerStyle() { epicBarInnerStyle() {
return { return {
maxWidth: `${this.clientWidth - EPIC_DETAILS_CELL_WIDTH}px`, maxWidth: `${this.clientWidth - EPIC_DETAILS_CELL_WIDTH}px`,
...@@ -175,7 +168,7 @@ export default { ...@@ -175,7 +168,7 @@ export default {
v-if="hasStartDate" v-if="hasStartDate"
:id="`epic-bar-${epic.id}`" :id="`epic-bar-${epic.id}`"
:href="epic.webUrl" :href="epic.webUrl"
:style="timelineBarStyles" :style="timelineBarStyles(epic)"
class="epic-bar" class="epic-bar"
> >
<div class="epic-bar-inner" :style="epicBarInnerStyle"> <div class="epic-bar-inner" :style="epicBarInnerStyle">
...@@ -202,7 +195,7 @@ export default { ...@@ -202,7 +195,7 @@ export default {
triggers="hover focus" triggers="hover focus"
placement="right" placement="right"
> >
<p class="text-secondary m-0">{{ timeframeString }}</p> <p class="text-secondary m-0">{{ timeframeString(epic) }}</p>
<p class="m-0">{{ popoverWeightText }}</p> <p class="m-0">{{ popoverWeightText }}</p>
</gl-popover> </gl-popover>
</div> </div>
......
<script>
import { GlPopover } from '@gitlab/ui';
import CommonMixin from '../mixins/common_mixin';
import QuartersPresetMixin from '../mixins/quarters_preset_mixin';
import MonthsPresetMixin from '../mixins/months_preset_mixin';
import WeeksPresetMixin from '../mixins/weeks_preset_mixin';
import { TIMELINE_CELL_MIN_WIDTH, SCROLL_BAR_SIZE } from '../constants';
export default {
cellWidth: TIMELINE_CELL_MIN_WIDTH,
components: {
GlPopover,
},
mixins: [CommonMixin, QuartersPresetMixin, MonthsPresetMixin, WeeksPresetMixin],
props: {
presetType: {
type: String,
required: true,
},
timeframe: {
type: Array,
required: true,
},
timeframeItem: {
type: [Date, Object],
required: true,
},
milestone: {
type: Object,
required: true,
},
},
data() {
return {
hoverStyles: {},
};
},
computed: {
startDateValues() {
const { startDate } = this.milestone;
return {
day: startDate.getDay(),
date: startDate.getDate(),
month: startDate.getMonth(),
year: startDate.getFullYear(),
time: startDate.getTime(),
};
},
endDateValues() {
const { endDate } = this.milestone;
return {
day: endDate.getDay(),
date: endDate.getDate(),
month: endDate.getMonth(),
year: endDate.getFullYear(),
time: endDate.getTime(),
};
},
hasStartDate() {
if (this.presetTypeQuarters) {
return this.hasStartDateForQuarter();
} else if (this.presetTypeMonths) {
return this.hasStartDateForMonth();
} else if (this.presetTypeWeeks) {
return this.hasStartDateForWeek();
}
return false;
},
startDate() {
return this.milestone.startDateOutOfRange
? this.milestone.originalStartDate
: this.milestone.startDate;
},
endDate() {
return this.milestone.endDateOutOfRange
? this.milestone.originalEndDate
: this.milestone.endDate;
},
smallClass() {
const smallStyleClass = 'milestone-small';
const minimumStyleClass = 'milestone-minimum';
if (this.presetTypeQuarters) {
const width = this.getTimelineBarWidthForQuarters(this.milestone);
if (width < 9) {
return minimumStyleClass;
}
if (width < 12) {
return smallStyleClass;
}
} else if (this.presetTypeMonths) {
const width = this.getTimelineBarWidthForMonths();
if (width < 12) {
return smallStyleClass;
}
}
return '';
},
},
mounted() {
this.$nextTick(() => {
this.hoverStyles = this.getHoverStyles();
});
},
methods: {
getHoverStyles() {
const elHeight = this.$root.$el.getBoundingClientRect().y;
return {
height: `calc(100vh - ${elHeight + SCROLL_BAR_SIZE}px)`,
};
},
},
};
</script>
<template>
<div class="timeline-bar-wrapper">
<span
v-if="hasStartDate"
:class="[
{
'start-date-undefined': milestone.startDateUndefined,
'end-date-undefined': milestone.endDateUndefined,
},
smallClass,
]"
:style="timelineBarStyles(milestone)"
class="milestone-item-details d-inline-block position-absolute"
>
<a :href="milestone.webPath" class="milestone-url d-block">
<span
:id="`milestone-item-${milestone.id}`"
class="milestone-item-title str-truncated-100 bold position-sticky"
>{{ milestone.title }}</span
>
<span class="timeline-bar position-relative d-block"></span>
</a>
<div class="milestone-start-and-end position-relative" :style="hoverStyles"></div>
<gl-popover
:target="`milestone-item-${milestone.id}`"
boundary="viewport"
placement="lefttop"
triggers="hover"
:title="milestone.title"
>
{{ timeframeString(milestone) }}
</gl-popover>
</span>
</div>
</template>
<script>
import MilestoneItem from './milestone_item.vue';
import CurrentDayIndicator from './current_day_indicator.vue';
export default {
components: {
MilestoneItem,
CurrentDayIndicator,
},
props: {
presetType: {
type: String,
required: true,
},
timeframe: {
type: Array,
required: true,
},
milestones: {
type: Array,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
},
};
</script>
<template>
<div>
<span
v-for="timeframeItem in timeframe"
:key="timeframeItem.id"
class="milestone-timeline-cell d-table-cell position-relative border-right border-bottom"
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"
/>
</span>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import eventHub from '../event_hub';
import { EPIC_DETAILS_CELL_WIDTH, EPIC_ITEM_HEIGHT, TIMELINE_CELL_MIN_WIDTH } from '../constants';
import MilestoneTimeline from './milestone_timeline.vue';
export default {
components: {
MilestoneTimeline,
},
props: {
presetType: {
type: String,
required: true,
},
milestones: {
type: Array,
required: true,
},
timeframe: {
type: Array,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
},
data() {
return {
offsetLeft: 0,
showBottomShadow: false,
roadmapShellEl: null,
};
},
computed: {
...mapState(['bufferSize']),
emptyRowContainerVisible() {
return this.milestones.length < this.bufferSize;
},
sectionContainerStyles() {
return {
width: `${EPIC_DETAILS_CELL_WIDTH + TIMELINE_CELL_MIN_WIDTH * this.timeframe.length}px`,
};
},
shadowCellStyles() {
return {
left: `${this.offsetLeft}px`,
};
},
},
mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
this.initMounted();
},
beforeDestroy() {
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
},
methods: {
...mapActions(['setBufferSize']),
initMounted() {
this.roadmapShellEl = this.$root.$el && this.$root.$el.firstChild;
this.setBufferSize(Math.ceil((window.innerHeight - this.$el.offsetTop) / EPIC_ITEM_HEIGHT));
this.$nextTick(() => {
this.offsetLeft = (this.$el.parentElement && this.$el.parentElement.offsetLeft) || 0;
this.$nextTick(() => {
this.scrollToTodayIndicator();
});
});
},
scrollToTodayIndicator() {
if (this.$el.parentElement) this.$el.parentElement.scrollBy(TIMELINE_CELL_MIN_WIDTH / 2, 0);
},
handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) {
this.showBottomShadow = Math.ceil(scrollTop) + clientHeight < scrollHeight;
},
},
};
</script>
<template>
<div :style="sectionContainerStyles" class="milestones-list-section d-table">
<div
class="milestones-list-title d-table-cell bold border-bottom align-top position-sticky pt-2 pl-3"
>
{{ __('Milestones') }}
</div>
<div class="milestones-list-items d-table-cell">
<milestone-timeline
:preset-type="presetType"
:timeframe="timeframe"
:milestones="milestones"
:current-group-id="currentGroupId"
/>
</div>
<div v-show="showBottomShadow" :style="shadowCellStyles" class="scroll-bottom-shadow"></div>
</div>
</template>
...@@ -46,6 +46,7 @@ export default { ...@@ -46,6 +46,7 @@ export default {
...mapState([ ...mapState([
'currentGroupId', 'currentGroupId',
'epics', 'epics',
'milestones',
'timeframe', 'timeframe',
'extendedTimeframe', 'extendedTimeframe',
'windowResizeInProgress', 'windowResizeInProgress',
...@@ -54,6 +55,7 @@ export default { ...@@ -54,6 +55,7 @@ export default {
'epicsFetchResultEmpty', 'epicsFetchResultEmpty',
'epicsFetchFailure', 'epicsFetchFailure',
'isChildEpics', 'isChildEpics',
'milestonesFetchFailure',
]), ]),
timeframeStart() { timeframeStart() {
return this.timeframe[0]; return this.timeframe[0];
...@@ -73,6 +75,7 @@ export default { ...@@ -73,6 +75,7 @@ export default {
}, },
mounted() { mounted() {
this.fetchEpicsFn(); this.fetchEpicsFn();
this.fetchMilestones();
}, },
methods: { methods: {
...mapActions([ ...mapActions([
...@@ -83,6 +86,8 @@ export default { ...@@ -83,6 +86,8 @@ export default {
'fetchEpicsForTimeframeGQL', 'fetchEpicsForTimeframeGQL',
'extendTimeframe', 'extendTimeframe',
'refreshEpicDates', 'refreshEpicDates',
'fetchMilestones',
'refreshMilestoneDates',
]), ]),
/** /**
* Once timeline is expanded (either with prepend or append) * Once timeline is expanded (either with prepend or append)
...@@ -113,6 +118,7 @@ export default { ...@@ -113,6 +118,7 @@ export default {
handleScrollToExtend(roadmapTimelineEl, extendType = EXTEND_AS.PREPEND) { handleScrollToExtend(roadmapTimelineEl, extendType = EXTEND_AS.PREPEND) {
this.extendTimeframe({ extendAs: extendType }); this.extendTimeframe({ extendAs: extendType });
this.refreshEpicDates(); this.refreshEpicDates();
this.refreshMilestoneDates();
this.$nextTick(() => { this.$nextTick(() => {
this.fetchEpicsForTimeframeFn({ this.fetchEpicsForTimeframeFn({
...@@ -141,6 +147,7 @@ export default { ...@@ -141,6 +147,7 @@ export default {
v-if="showRoadmap" v-if="showRoadmap"
:preset-type="presetType" :preset-type="presetType"
:epics="epics" :epics="epics"
:milestones="milestones"
:timeframe="timeframe" :timeframe="timeframe"
:current-group-id="currentGroupId" :current-group-id="currentGroupId"
@onScrollToStart="handleScrollToExtend" @onScrollToStart="handleScrollToExtend"
......
...@@ -7,12 +7,14 @@ import { EXTEND_AS } from '../constants'; ...@@ -7,12 +7,14 @@ import { EXTEND_AS } from '../constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import epicsListSection from './epics_list_section.vue'; import epicsListSection from './epics_list_section.vue';
import milestonesListSection from './milestones_list_section.vue';
import roadmapTimelineSection from './roadmap_timeline_section.vue'; import roadmapTimelineSection from './roadmap_timeline_section.vue';
export default { export default {
components: { components: {
GlSkeletonLoading, GlSkeletonLoading,
epicsListSection, epicsListSection,
milestonesListSection,
roadmapTimelineSection, roadmapTimelineSection,
}, },
props: { props: {
...@@ -24,6 +26,10 @@ export default { ...@@ -24,6 +26,10 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
milestones: {
type: Array,
required: true,
},
timeframe: { timeframe: {
type: Array, type: Array,
required: true, required: true,
...@@ -34,12 +40,17 @@ export default { ...@@ -34,12 +40,17 @@ export default {
}, },
}, },
data() { data() {
const milestonesInRoadmap = gon.features && gon.features.milestonesInRoadmap;
return { return {
timeframeStartOffset: 0, timeframeStartOffset: 0,
milestonesInRoadmap,
}; };
}, },
computed: { computed: {
...mapState(['defaultInnerHeight']), ...mapState(['defaultInnerHeight']),
displayMilestones() {
return this.milestonesInRoadmap && this.milestones.length !== 0;
},
}, },
mounted() { mounted() {
this.$nextTick(() => { this.$nextTick(() => {
...@@ -85,6 +96,13 @@ export default { ...@@ -85,6 +96,13 @@ export default {
:epics="epics" :epics="epics"
:timeframe="timeframe" :timeframe="timeframe"
/> />
<milestones-list-section
v-if="displayMilestones"
:preset-type="presetType"
:milestones="milestones"
:timeframe="timeframe"
:current-group-id="currentGroupId"
/>
<div v-if="!epics.length" class="skeleton-loader js-skeleton-loader"> <div v-if="!epics.length" class="skeleton-loader js-skeleton-loader">
<div v-for="n in 10" :key="n" class="mt-2"> <div v-for="n in 10" :key="n" class="mt-2">
<gl-skeleton-loading :lines="2" /> <gl-skeleton-loading :lines="2" />
......
import { totalDaysInMonth, dayInQuarter, totalDaysInQuarter } from '~/lib/utils/datetime_utility'; import { s__, sprintf } from '~/locale';
import {
dateInWords,
totalDaysInMonth,
dayInQuarter,
totalDaysInQuarter,
} from '~/lib/utils/datetime_utility';
import { PRESET_TYPES, DAYS_IN_WEEK } from '../constants'; import { PRESET_TYPES, DAYS_IN_WEEK } from '../constants';
...@@ -68,5 +74,54 @@ export default { ...@@ -68,5 +74,54 @@ export default {
left: `${left}%`, left: `${left}%`,
}; };
}, },
timeframeString(roadmapItem) {
if (roadmapItem.startDateUndefined) {
return sprintf(s__('GroupRoadmap|No start date – %{dateWord}'), {
dateWord: dateInWords(this.endDate, true),
});
} else if (roadmapItem.endDateUndefined) {
return sprintf(s__('GroupRoadmap|%{dateWord} – No end date'), {
dateWord: dateInWords(this.startDate, true),
});
}
// In case both start and end date fall in same year
// We should hide year from start date
const startDateInWords = dateInWords(
this.startDate,
true,
this.startDate.getFullYear() === this.endDate.getFullYear(),
);
const endDateInWords = dateInWords(this.endDate, true);
return sprintf(s__('GroupRoadmap|%{startDateInWords} – %{endDateInWords}'), {
startDateInWords,
endDateInWords,
});
},
timelineBarStyles(roadmapItem) {
let barStyles = {};
if (this.hasStartDate) {
if (this.presetTypeQuarters) {
// CSS properties are a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/24
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
barStyles = `width: ${this.getTimelineBarWidthForQuarters(
roadmapItem,
)}px; ${this.getTimelineBarStartOffsetForQuarters(roadmapItem)}`;
} else if (this.presetTypeMonths) {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
barStyles = `width: ${this.getTimelineBarWidthForMonths()}px; ${this.getTimelineBarStartOffsetForMonths(
roadmapItem,
)}`;
} else if (this.presetTypeWeeks) {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
barStyles = `width: ${this.getTimelineBarWidthForWeeks()}px; ${this.getTimelineBarStartOffsetForWeeks(
roadmapItem,
)}`;
}
}
return barStyles;
},
}, },
}; };
query groupMilestones(
$fullPath: ID!
$state: MilestoneStateEnum
$startDate: Time
$dueDate: Time
) {
group(fullPath: $fullPath) {
id
name
milestones(
state: $state
startDate: $startDate
endDate: $dueDate
) {
edges {
node {
id
description
title
state
dueDate
startDate
webPath
}
}
}
}
}
...@@ -10,12 +10,14 @@ import { ...@@ -10,12 +10,14 @@ import {
sortEpics, sortEpics,
extendTimeframeForPreset, extendTimeframeForPreset,
} from '../utils/roadmap_utils'; } from '../utils/roadmap_utils';
import { EXTEND_AS } from '../constants'; import { EXTEND_AS } from '../constants';
import groupEpics from '../queries/groupEpics.query.graphql'; import groupEpics from '../queries/groupEpics.query.graphql';
import epicChildEpics from '../queries/epicChildEpics.query.graphql'; import epicChildEpics from '../queries/epicChildEpics.query.graphql';
import groupEpicsForUnfilteredEpicAggregatesFeatureFlag from '../queries/groupEpicsForUnfilteredEpicAggregatesFeatureFlag.query.graphql'; import groupEpicsForUnfilteredEpicAggregatesFeatureFlag from '../queries/groupEpicsForUnfilteredEpicAggregatesFeatureFlag.query.graphql';
import epicChildEpicsForUnfilteredEpicAggregatesFeatureFlag from '../queries/epicChildEpicsForUnfilteredEpicAggregatesFeatureFlag.query.graphql'; import epicChildEpicsForUnfilteredEpicAggregatesFeatureFlag from '../queries/epicChildEpicsForUnfilteredEpicAggregatesFeatureFlag.query.graphql';
import groupMilestones from '../queries/groupMilestones.query.graphql';
import * as types from './mutation_types'; import * as types from './mutation_types';
...@@ -205,6 +207,94 @@ export const refreshEpicDates = ({ commit, state, getters }) => { ...@@ -205,6 +207,94 @@ export const refreshEpicDates = ({ commit, state, getters }) => {
commit(types.SET_EPICS, epics); commit(types.SET_EPICS, epics);
}; };
export const fetchGroupMilestones = (
{ fullPath, presetType, filterParams, timeframe },
defaultTimeframe,
) => {
const query = groupMilestones;
const variables = {
fullPath,
state: 'active',
...getEpicsTimeframeRange({
presetType,
timeframe: defaultTimeframe || timeframe,
}),
...filterParams,
};
return epicUtils.gqClient
.query({
query,
variables,
})
.then(({ data }) => {
const { group } = data;
const edges = (group.milestones && group.milestones.edges) || [];
return roadmapItemUtils.extractGroupMilestones(edges);
});
};
export const requestMilestones = ({ commit }) => commit(types.REQUEST_MILESTONES);
export const fetchMilestones = ({ state, dispatch }) => {
dispatch('requestMilestones');
return fetchGroupMilestones(state)
.then(rawMilestones => {
dispatch('receiveMilestonesSuccess', { rawMilestones });
})
.catch(() => dispatch('receiveMilestonesFailure'));
};
export const receiveMilestonesSuccess = (
{ commit, state, getters },
{ rawMilestones, newMilestone }, // timeframeExtended
) => {
const milestoneIds = [];
const milestones = rawMilestones.reduce((filteredMilestones, milestone) => {
const formattedMilestone = roadmapItemUtils.formatRoadmapItemDetails(
milestone,
getters.timeframeStartDate,
getters.timeframeEndDate,
);
// Exclude any Milestone that has invalid dates
// or is already present in Roadmap timeline
if (
formattedMilestone.startDate.getTime() <= formattedMilestone.endDate.getTime() &&
state.milestoneIds.indexOf(formattedMilestone.id) < 0
) {
Object.assign(formattedMilestone, {
newMilestone,
});
filteredMilestones.push(formattedMilestone);
milestoneIds.push(formattedMilestone.id);
}
return filteredMilestones;
}, []);
commit(types.UPDATE_MILESTONE_IDS, milestoneIds);
commit(types.RECEIVE_MILESTONES_SUCCESS, milestones);
};
export const receiveMilestonesFailure = ({ commit }) => {
commit(types.RECEIVE_MILESTONES_FAILURE);
flash(s__('GroupRoadmap|Something went wrong while fetching milestones'));
};
export const refreshMilestoneDates = ({ commit, state, getters }) => {
const milestones = state.milestones.map(milestone =>
roadmapItemUtils.processRoadmapItemDates(
milestone,
getters.timeframeStartDate,
getters.timeframeEndDate,
),
);
commit(types.SET_MILESTONES, milestones);
};
export const setBufferSize = ({ commit }, bufferSize) => commit(types.SET_BUFFER_SIZE, bufferSize); export const setBufferSize = ({ commit }, bufferSize) => commit(types.SET_BUFFER_SIZE, bufferSize);
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
......
...@@ -16,4 +16,11 @@ export const RECEIVE_EPICS_FAILURE = 'RECEIVE_EPICS_FAILURE'; ...@@ -16,4 +16,11 @@ export const RECEIVE_EPICS_FAILURE = 'RECEIVE_EPICS_FAILURE';
export const PREPEND_TIMEFRAME = 'PREPEND_TIMEFRAME'; export const PREPEND_TIMEFRAME = 'PREPEND_TIMEFRAME';
export const APPEND_TIMEFRAME = 'APPEND_TIMEFRAME'; export const APPEND_TIMEFRAME = 'APPEND_TIMEFRAME';
export const SET_MILESTONES = 'SET_MILESTONES';
export const UPDATE_MILESTONE_IDS = 'UPDATE_MILESTONE_IDS';
export const REQUEST_MILESTONES = 'REQUEST_MILESTONES';
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 SET_BUFFER_SIZE = 'SET_BUFFER_SIZE';
...@@ -51,6 +51,28 @@ export default { ...@@ -51,6 +51,28 @@ export default {
state.timeframe.push(...extendedTimeframe); state.timeframe.push(...extendedTimeframe);
}, },
[types.SET_MILESTONES](state, milestones) {
state.milestones = milestones;
},
[types.UPDATE_MILESTONE_IDS](state, milestoneIds) {
state.milestoneIds.push(...milestoneIds);
},
[types.REQUEST_MILESTONES](state) {
state.milestonesFetchInProgress = true;
},
[types.RECEIVE_MILESTONES_SUCCESS](state, milestones) {
state.milestonesFetchInProgress = false;
state.milestonesFetchResultEmpty = milestones.length === 0;
if (!state.milestonesFetchResultEmpty) {
state.milestones = milestones;
}
},
[types.RECEIVE_MILESTONES_FAILURE](state) {
state.milestonesFetchInProgress = false;
state.milestonesFetchFailure = true;
},
[types.SET_BUFFER_SIZE](state, bufferSize) { [types.SET_BUFFER_SIZE](state, bufferSize) {
state.bufferSize = bufferSize; state.bufferSize = bufferSize;
}, },
......
...@@ -17,6 +17,8 @@ export default () => ({ ...@@ -17,6 +17,8 @@ export default () => ({
extendedTimeframe: [], extendedTimeframe: [],
presetType: '', presetType: '',
sortedBy: '', sortedBy: '',
milestoneIds: [],
milestones: [],
bufferSize: 0, bufferSize: 0,
// UI Flags // UI Flags
...@@ -27,4 +29,7 @@ export default () => ({ ...@@ -27,4 +29,7 @@ export default () => ({
epicsFetchForTimeframeInProgress: false, epicsFetchForTimeframeInProgress: false,
epicsFetchFailure: false, epicsFetchFailure: false,
epicsFetchResultEmpty: false, epicsFetchResultEmpty: false,
milestonesFetchInProgress: false,
milestonesFetchFailure: false,
milestonesFetchResultEmpty: false,
}); });
...@@ -98,3 +98,14 @@ export const formatRoadmapItemDetails = (rawRoadmapItem, timeframeStartDate, tim ...@@ -98,3 +98,14 @@ export const formatRoadmapItemDetails = (rawRoadmapItem, timeframeStartDate, tim
return roadmapItem; return roadmapItem;
}; };
/**
* Returns array of milestones extracted from GraphQL response
* discarding the `edges`->`node` nesting
*
* @param {Object} group
*/
export const extractGroupMilestones = edges =>
edges.map(({ node, milestoneNode = node }) => ({
...milestoneNode,
}));
...@@ -147,6 +147,7 @@ html.group-epics-roadmap-html { ...@@ -147,6 +147,7 @@ html.group-epics-roadmap-html {
.roadmap-timeline-section .timeline-header-blank::after, .roadmap-timeline-section .timeline-header-blank::after,
.epics-list-section .epic-details-cell::after, .epics-list-section .epic-details-cell::after,
.milestones-list-section .milestones-list-title::after,
.skeleton-loader::after { .skeleton-loader::after {
content: ''; content: '';
position: absolute; position: absolute;
...@@ -344,14 +345,6 @@ html.group-epics-roadmap-html { ...@@ -344,14 +345,6 @@ html.group-epics-roadmap-html {
background-color: transparent; background-color: transparent;
border-right: $border-style; border-right: $border-style;
.current-day-indicator {
top: -1px;
width: 2px;
height: calc(100% + 1px);
background-color: $red-500;
pointer-events: none;
}
&:last-child { &:last-child {
border-right: 0; border-right: 0;
} }
...@@ -399,3 +392,154 @@ html.group-epics-roadmap-html { ...@@ -399,3 +392,154 @@ html.group-epics-roadmap-html {
z-index: 2; z-index: 2;
} }
.epic-timeline-cell,
.milestone-timeline-cell {
.current-day-indicator {
top: -1px;
width: 2px;
height: calc(100% + 1px);
background-color: $red-500;
pointer-events: none;
}
}
.milestones-list-section {
.milestones-list-items {
.milestone-timeline-cell {
width: $timeline-cell-width;
}
.timeline-bar-wrapper {
height: 32px;
color: $gray-700;
}
.milestone-start-and-end {
display: none;
border-left: 2px dotted $gray-900;
border-right: 2px dotted $gray-900;
opacity: 0.5;
top: 1px;
}
.milestone-item-details {
z-index: 1;
&:hover .milestone-start-and-end {
display: block;
}
}
.milestone-item-title {
left: $details-cell-width + $grid-size;
height: 30px;
z-index: 2;
}
a.milestone-url {
color: inherit;
max-width: 100%;
&:hover {
color: $gray-900;
cursor: pointer;
.timeline-bar {
background-color: $gray-900;
&::before {
background-color: $gray-900;
}
&::after {
border-color: $gray-900;
}
}
}
}
.milestone-small,
.milestone-minimum {
.milestone-item-title {
width: 100%;
text-indent: -9999px;
&::after {
position: absolute;
left: 0;
}
}
}
.milestone-small {
.milestone-item-title::after {
content: '...';
text-indent: 0;
}
}
.milestone-minimum {
// We need important here to overwrite inline width which depends on dates
width: 8px !important;
.milestone-item-title::after {
content: '.';
text-indent: 5px;
}
.timeline-bar {
height: 0;
&::before {
display: none;
}
}
.milestone-start-and-end {
border-left: 0;
}
}
.timeline-bar {
width: 100%;
background-color: $gray-700;
height: 2px;
z-index: 1;
bottom: 4px;
&::before,
&::after {
content: '';
position: absolute;
top: -3px;
height: 8px;
}
&::before {
width: 2px;
background-color: $gray-700;
}
&::after {
right: -3px;
width: 8px;
border: 2px solid $gray-700;
border-radius: 4px;
background-color: $white-light;
}
}
}
.milestones-list-title {
height: 100%;
left: 0;
width: $details-cell-width;
font-size: $code-font-size;
background-color: $white-light;
z-index: 2;
&::after {
height: 100%;
}
}
}
...@@ -13,6 +13,7 @@ module Groups ...@@ -13,6 +13,7 @@ module Groups
push_frontend_feature_flag(:roadmap_graphql, @group) push_frontend_feature_flag(:roadmap_graphql, @group)
push_frontend_feature_flag(:unfiltered_epic_aggregates, @group) push_frontend_feature_flag(:unfiltered_epic_aggregates, @group)
push_frontend_feature_flag(:roadmap_buffered_rendering, @group) push_frontend_feature_flag(:roadmap_buffered_rendering, @group)
push_frontend_feature_flag(:milestones_in_roadmap, @group)
end end
# show roadmap for a group # show roadmap for a group
......
---
title: Add milestones to roadmap
merge_request: 22748
author:
type: added
import Vue from 'vue';
import milestoneItemComponent from 'ee/roadmap/components/milestone_item.vue';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import { mount } from '@vue/test-utils';
import { mockTimeframeInitialDate, mockMilestone2 } from '../../../javascripts/roadmap/mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
milestone = mockMilestone2,
timeframe = mockTimeframeMonths,
timeframeItem = mockTimeframeMonths[0],
}) => {
const Component = Vue.extend(milestoneItemComponent);
return mount(Component, {
propsData: {
presetType,
milestone,
timeframe,
timeframeItem,
},
stubs: { GlPopover: true },
});
};
describe('MilestoneItemComponent', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent({});
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('computed', () => {
describe('startDateValues', () => {
it('returns object containing date parts from milestone.startDate', () => {
expect(wrapper.vm.startDateValues).toEqual(
jasmine.objectContaining({
day: mockMilestone2.startDate.getDay(),
date: mockMilestone2.startDate.getDate(),
month: mockMilestone2.startDate.getMonth(),
year: mockMilestone2.startDate.getFullYear(),
time: mockMilestone2.startDate.getTime(),
}),
);
});
});
describe('endDateValues', () => {
it('returns object containing date parts from milestone.endDate', () => {
expect(wrapper.vm.endDateValues).toEqual(
jasmine.objectContaining({
day: mockMilestone2.endDate.getDay(),
date: mockMilestone2.endDate.getDate(),
month: mockMilestone2.endDate.getMonth(),
year: mockMilestone2.endDate.getFullYear(),
time: mockMilestone2.endDate.getTime(),
}),
);
});
});
it('returns Milestone.startDate when start date is within range', () => {
wrapper = createComponent({ milestone: mockMilestone2 });
expect(wrapper.vm.startDate).toBe(mockMilestone2.startDate);
});
it('returns Milestone.originalStartDate when start date is out of range', () => {
const mockStartDate = new Date(2018, 0, 1);
const mockMilestoneItem = Object.assign({}, mockMilestone2, {
startDateOutOfRange: true,
originalStartDate: mockStartDate,
});
wrapper = createComponent({ milestone: mockMilestoneItem });
expect(wrapper.vm.startDate).toBe(mockStartDate);
});
});
describe('endDate', () => {
it('returns Milestone.endDate when end date is within range', () => {
wrapper = createComponent({ milestone: mockMilestone2 });
expect(wrapper.vm.endDate).toBe(mockMilestone2.endDate);
});
it('returns Milestone.originalEndDate when end date is out of range', () => {
const mockEndDate = new Date(2018, 0, 1);
const mockMilestoneItem = Object.assign({}, mockMilestone2, {
endDateOutOfRange: true,
originalEndDate: mockEndDate,
});
wrapper = createComponent({ milestone: mockMilestoneItem });
expect(wrapper.vm.endDate).toBe(mockEndDate);
});
});
describe('timeframeString', () => {
it('returns timeframe string correctly when both start and end dates are defined', () => {
wrapper = createComponent({ milestone: mockMilestone2 });
expect(wrapper.vm.timeframeString(mockMilestone2)).toBe('Nov 10, 2017 – Jul 2, 2018');
});
it('returns timeframe string correctly when only start date is defined', () => {
const mockMilestoneItem = Object.assign({}, mockMilestone2, {
endDateUndefined: true,
});
wrapper = createComponent({ milestone: mockMilestoneItem });
expect(wrapper.vm.timeframeString(mockMilestoneItem)).toBe('Nov 10, 2017 – No end date');
});
it('returns timeframe string correctly when only end date is defined', () => {
const mockMilestoneItem = Object.assign({}, mockMilestone2, {
startDateUndefined: true,
});
wrapper = createComponent({ milestone: mockMilestoneItem });
expect(wrapper.vm.timeframeString(mockMilestoneItem)).toBe('No start date – Jul 2, 2018');
});
it('returns timeframe string with hidden year for start date when both start and end dates are from same year', () => {
const mockMilestoneItem = Object.assign({}, mockMilestone2, {
startDate: new Date(2018, 0, 1),
endDate: new Date(2018, 3, 1),
});
wrapper = createComponent({ milestone: mockMilestoneItem });
expect(wrapper.vm.timeframeString(mockMilestoneItem)).toBe('Jan 1 – Apr 1, 2018');
});
});
describe('template', () => {
it('renders component container element class `timeline-bar-wrapper`', () => {
expect(wrapper.vm.$el.classList.contains('timeline-bar-wrapper')).toBeTruthy();
});
it('renders component element class `milestone-item-details`', () => {
expect(wrapper.vm.$el.querySelector('.milestone-item-details')).not.toBeNull();
});
it('renders Milestone item link element with class `milestone-url`', () => {
expect(wrapper.vm.$el.querySelector('.milestone-url')).not.toBeNull();
});
it('renders Milestone timeline bar element with class `timeline-bar`', () => {
expect(wrapper.vm.$el.querySelector('.timeline-bar')).not.toBeNull();
});
it('renders Milestone title element with class `milestone-item-title`', () => {
expect(wrapper.vm.$el.querySelector('.milestone-item-title')).not.toBeNull();
});
});
});
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
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';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import {
mockTimeframeInitialDate,
mockMilestone2,
mockGroupId,
} from '../../../javascripts/roadmap/mock_data';
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;
afterEach(() => {
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);
});
it('renders MilestoneItem component', () => {
wrapper = createComponent();
expect(wrapper.find(MilestoneItem).exists()).toBe(true);
});
});
});
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';
import createStore from 'ee/roadmap/store';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import {
PRESET_TYPES,
EPIC_DETAILS_CELL_WIDTH,
TIMELINE_CELL_MIN_WIDTH,
} from 'ee/roadmap/constants';
import {
mockTimeframeInitialDate,
mockGroupId,
rawMilestones,
} from '../../../javascripts/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,
},
});
};
describe('MilestonesListSectionComponent', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(wrapper.vm.offsetLeft).toBe(0);
expect(wrapper.vm.roadmapShellEl).toBeDefined();
});
});
describe('computed', () => {
describe('sectionContainerStyles', () => {
it('returns style string for container element based on sectionShellWidth', () => {
expect(wrapper.vm.sectionContainerStyles.width).toBe(
`${EPIC_DETAILS_CELL_WIDTH + TIMELINE_CELL_MIN_WIDTH * wrapper.vm.timeframe.length}px`,
);
});
});
describe('shadowCellStyles', () => {
it('returns computed style object based on `offsetLeft` prop value', () => {
expect(wrapper.vm.shadowCellStyles.left).toBe('0px');
});
});
});
describe('methods', () => {
describe('initMounted', () => {
it('sets value of `roadmapShellEl` with root component element', () => {
expect(wrapper.vm.roadmapShellEl instanceof HTMLElement).toBe(true);
});
});
describe('handleEpicsListScroll', () => {
it('toggles value of `showBottomShadow` based on provided `scrollTop`, `clientHeight` & `scrollHeight`', () => {
wrapper.vm.handleEpicsListScroll({
scrollTop: 5,
clientHeight: 5,
scrollHeight: 15,
});
// Math.ceil(scrollTop) + clientHeight < scrollHeight
expect(wrapper.vm.showBottomShadow).toBe(true);
wrapper.vm.handleEpicsListScroll({
scrollTop: 15,
clientHeight: 5,
scrollHeight: 15,
});
// Math.ceil(scrollTop) + clientHeight < scrollHeight
expect(wrapper.vm.showBottomShadow).toBe(false);
});
});
});
describe('template', () => {
it('renders component container element with class `milestones-list-section`', () => {
expect(wrapper.vm.$el.classList.contains('milestones-list-section')).toBe(true);
});
it('renders element with class `milestones-list-title`', () => {
wrapper.vm.setBufferSize(50);
expect(wrapper.find('.milestones-list-title').exists()).toBe(true);
});
it('renders element with class `milestones-list-items` containing MilestoneTimeline component', () => {
const listItems = wrapper.find('.milestones-list-items');
expect(listItems.exists()).toBe(true);
expect(listItems.find(MilestoneTimeline).exists()).toBe(true);
});
it('renders bottom shadow element when `showBottomShadow` prop is true', () => {
wrapper.setData({
showBottomShadow: true,
});
expect(wrapper.find('.scroll-bottom-shadow').exists()).toBe(true);
});
});
});
...@@ -76,7 +76,7 @@ describe('EpicItemComponent', () => { ...@@ -76,7 +76,7 @@ describe('EpicItemComponent', () => {
describe('timeframeString', () => { describe('timeframeString', () => {
it('returns timeframe string correctly when both start and end dates are defined', () => { it('returns timeframe string correctly when both start and end dates are defined', () => {
expect(vm.timeframeString).toBe('Jul 10, 2017 – Jun 2, 2018'); expect(vm.timeframeString(mockEpic)).toBe('Jul 10, 2017 – Jun 2, 2018');
}); });
it('returns timeframe string correctly when only start date is defined', () => { it('returns timeframe string correctly when only start date is defined', () => {
...@@ -85,7 +85,7 @@ describe('EpicItemComponent', () => { ...@@ -85,7 +85,7 @@ describe('EpicItemComponent', () => {
}); });
vm = createComponent({ epic }); vm = createComponent({ epic });
expect(vm.timeframeString).toBe('Jul 10, 2017 – No end date'); expect(vm.timeframeString(epic)).toBe('Jul 10, 2017 – No end date');
}); });
it('returns timeframe string correctly when only end date is defined', () => { it('returns timeframe string correctly when only end date is defined', () => {
...@@ -94,7 +94,7 @@ describe('EpicItemComponent', () => { ...@@ -94,7 +94,7 @@ describe('EpicItemComponent', () => {
}); });
vm = createComponent({ epic }); vm = createComponent({ epic });
expect(vm.timeframeString).toBe('No start date – Jun 2, 2018'); expect(vm.timeframeString(epic)).toBe('No start date – Jun 2, 2018');
}); });
it('returns timeframe string with hidden year for start date when both start and end dates are from same year', () => { it('returns timeframe string with hidden year for start date when both start and end dates are from same year', () => {
...@@ -104,7 +104,7 @@ describe('EpicItemComponent', () => { ...@@ -104,7 +104,7 @@ describe('EpicItemComponent', () => {
}); });
vm = createComponent({ epic }); vm = createComponent({ epic });
expect(vm.timeframeString).toBe('Jan 1 – Apr 1, 2018'); expect(vm.timeframeString(epic)).toBe('Jan 1 – Apr 1, 2018');
}); });
}); });
......
...@@ -216,6 +216,7 @@ describe('Roadmap AppComponent', () => { ...@@ -216,6 +216,7 @@ describe('Roadmap AppComponent', () => {
it('updates the store and refreshes roadmap with extended timeline based on provided extendType', () => { it('updates the store and refreshes roadmap with extended timeline based on provided extendType', () => {
spyOn(vm, 'extendTimeframe'); spyOn(vm, 'extendTimeframe');
spyOn(vm, 'refreshEpicDates'); spyOn(vm, 'refreshEpicDates');
spyOn(vm, 'refreshMilestoneDates');
const extendType = EXTEND_AS.PREPEND; const extendType = EXTEND_AS.PREPEND;
...@@ -223,11 +224,13 @@ describe('Roadmap AppComponent', () => { ...@@ -223,11 +224,13 @@ describe('Roadmap AppComponent', () => {
expect(vm.extendTimeframe).toHaveBeenCalledWith({ extendAs: extendType }); expect(vm.extendTimeframe).toHaveBeenCalledWith({ extendAs: extendType });
expect(vm.refreshEpicDates).toHaveBeenCalled(); expect(vm.refreshEpicDates).toHaveBeenCalled();
expect(vm.refreshMilestoneDates).toHaveBeenCalled();
}); });
it('calls `fetchEpicsForTimeframe` with extended timeframe array', done => { it('calls `fetchEpicsForTimeframe` with extended timeframe array', done => {
spyOn(vm, 'extendTimeframe').and.stub(); spyOn(vm, 'extendTimeframe').and.stub();
spyOn(vm, 'refreshEpicDates').and.stub(); spyOn(vm, 'refreshEpicDates').and.stub();
spyOn(vm, 'refreshMilestoneDates').and.stub();
spyOn(vm, 'fetchEpicsForTimeframeFn').and.callFake(() => new Promise(() => {})); spyOn(vm, 'fetchEpicsForTimeframeFn').and.callFake(() => new Promise(() => {}));
const extendType = EXTEND_AS.PREPEND; const extendType = EXTEND_AS.PREPEND;
......
...@@ -8,13 +8,14 @@ import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils'; ...@@ -8,13 +8,14 @@ import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants'; import { PRESET_TYPES } from 'ee/roadmap/constants';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { mockEpic, mockTimeframeInitialDate, mockGroupId } from '../mock_data'; import { mockEpic, mockTimeframeInitialDate, mockGroupId, mockMilestone } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate); const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = ( const createComponent = (
{ {
epics = [mockEpic], epics = [mockEpic],
milestones = [mockMilestone],
timeframe = mockTimeframeMonths, timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId, currentGroupId = mockGroupId,
defaultInnerHeight = 0, defaultInnerHeight = 0,
...@@ -34,6 +35,7 @@ const createComponent = ( ...@@ -34,6 +35,7 @@ const createComponent = (
props: { props: {
presetType: PRESET_TYPES.MONTHS, presetType: PRESET_TYPES.MONTHS,
epics, epics,
milestones,
timeframe, timeframe,
currentGroupId, currentGroupId,
}, },
......
...@@ -364,3 +364,115 @@ export const mockEpicChildEpicsQueryResponse = { ...@@ -364,3 +364,115 @@ export const mockEpicChildEpicsQueryResponse = {
}, },
}, },
}; };
export const rawMilestones = [
{
id: 'gid://gitlab/Milestone/40',
iid: 1,
state: 'active',
description: null,
title: 'Milestone 1',
startDate: '2017-12-25',
dueDate: '2018-03-09',
webPath: '/groups/gitlab-org/-/milestones/1',
},
{
id: 'gid://gitlab/Milestone/41',
iid: 2,
state: 'active',
description: null,
title: 'Milestone 2',
startDate: '2017-12-26',
dueDate: '2018-03-10',
webPath: '/groups/gitlab-org/-/milestones/2',
},
];
export const mockMilestone = {
id: 1,
iid: 1,
state: 'active',
description:
'Explicabo et soluta minus praesentium minima ab et voluptatem. Quas architecto vero corrupti voluptatibus labore accusantium consectetur. Aliquam aut impedit voluptates illum molestias aut harum. Aut non odio praesentium aut.\n\nQuo asperiores aliquid sed nobis. Omnis sint iste provident numquam. Qui voluptatem tempore aut aut voluptas dolorem qui.\n\nEst est nemo quod est. Odit modi eos natus cum illo aut. Expedita nostrum ea est omnis magnam ut eveniet maxime. Itaque ipsam provident minima et occaecati ut. Dicta est perferendis sequi perspiciatis rerum voluptatum deserunt.',
title:
'Cupiditate exercitationem unde harum reprehenderit maxime eius velit recusandae incidunt quia.',
groupId: 2,
groupName: 'Gitlab Org',
groupFullName: 'Gitlab Org',
startDate: new Date('2017-07-10'),
endDate: new Date('2018-06-02'),
webPath: '/groups/gitlab-org/-/milestones/1',
};
export const mockMilestone2 = {
id: 2,
iid: 2,
state: 'active',
description:
'Explicabo et soluta minus praesentium minima ab et voluptatem. Quas architecto vero corrupti voluptatibus labore accusantium consectetur. Aliquam aut impedit voluptates illum molestias aut harum. Aut non odio praesentium aut.\n\nQuo asperiores aliquid sed nobis. Omnis sint iste provident numquam. Qui voluptatem tempore aut aut voluptas dolorem qui.\n\nEst est nemo quod est. Odit modi eos natus cum illo aut. Expedita nostrum ea est omnis magnam ut eveniet maxime. Itaque ipsam provident minima et occaecati ut. Dicta est perferendis sequi perspiciatis rerum voluptatum deserunt.',
title: 'Milestone 2',
groupId: 2,
groupName: 'Gitlab Org',
groupFullName: 'Gitlab Org',
startDate: new Date('2017-11-10'),
endDate: new Date('2018-07-02'),
webPath: '/groups/gitlab-org/-/milestones/1',
};
export const mockFormattedMilestone = {
id: 1,
iid: 1,
state: 'active',
title:
'Cupiditate exercitationem unde harum reprehenderit maxime eius velit recusandae incidunt quia.',
description:
'Explicabo et soluta minus praesentium minima ab et voluptatem. Quas architecto vero corrupti voluptatibus labore accusantium consectetur. Aliquam aut impedit voluptates illum molestias aut harum. Aut non odio praesentium aut.\n\nQuo asperiores aliquid sed nobis. Omnis sint iste provident numquam. Qui voluptatem tempore aut aut voluptas dolorem qui.\n\nEst est nemo quod est. Odit modi eos natus cum illo aut. Expedita nostrum ea est omnis magnam ut eveniet maxime. Itaque ipsam provident minima et occaecati ut. Dicta est perferendis sequi perspiciatis rerum voluptatum deserunt.',
groupId: 2,
groupName: 'Gitlab Org',
groupFullName: 'Gitlab Org',
startDate: new Date(2017, 10, 1),
originalStartDate: new Date(2017, 5, 26),
endDate: new Date(2018, 2, 10),
originalEndDate: new Date(2018, 2, 10),
startDateOutOfRange: true,
endDateOutOfRange: false,
webPath: '/groups/gitlab-org/-/milestones/1',
newMilestone: undefined,
};
export const mockGroupMilestonesQueryResponse = {
data: {
group: {
id: 'gid://gitlab/Group/2',
name: 'Gitlab Org',
milestones: {
edges: [
{
node: {
iid: 1,
id: 'gid://gitlab/Milestone/40',
state: 'active',
description: null,
title: 'Milestone 1',
startDate: '2017-12-25',
dueDate: '2018-03-09',
webPath: '/groups/gitlab-org/-/milestones/1',
},
},
{
node: {
iid: 2,
id: 'gid://gitlab/Milestone/41',
state: 'active',
description: null,
title: 'Milestone 2',
startDate: '2017-12-26',
dueDate: '2018-03-10',
webPath: '/groups/gitlab-org/-/milestones/2',
},
},
],
},
},
},
};
...@@ -9,6 +9,7 @@ import * as epicUtils from 'ee/roadmap/utils/epic_utils'; ...@@ -9,6 +9,7 @@ import * as epicUtils from 'ee/roadmap/utils/epic_utils';
import * as roadmapItemUtils from 'ee/roadmap/utils/roadmap_item_utils'; import * as roadmapItemUtils from 'ee/roadmap/utils/roadmap_item_utils';
import { PRESET_TYPES, EXTEND_AS } from 'ee/roadmap/constants'; import { PRESET_TYPES, EXTEND_AS } from 'ee/roadmap/constants';
import groupEpics from 'ee/roadmap/queries/groupEpics.query.graphql'; import groupEpics from 'ee/roadmap/queries/groupEpics.query.graphql';
import groupMilestones from 'ee/roadmap/queries/groupMilestones.query.graphql';
import epicChildEpics from 'ee/roadmap/queries/epicChildEpics.query.graphql'; import epicChildEpics from 'ee/roadmap/queries/epicChildEpics.query.graphql';
import testAction from 'spec/helpers/vuex_action_helper'; import testAction from 'spec/helpers/vuex_action_helper';
...@@ -27,6 +28,10 @@ import { ...@@ -27,6 +28,10 @@ import {
mockSortedBy, mockSortedBy,
mockGroupEpicsQueryResponse, mockGroupEpicsQueryResponse,
mockEpicChildEpicsQueryResponse, mockEpicChildEpicsQueryResponse,
mockGroupMilestonesQueryResponse,
rawMilestones,
mockMilestone,
mockFormattedMilestone,
} from '../mock_data'; } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate); const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
...@@ -417,4 +422,180 @@ describe('Roadmap Vuex Actions', () => { ...@@ -417,4 +422,180 @@ describe('Roadmap Vuex Actions', () => {
); );
}); });
}); });
describe('fetchGroupMilestones', () => {
let mockState;
let expectedVariables;
beforeEach(() => {
mockState = {
fullPath: 'gitlab-org',
milestonessState: 'active',
presetType: PRESET_TYPES.MONTHS,
timeframe: mockTimeframeMonths,
};
expectedVariables = {
fullPath: 'gitlab-org',
state: mockState.milestonessState,
startDate: '2017-11-1',
dueDate: '2018-6-30',
};
});
it('should fetch Group Milestones using GraphQL client when milestoneIid is not present in state', done => {
spyOn(epicUtils.gqClient, 'query').and.returnValue(
Promise.resolve({
data: mockGroupMilestonesQueryResponse.data,
}),
);
actions
.fetchGroupMilestones(mockState)
.then(() => {
expect(epicUtils.gqClient.query).toHaveBeenCalledWith({
query: groupMilestones,
variables: expectedVariables,
});
})
.then(done)
.catch(done.fail);
});
});
describe('requestMilestones', () => {
it('should set `milestonesFetchInProgress` to true', done => {
testAction(actions.requestMilestones, {}, state, [{ type: 'REQUEST_MILESTONES' }], [], done);
});
});
describe('fetchMilestones', () => {
describe('success', () => {
it('should dispatch requestMilestones and receiveMilestonesSuccess when request is successful', done => {
spyOn(epicUtils.gqClient, 'query').and.returnValue(
Promise.resolve({
data: mockGroupMilestonesQueryResponse.data,
}),
);
testAction(
actions.fetchMilestones,
null,
state,
[],
[
{
type: 'requestMilestones',
},
{
type: 'receiveMilestonesSuccess',
payload: { rawMilestones },
},
],
done,
);
});
});
describe('failure', () => {
it('should dispatch requestMilestones and receiveMilestonesFailure when request fails', done => {
testAction(
actions.fetchMilestones,
null,
state,
[],
[
{
type: 'requestMilestones',
},
{
type: 'receiveMilestonesFailure',
},
],
done,
);
});
});
});
describe('receiveMilestonesSuccess', () => {
it('should set formatted milestones array and milestoneId to IDs array in state based on provided milestones list', done => {
testAction(
actions.receiveMilestonesSuccess,
{
rawMilestones: [
Object.assign({}, mockMilestone, {
start_date: '2017-12-31',
end_date: '2018-2-15',
}),
],
},
state,
[
{ type: types.UPDATE_MILESTONE_IDS, payload: [mockMilestone.id] },
{
type: types.RECEIVE_MILESTONES_SUCCESS,
payload: [
Object.assign({}, mockFormattedMilestone, {
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),
}),
],
},
],
[],
done,
);
});
});
describe('receiveMilestonesFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('should set milestonesFetchInProgress to false and milestonesFetchFailure to true', done => {
testAction(
actions.receiveMilestonesFailure,
{},
state,
[{ type: types.RECEIVE_MILESTONES_FAILURE }],
[],
done,
);
});
it('should show flash error', () => {
actions.receiveMilestonesFailure({ commit: () => {} });
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
'Something went wrong while fetching milestones',
);
});
});
describe('refreshMilestoneDates', () => {
it('should update milestones after refreshing milestone dates to match with updated timeframe', done => {
const milestones = rawMilestones.map(milestone =>
roadmapItemUtils.formatRoadmapItemDetails(
milestone,
state.timeframeStartDate,
state.timeframeEndDate,
),
);
testAction(
actions.refreshMilestoneDates,
{},
{ ...state, timeframe: mockTimeframeMonths.concat(mockTimeframeMonthsAppend), milestones },
[{ type: types.SET_MILESTONES, payload: milestones }],
[],
done,
);
});
});
}); });
...@@ -138,6 +138,54 @@ describe('Roadmap Store Mutations', () => { ...@@ -138,6 +138,54 @@ describe('Roadmap Store Mutations', () => {
}); });
}); });
describe('SET_MILESTONES', () => {
it('Should provided milestones array in state', () => {
const milestones = [{ id: 1 }, { id: 2 }];
mutations[types.SET_MILESTONES](state, milestones);
expect(state.milestones).toEqual(milestones);
});
});
describe('UPDATE_MILESTONE_IDS', () => {
it('Should update milestoneIds array', () => {
mutations[types.UPDATE_MILESTONE_IDS](state, [22]);
expect(state.milestoneIds.length).toBe(1);
expect(state.milestoneIds[0]).toBe(22);
});
});
describe('REQUEST_MILESTONES', () => {
it('Should set state.milestonesFetchInProgress to `true`', () => {
mutations[types.REQUEST_MILESTONES](state);
expect(state.milestonesFetchInProgress).toBe(true);
});
});
describe('RECEIVE_MILESTONES_SUCCESS', () => {
it('Should set milestonesFetchResultEmpty, milestones in state based on provided milestones array and set milestonesFetchInProgress to `false`', () => {
const milestones = [{ id: 1 }, { id: 2 }];
mutations[types.RECEIVE_MILESTONES_SUCCESS](state, milestones);
expect(state.milestonesFetchResultEmpty).toBe(false);
expect(state.milestones).toEqual(milestones);
expect(state.milestonesFetchInProgress).toBe(false);
});
});
describe('RECEIVE_MILESTONES_FAILURE', () => {
it('Should set milestonesFetchInProgress to false and milestonesFetchFailure to true', () => {
mutations[types.RECEIVE_MILESTONES_FAILURE](state);
expect(state.milestonesFetchInProgress).toBe(false);
expect(state.milestonesFetchFailure).toBe(true);
});
});
describe('SET_BUFFER_SIZE', () => { describe('SET_BUFFER_SIZE', () => {
it('Should set `bufferSize` in state', () => { it('Should set `bufferSize` in state', () => {
const bufferSize = 10; const bufferSize = 10;
......
...@@ -2,7 +2,7 @@ import * as roadmapItemUtils from 'ee/roadmap/utils/roadmap_item_utils'; ...@@ -2,7 +2,7 @@ import * as roadmapItemUtils from 'ee/roadmap/utils/roadmap_item_utils';
import { parsePikadayDate } from '~/lib/utils/datetime_utility'; import { parsePikadayDate } from '~/lib/utils/datetime_utility';
import { rawEpics } from '../mock_data'; import { rawEpics, mockGroupMilestonesQueryResponse } from '../mock_data';
describe('processRoadmapItemDates', () => { describe('processRoadmapItemDates', () => {
const timeframeStartDate = new Date(2017, 0, 1); const timeframeStartDate = new Date(2017, 0, 1);
...@@ -113,3 +113,17 @@ describe('formatRoadmapItemDetails', () => { ...@@ -113,3 +113,17 @@ describe('formatRoadmapItemDetails', () => {
expect(epic.endDateUndefined).toBe(true); expect(epic.endDateUndefined).toBe(true);
}); });
}); });
describe('extractGroupMilestones', () => {
it('returns array of epics with `edges->nodes` nesting removed', () => {
const { edges } = mockGroupMilestonesQueryResponse.data.group.milestones;
const extractedMilestones = roadmapItemUtils.extractGroupMilestones(edges);
expect(extractedMilestones.length).toBe(edges.length);
expect(extractedMilestones[0]).toEqual(
jasmine.objectContaining({
...edges[0].node,
}),
);
});
});
...@@ -9937,18 +9937,30 @@ msgstr "" ...@@ -9937,18 +9937,30 @@ msgstr ""
msgid "Group: %{name}" msgid "Group: %{name}"
msgstr "" msgstr ""
msgid "GroupRoadmap|%{dateWord} - No end date"
msgstr ""
msgid "GroupRoadmap|%{dateWord} – No end date" msgid "GroupRoadmap|%{dateWord} – No end date"
msgstr "" msgstr ""
msgid "GroupRoadmap|%{startDateInWords} - %{endDateInWords}"
msgstr ""
msgid "GroupRoadmap|%{startDateInWords} – %{endDateInWords}" msgid "GroupRoadmap|%{startDateInWords} – %{endDateInWords}"
msgstr "" msgstr ""
msgid "GroupRoadmap|No start date - %{dateWord}"
msgstr ""
msgid "GroupRoadmap|No start date – %{dateWord}" msgid "GroupRoadmap|No start date – %{dateWord}"
msgstr "" msgstr ""
msgid "GroupRoadmap|Something went wrong while fetching epics" msgid "GroupRoadmap|Something went wrong while fetching epics"
msgstr "" msgstr ""
msgid "GroupRoadmap|Something went wrong while fetching milestones"
msgstr ""
msgid "GroupRoadmap|Sorry, no epics matched your search" msgid "GroupRoadmap|Sorry, no epics matched your search"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment