Commit 48318f75 authored by Kushal Pandya's avatar Kushal Pandya Committed by Olena Horal-Koretska

Add daterange picker to filter Roadmap

parent e2e50bb9
---
name: roadmap_daterange_filter
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55639
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/323917
milestone: '14.3'
type: development
group: group::product planning
default_enabled: false
......@@ -34,6 +34,6 @@ export default {
<span
v-if="hasToday"
:style="indicatorStyles"
class="current-day-indicator position-absolute"
class="current-day-indicator js-current-day-indicator gl-absolute"
></span>
</template>
......@@ -5,7 +5,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { EPIC_DETAILS_CELL_WIDTH, TIMELINE_CELL_MIN_WIDTH, EPIC_ITEM_HEIGHT } from '../constants';
import eventHub from '../event_hub';
import { generateKey } from '../utils/epic_utils';
import { generateKey, scrollToCurrentDay } from '../utils/epic_utils';
import CurrentDayIndicator from './current_day_indicator.vue';
import EpicItem from './epic_item.vue';
......@@ -115,7 +115,7 @@ export default {
// to timeline expand, so we wait for another render
// cycle to complete.
this.$nextTick(() => {
this.scrollToTodayIndicator();
scrollToCurrentDay(this.$el);
});
if (!Object.keys(this.emptyRowContainerStyles).length) {
......@@ -139,13 +139,6 @@ export default {
}
return {};
},
/**
* Scroll timeframe to the right of the timeline
* by half the column size
*/
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;
},
......
......@@ -4,6 +4,7 @@ import { mapState, mapActions } from 'vuex';
import { __, n__ } from '~/locale';
import { EPIC_DETAILS_CELL_WIDTH, EPIC_ITEM_HEIGHT, TIMELINE_CELL_MIN_WIDTH } from '../constants';
import eventHub from '../event_hub';
import { scrollToCurrentDay } from '../utils/epic_utils';
import MilestoneTimeline from './milestone_timeline.vue';
const EXPAND_BUTTON_EXPANDED = {
......@@ -97,13 +98,10 @@ export default {
this.offsetLeft = (this.$el.parentElement && this.$el.parentElement.offsetLeft) || 0;
this.$nextTick(() => {
this.scrollToTodayIndicator();
scrollToCurrentDay(this.$el);
});
});
},
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;
},
......
......@@ -9,6 +9,7 @@ import {
EXTEND_AS,
EPICS_LIMIT_DISMISSED_COOKIE_NAME,
EPICS_LIMIT_DISMISSED_COOKIE_TIMEOUT,
DATE_RANGES,
} from '../constants';
import eventHub from '../event_hub';
import EpicsListEmpty from './epics_list_empty.vue';
......@@ -32,6 +33,11 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
props: {
timeframeRangeType: {
type: String,
required: false,
default: DATE_RANGES.CURRENT_QUARTER,
},
presetType: {
type: String,
required: true,
......@@ -155,7 +161,7 @@ export default {
<template>
<div class="roadmap-app-container gl-h-full">
<roadmap-filters v-if="showFilteredSearchbar" />
<roadmap-filters v-if="showFilteredSearchbar" :timeframe-range-type="timeframeRangeType" />
<gl-alert
v-if="isWarningVisible"
variant="warning"
......
......@@ -9,18 +9,26 @@ import {
import { mapState, mapActions } from 'vuex';
import { visitUrl, mergeUrlParams, updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { __, s__ } from '~/locale';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { EPICS_STATES, PRESET_TYPES } from '../constants';
import { EPICS_STATES, PRESET_TYPES, DATE_RANGES } from '../constants';
import EpicsFilteredSearchMixin from '../mixins/filtered_search_mixin';
import { getPresetTypeForTimeframeRangeType } from '../utils/roadmap_utils';
const pickerType = {
Start: 'start',
End: 'end',
};
export default {
pickerType,
epicStates: EPICS_STATES,
availablePresets: [
{ text: __('Quarters'), value: PRESET_TYPES.QUARTERS },
{ text: __('Months'), value: PRESET_TYPES.MONTHS },
{ text: __('Weeks'), value: PRESET_TYPES.WEEKS },
availableDateRanges: [
{ text: s__('GroupRoadmap|This quarter'), value: DATE_RANGES.CURRENT_QUARTER },
{ text: s__('GroupRoadmap|This year'), value: DATE_RANGES.CURRENT_YEAR },
{ text: s__('GroupRoadmap|Within 3 years'), value: DATE_RANGES.THREE_YEARS },
],
availableSortOptions: [
{
......@@ -48,7 +56,18 @@ export default {
GlDropdownDivider,
FilteredSearchBar,
},
mixins: [EpicsFilteredSearchMixin],
mixins: [EpicsFilteredSearchMixin, glFeatureFlagsMixin()],
props: {
timeframeRangeType: {
type: String,
required: true,
},
},
data() {
return {
selectedDaterange: this.timeframeRangeType,
};
},
computed: {
...mapState(['presetType', 'epicsState', 'sortedBy', 'filterParams']),
selectedEpicStateTitle() {
......@@ -59,6 +78,34 @@ export default {
}
return __('Closed epics');
},
daterangeDropdownText() {
switch (this.selectedDaterange) {
case DATE_RANGES.CURRENT_QUARTER:
return s__('GroupRoadmap|This quarter');
case DATE_RANGES.CURRENT_YEAR:
return s__('GroupRoadmap|This year');
case DATE_RANGES.THREE_YEARS:
return s__('GroupRoadmap|Within 3 years');
default:
return '';
}
},
availablePresets() {
const quarters = { text: __('Quarters'), value: PRESET_TYPES.QUARTERS };
const months = { text: __('Months'), value: PRESET_TYPES.MONTHS };
const weeks = { text: __('Weeks'), value: PRESET_TYPES.WEEKS };
if (!this.glFeatures.roadmapDaterangeFilter) {
return [quarters, months, weeks];
}
if (this.selectedDaterange === DATE_RANGES.CURRENT_YEAR) {
return [months, weeks];
} else if (this.selectedDaterange === DATE_RANGES.THREE_YEARS) {
return [quarters, months, weeks];
}
return [];
},
},
watch: {
urlParams: {
......@@ -77,8 +124,34 @@ export default {
},
methods: {
...mapActions(['setEpicsState', 'setFilterParams', 'setSortedBy', 'fetchEpics']),
handleDaterangeSelect(value) {
this.selectedDaterange = value;
},
handleDaterangeDropdownOpen() {
this.initialSelectedDaterange = this.selectedDaterange;
},
handleDaterangeDropdownClose() {
if (this.initialSelectedDaterange !== this.selectedDaterange) {
visitUrl(
mergeUrlParams(
{
timeframe_range_type: this.selectedDaterange,
layout: getPresetTypeForTimeframeRangeType(this.selectedDaterange),
},
window.location.href,
),
);
}
},
handleRoadmapLayoutChange(presetType) {
visitUrl(mergeUrlParams({ layout: presetType }, window.location.href));
visitUrl(
mergeUrlParams(
this.glFeatures.roadmapDaterangeFilter
? { timeframe_range_type: this.selectedDaterange, layout: presetType }
: { layout: presetType },
window.location.href,
),
);
},
handleEpicStateChange(epicsState) {
this.setEpicsState(epicsState);
......@@ -99,12 +172,30 @@ export default {
<template>
<div class="epics-filters epics-roadmap-filters epics-roadmap-filters-gl-ui">
<div
class="epics-details-filters filtered-search-block gl-display-flex gl-flex-direction-column gl-xl-flex-direction-row row-content-block second-block"
class="epics-details-filters filtered-search-block gl-display-flex gl-flex-direction-column gl-xl-flex-direction-row gl-pb-3 row-content-block second-block"
>
<gl-form-group class="mb-0">
<gl-dropdown
v-if="glFeatures.roadmapDaterangeFilter"
icon="calendar"
class="gl-mr-0 gl-lg-mr-3 mb-sm-2 roadmap-daterange-dropdown"
toggle-class="gl-rounded-base!"
:text="daterangeDropdownText"
data-testid="daterange-dropdown"
@show="handleDaterangeDropdownOpen"
@hide="handleDaterangeDropdownClose"
>
<gl-dropdown-item
v-for="dateRange in $options.availableDateRanges"
:key="dateRange.value"
:value="dateRange.value"
@click="handleDaterangeSelect(dateRange.value)"
>{{ dateRange.text }}</gl-dropdown-item
>
</gl-dropdown>
<gl-form-group v-if="availablePresets.length" class="gl-mr-0 gl-lg-mr-3 mb-sm-2">
<gl-segmented-control
:checked="presetType"
:options="$options.availablePresets"
:options="availablePresets"
class="gl-display-flex d-xl-block"
buttons
@input="handleRoadmapLayoutChange"
......@@ -112,8 +203,8 @@ export default {
</gl-form-group>
<gl-dropdown
:text="selectedEpicStateTitle"
class="gl-my-2 my-xl-0 mx-xl-2"
toggle-class="gl-rounded-small"
class="gl-mr-0 gl-lg-mr-3 mb-sm-2"
toggle-class="gl-rounded-base!"
>
<gl-dropdown-item
:is-check-item="true"
......
......@@ -2,6 +2,7 @@
import { mapState } from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { EXTEND_AS } from '../constants';
import eventHub from '../event_hub';
......@@ -15,6 +16,7 @@ export default {
milestonesListSection,
roadmapTimelineSection,
},
mixins: [glFeatureFlagsMixin()],
props: {
presetType: {
type: String,
......@@ -67,25 +69,28 @@ export default {
methods: {
handleScroll() {
const { scrollTop, scrollLeft, clientHeight, scrollHeight } = this.$el;
const timelineEdgeStartEl = this.$refs.roadmapTimeline.$el
.querySelector('.timeline-header-item')
.querySelector('.item-sublabel .sublabel-value:first-child');
const timelineEdgeEndEl = this.$refs.roadmapTimeline.$el
.querySelector('.timeline-header-item:last-child')
.querySelector('.item-sublabel .sublabel-value:last-child');
// If timeline was scrolled to start
if (isInViewport(timelineEdgeStartEl, { left: this.timeframeStartOffset })) {
this.$emit('onScrollToStart', {
el: this.$refs.roadmapTimeline.$el,
extendAs: EXTEND_AS.PREPEND,
});
} else if (isInViewport(timelineEdgeEndEl)) {
// If timeline was scrolled to end
this.$emit('onScrollToEnd', {
el: this.$refs.roadmapTimeline.$el,
extendAs: EXTEND_AS.APPEND,
});
if (!this.glFeatures.roadmapDaterangeFilter) {
const timelineEdgeStartEl = this.$refs.roadmapTimeline.$el
.querySelector('.timeline-header-item')
.querySelector('.item-sublabel .sublabel-value:first-child');
const timelineEdgeEndEl = this.$refs.roadmapTimeline.$el
.querySelector('.timeline-header-item:last-child')
.querySelector('.item-sublabel .sublabel-value:last-child');
// If timeline was scrolled to start
if (isInViewport(timelineEdgeStartEl, { left: this.timeframeStartOffset })) {
this.$emit('onScrollToStart', {
el: this.$refs.roadmapTimeline.$el,
extendAs: EXTEND_AS.PREPEND,
});
} else if (isInViewport(timelineEdgeEndEl)) {
// If timeline was scrolled to end
this.$emit('onScrollToEnd', {
el: this.$refs.roadmapTimeline.$el,
extendAs: EXTEND_AS.APPEND,
});
}
}
eventHub.$emit('epicsListScrolled', { scrollTop, scrollLeft, clientHeight, scrollHeight });
......
......@@ -23,6 +23,12 @@ export const PERCENTAGE = 100;
export const SMALL_TIMELINE_BAR = 40;
export const DATE_RANGES = {
CURRENT_QUARTER: 'CURRENT_QUARTER',
CURRENT_YEAR: 'CURRENT_YEAR',
THREE_YEARS: 'THREE_YEARS',
};
export const PRESET_TYPES = {
QUARTERS: 'QUARTERS',
MONTHS: 'MONTHS',
......
......@@ -40,6 +40,8 @@ export default {
sort: this.sortedBy,
prev: this.prevPageCursor || undefined,
next: this.nextPageCursor || undefined,
layout: this.presetType || undefined,
timeframe_range_type: this.timeframeRangeType || undefined,
author_username: authorUsername,
'label_name[]': labelName,
milestone_title: milestoneTitle,
......
......@@ -9,10 +9,14 @@ import EpicItem from './components/epic_item.vue';
import EpicItemContainer from './components/epic_item_container.vue';
import roadmapApp from './components/roadmap_app.vue';
import { PRESET_TYPES, EPIC_DETAILS_CELL_WIDTH } from './constants';
import { PRESET_TYPES, EPIC_DETAILS_CELL_WIDTH, DATE_RANGES } from './constants';
import createStore from './store';
import { getTimeframeForPreset } from './utils/roadmap_utils';
import {
getTimeframeForPreset,
getPresetTypeForTimeframeRangeType,
getTimeframeForRangeType,
} from './utils/roadmap_utils';
Vue.use(Translate);
......@@ -57,18 +61,38 @@ export default () => {
};
},
data() {
const supportedPresetTypes = Object.keys(PRESET_TYPES);
const { dataset } = this.$options.el;
const presetType =
supportedPresetTypes.indexOf(dataset.presetType) > -1
? dataset.presetType
: PRESET_TYPES.MONTHS;
let timeframe;
let timeframeRangeType;
let presetType;
if (gon.features.roadmapDaterangeFilter) {
timeframeRangeType =
Object.keys(DATE_RANGES).indexOf(dataset.timeframeRangeType) > -1
? dataset.timeframeRangeType
: DATE_RANGES.CURRENT_QUARTER;
presetType = getPresetTypeForTimeframeRangeType(timeframeRangeType, dataset.presetType);
timeframe = getTimeframeForRangeType({
timeframeRangeType,
presetType,
});
} else {
presetType =
Object.keys(PRESET_TYPES).indexOf(dataset.presetType) > -1
? dataset.presetType
: PRESET_TYPES.MONTHS;
timeframe = getTimeframeForPreset(
presetType,
window.innerWidth - el.offsetLeft - EPIC_DETAILS_CELL_WIDTH,
);
}
const rawFilterParams = queryToObject(window.location.search, {
gatherArrays: true,
});
const filterParams = {
...convertObjectPropsToCamelCase(rawFilterParams, {
dropKeys: ['scope', 'utf8', 'state', 'sort', 'layout'], // These keys are unsupported/unnecessary
dropKeys: ['scope', 'utf8', 'state', 'sort', 'timeframe_range_type', 'layout'], // These keys are unsupported/unnecessary
}),
// We shall put parsed value of `confidential` only
// when it is defined.
......@@ -80,10 +104,6 @@ export default () => {
epicIid: rawFilterParams.epicIid,
}),
};
const timeframe = getTimeframeForPreset(
presetType,
window.innerWidth - el.offsetLeft - EPIC_DETAILS_CELL_WIDTH,
);
return {
emptyStateIllustrationPath: dataset.emptyStateIllustration,
......@@ -98,6 +118,7 @@ export default () => {
epicsState: dataset.epicsState,
sortedBy: dataset.sortedBy,
filterParams,
timeframeRangeType,
presetType,
timeframe,
};
......@@ -108,6 +129,7 @@ export default () => {
fullPath: this.fullPath,
epicIid: this.epicIid,
sortedBy: this.sortedBy,
timeframeRangeType: this.timeframeRangeType,
presetType: this.presetType,
epicsState: this.epicsState,
timeframe: this.timeframe,
......@@ -125,6 +147,7 @@ export default () => {
render(createElement) {
return createElement('roadmap-app', {
props: {
timeframeRangeType: this.timeframeRangeType,
presetType: this.presetType,
emptyStateIllustrationPath: this.emptyStateIllustrationPath,
},
......
......@@ -17,6 +17,7 @@ export default () => ({
timeframe: [],
extendedTimeframe: [],
presetType: '',
timeframeRangeType: '',
sortedBy: '',
milestoneIds: [],
milestones: [],
......
......@@ -10,3 +10,10 @@ export const gqClient = createGqClient(
export const addIsChildEpicTrueProperty = (obj) => ({ ...obj, isChildEpic: true });
export const generateKey = (epic) => `${epic.isChildEpic ? 'child-epic-' : 'epic-'}${epic.id}`;
export const scrollToCurrentDay = (parentEl) => {
const todayIndicatorEl = parentEl.querySelector('.js-current-day-indicator');
if (todayIndicatorEl) {
todayIndicatorEl.scrollIntoView({ block: 'nearest', inline: 'center' });
}
};
......@@ -4,6 +4,7 @@ import {
DAYS_IN_WEEK,
EXTEND_AS,
PRESET_DEFAULTS,
DATE_RANGES,
PRESET_TYPES,
TIMELINE_CELL_MIN_WIDTH,
} from '../constants';
......@@ -364,6 +365,117 @@ export const getTimeframeForPreset = (
return timeframe;
};
export const getWeeksForDates = (startDate, endDate) => {
const timeframe = [];
const start = newDate(startDate);
const end = newDate(endDate);
// Move to Sunday that comes just before startDate
start.setDate(start.getDate() - start.getDay());
while (start.getTime() < end.getTime()) {
// Push date to timeframe only when day is
// first day (Sunday) of the week
timeframe.push(newDate(start));
// Move date next Sunday
start.setDate(start.getDate() + DAYS_IN_WEEK);
}
return timeframe;
};
export const getTimeframeForRangeType = ({
timeframeRangeType = DATE_RANGES.CURRENT_QUARTER,
presetType = PRESET_TYPES.WEEKS,
}) => {
let timeframe = [];
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
// We need to prepare timeframe containing all the weeks of
// current quarter.
if (timeframeRangeType === DATE_RANGES.CURRENT_QUARTER) {
// Get current quarter for current month
const currentQuarter = Math.floor((startDate.getMonth() + 3) / 3);
// Get index of current month in current quarter
// It could be 0, 1, 2 (i.e. first, second or third)
const currentMonthInCurrentQuarter = monthsForQuarters[currentQuarter].indexOf(
startDate.getMonth(),
);
// Get last day of the last month of current quarter
const endDate = newDate(startDate);
if (currentMonthInCurrentQuarter === 0) {
endDate.setMonth(endDate.getMonth() + 2);
} else if (currentMonthInCurrentQuarter === 1) {
endDate.setMonth(endDate.getMonth() + 1);
}
endDate.setDate(totalDaysInMonth(endDate));
// Move startDate to first day of the first month of current quarter
startDate.setMonth(startDate.getMonth() - currentMonthInCurrentQuarter);
startDate.setDate(1);
timeframe = getWeeksForDates(startDate, endDate);
} else if (timeframeRangeType === DATE_RANGES.CURRENT_YEAR) {
// Move start date to first day of current year
startDate.setMonth(0);
startDate.setDate(1);
if (presetType === PRESET_TYPES.MONTHS) {
timeframe = getTimeframeWindowFrom(startDate, 12);
} else {
// Get last day of current year
const endDate = newDate(startDate);
endDate.setMonth(11);
endDate.setDate(totalDaysInMonth(endDate));
timeframe = getWeeksForDates(startDate, endDate);
}
} else {
// Get last day of the month, 18 months from startDate.
const endDate = newDate(startDate);
endDate.setMonth(endDate.getMonth() + 18);
endDate.setDate(totalDaysInMonth(endDate));
// Move start date to the 18 months behind
startDate.setMonth(startDate.getMonth() - 18);
startDate.setDate(1);
if (presetType === PRESET_TYPES.QUARTERS) {
timeframe = getTimeframeWindowFrom(startDate, 18 * 2);
const quartersTimeframe = [];
// Iterate over the timeframe and break it down
// in chunks of quarters
for (let i = 0; i < timeframe.length; i += 3) {
const range = timeframe.slice(i, i + 3);
const lastMonthOfQuarter = range[range.length - 1];
const quarterSequence = Math.floor((range[0].getMonth() + 3) / 3);
const year = range[0].getFullYear();
// Ensure that `range` spans across duration of
// entire quarter
lastMonthOfQuarter.setDate(totalDaysInMonth(lastMonthOfQuarter));
quartersTimeframe.push({
quarterSequence,
range,
year,
});
}
timeframe = quartersTimeframe;
} else if (presetType === PRESET_TYPES.MONTHS) {
timeframe = getTimeframeWindowFrom(startDate, 18 * 2);
} else {
timeframe = getWeeksForDates(startDate, endDate);
}
}
return timeframe;
};
/**
* Returns timeframe range in string based on provided config.
*
......@@ -440,3 +552,27 @@ export const sortEpics = (epics, sortedBy) => {
return 0;
});
};
export const getPresetTypeForTimeframeRangeType = (timeframeRangeType, initialPresetType) => {
let presetType;
switch (timeframeRangeType) {
case DATE_RANGES.CURRENT_QUARTER:
presetType = PRESET_TYPES.WEEKS;
break;
case DATE_RANGES.CURRENT_YEAR:
presetType = [PRESET_TYPES.MONTHS, PRESET_TYPES.WEEKS].includes(initialPresetType)
? initialPresetType
: PRESET_TYPES.MONTHS;
break;
case DATE_RANGES.THREE_YEARS:
presetType = [PRESET_TYPES.QUARTERS, PRESET_TYPES.MONTHS, PRESET_TYPES.WEEKS].includes(
initialPresetType,
)
? initialPresetType
: PRESET_TYPES.QUARTERS;
break;
default:
break;
}
return presetType;
};
......@@ -536,3 +536,16 @@ html.group-epics-roadmap-html {
color: var(--gray-500, $gray-500);
padding-top: $gl-spacing-scale-1;
}
.epics-roadmap-filters {
.sort-dropdown-container {
// This override is needed to make sort-dropdown have same height
// as filtered search bar.
@include media-breakpoint-up(sm) {
.dropdown,
> button {
margin-bottom: $gl-padding-8;
}
}
}
}
......@@ -10,6 +10,7 @@ module Groups
before_action do
push_frontend_feature_flag(:async_filtering, @group, default_enabled: true)
push_frontend_feature_flag(:performance_roadmap, @group, default_enabled: :yaml)
push_frontend_feature_flag(:roadmap_daterange_filter, @group, type: :development, default_enabled: :yaml)
end
feature_category :roadmaps
......
......@@ -25,6 +25,7 @@
epics_docs_path: help_page_path('user/group/epics/index'),
group_labels_endpoint: group_labels_path(@group, format: :json),
group_milestones_endpoint: group_milestones_path(@group, format: :json),
timeframe_range_type: params[:timeframe_range_type],
preset_type: roadmap_layout,
epics_state: @epics_state,
sorted_by: @sort,
......
......@@ -10,6 +10,7 @@ import {
} from 'ee/roadmap/constants';
import createStore from 'ee/roadmap/store';
import { REQUEST_EPICS_FOR_NEXT_PAGE } from 'ee/roadmap/store/mutation_types';
import { scrollToCurrentDay } from 'ee/roadmap/utils/epic_utils';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import {
mockFormattedChildEpic1,
......@@ -24,6 +25,11 @@ import {
} from 'ee_jest/roadmap/mock_data';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
jest.mock('ee/roadmap/utils/epic_utils', () => ({
...jest.requireActual('ee/roadmap/utils/epic_utils'),
scrollToCurrentDay: jest.fn(),
}));
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const store = createStore();
store.dispatch('setInitialData', {
......@@ -166,8 +172,6 @@ describe('EpicsListSectionComponent', () => {
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27992#note_319213990
wrapper.destroy();
wrapper = createComponent();
jest.spyOn(wrapper.vm, 'scrollToTodayIndicator').mockImplementation(() => {});
});
it('calls action `setBufferSize` with value based on window.innerHeight and component element position', () => {
......@@ -182,15 +186,12 @@ describe('EpicsListSectionComponent', () => {
});
});
it('calls `scrollToTodayIndicator` following the component render', () => {
it('calls `scrollToCurrentDay` following the component render', async () => {
// Original method implementation waits for render cycle
// to complete at 2 levels before scrolling.
return wrapper.vm
.$nextTick()
.then(() => wrapper.vm.$nextTick())
.then(() => {
expect(wrapper.vm.scrollToTodayIndicator).toHaveBeenCalled();
});
await wrapper.vm.$nextTick(); // set offsetLeft value
await wrapper.vm.$nextTick(); // Wait for nextTick before scroll
expect(scrollToCurrentDay).toHaveBeenCalledWith(wrapper.vm.$el);
});
it('sets style object to `emptyRowContainerStyles`', () => {
......
......@@ -8,6 +8,7 @@ import {
TIMELINE_CELL_MIN_WIDTH,
} from 'ee/roadmap/constants';
import createStore from 'ee/roadmap/store';
import { scrollToCurrentDay } from 'ee/roadmap/utils/epic_utils';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import {
mockTimeframeInitialDate,
......@@ -16,6 +17,11 @@ import {
} from 'ee_jest/roadmap/mock_data';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
jest.mock('ee/roadmap/utils/epic_utils', () => ({
...jest.requireActual('ee/roadmap/utils/epic_utils'),
scrollToCurrentDay: jest.fn(),
}));
const initializeStore = (mockTimeframeMonths) => {
const store = createStore();
store.dispatch('setInitialData', {
......@@ -104,6 +110,14 @@ describe('MilestonesListSectionComponent', () => {
it('sets value of `roadmapShellEl` with root component element', () => {
expect(wrapper.vm.roadmapShellEl instanceof HTMLElement).toBe(true);
});
it('calls `scrollToCurrentDay` following the component render', async () => {
// Original method implementation waits for render cycle
// to complete at 2 levels before scrolling.
await wrapper.vm.$nextTick(); // set offsetLeft value
await wrapper.vm.$nextTick(); // Wait for nextTick before scroll
expect(scrollToCurrentDay).toHaveBeenCalledWith(wrapper.vm.$el);
});
});
describe('handleEpicsListScroll', () => {
......
import { GlSegmentedControl, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import RoadmapFilters from 'ee/roadmap/components/roadmap_filters.vue';
import { PRESET_TYPES, EPICS_STATES } from 'ee/roadmap/constants';
import { PRESET_TYPES, EPICS_STATES, DATE_RANGES } from 'ee/roadmap/constants';
import createStore from 'ee/roadmap/store';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import {
......@@ -18,6 +18,7 @@ import {
} from 'ee_jest/roadmap/mock_data';
import { TEST_HOST } from 'helpers/test_constants';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { visitUrl, mergeUrlParams, updateHistory } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
......@@ -37,6 +38,8 @@ const createComponent = ({
groupMilestonesPath = '/groups/gitlab-org/-/milestones.json',
timeframe = getTimeframeForMonthsView(mockTimeframeInitialDate),
filterParams = {},
roadmapDaterangeFilter = false,
timeframeRangeType = DATE_RANGES.CURRENT_QUARTER,
} = {}) => {
const localVue = createLocalVue();
const store = createStore();
......@@ -51,13 +54,19 @@ const createComponent = ({
timeframe,
});
return shallowMount(RoadmapFilters, {
return shallowMountExtended(RoadmapFilters, {
localVue,
store,
provide: {
groupFullPath,
groupMilestonesPath,
listEpicsPath,
glFeatures: {
roadmapDaterangeFilter,
},
},
props: {
timeframeRangeType,
},
});
};
......@@ -106,13 +115,17 @@ describe('RoadmapFilters', () => {
await wrapper.vm.$nextTick();
expect(global.window.location.href).toBe(
`${TEST_HOST}/?state=${EPICS_STATES.CLOSED}&sort=end_date_asc&author_username=root&label_name%5B%5D=Bug&milestone_title=4.0&confidential=true`,
`${TEST_HOST}/?state=${EPICS_STATES.CLOSED}&sort=end_date_asc&layout=MONTHS&author_username=root&label_name%5B%5D=Bug&milestone_title=4.0&confidential=true`,
);
});
});
});
describe('template', () => {
const quarters = { text: 'Quarters', value: PRESET_TYPES.QUARTERS };
const months = { text: 'Months', value: PRESET_TYPES.MONTHS };
const weeks = { text: 'Weeks', value: PRESET_TYPES.WEEKS };
beforeEach(() => {
updateHistory({ url: TEST_HOST, title: document.title, replace: true });
});
......@@ -122,11 +135,7 @@ describe('RoadmapFilters', () => {
expect(layoutSwitches.exists()).toBe(true);
expect(layoutSwitches.props('checked')).toBe(PRESET_TYPES.MONTHS);
expect(layoutSwitches.props('options')).toEqual([
{ text: 'Quarters', value: PRESET_TYPES.QUARTERS },
{ text: 'Months', value: PRESET_TYPES.MONTHS },
{ text: 'Weeks', value: PRESET_TYPES.WEEKS },
]);
expect(layoutSwitches.props('options')).toEqual([quarters, months, weeks]);
});
it('switching layout using roadmap layout switching buttons causes page to reload with selected layout', () => {
......@@ -302,5 +311,63 @@ describe('RoadmapFilters', () => {
});
});
});
describe('when roadmapDaterangeFilter feature flag is enabled', () => {
let wrapperWithDaterangeFilter;
const availableRanges = [
{ text: 'This quarter', value: DATE_RANGES.CURRENT_QUARTER },
{ text: 'This year', value: DATE_RANGES.CURRENT_YEAR },
{ text: 'Within 3 years', value: DATE_RANGES.THREE_YEARS },
];
beforeEach(async () => {
wrapperWithDaterangeFilter = createComponent({
roadmapDaterangeFilter: true,
timeframeRangeType: DATE_RANGES.CURRENT_QUARTER,
});
await wrapperWithDaterangeFilter.vm.$nextTick();
});
afterEach(() => {
wrapperWithDaterangeFilter.destroy();
});
it('renders daterange dropdown', async () => {
wrapperWithDaterangeFilter.setData({ selectedDaterange: DATE_RANGES.CURRENT_QUARTER });
await wrapperWithDaterangeFilter.vm.$nextTick();
const daterangeDropdown = wrapperWithDaterangeFilter.findByTestId('daterange-dropdown');
expect(daterangeDropdown.exists()).toBe(true);
expect(daterangeDropdown.props('text')).toBe('This quarter');
daterangeDropdown.findAllComponents(GlDropdownItem).wrappers.forEach((item, index) => {
expect(item.text()).toBe(availableRanges[index].text);
expect(item.attributes('value')).toBe(availableRanges[index].value);
});
});
it.each`
selectedDaterange | availablePresets
${DATE_RANGES.CURRENT_QUARTER} | ${[]}
${DATE_RANGES.CURRENT_YEAR} | ${[months, weeks]}
${DATE_RANGES.THREE_YEARS} | ${[quarters, months, weeks]}
`(
'renders $availablePresets.length items when selected daterange is "$selectedDaterange"',
async ({ selectedDaterange, availablePresets }) => {
wrapperWithDaterangeFilter.setData({ selectedDaterange });
await wrapperWithDaterangeFilter.vm.$nextTick();
const layoutSwitches = wrapperWithDaterangeFilter.findComponent(GlSegmentedControl);
if (selectedDaterange === DATE_RANGES.CURRENT_QUARTER) {
expect(layoutSwitches.exists()).toBe(false);
} else {
expect(layoutSwitches.exists()).toBe(true);
expect(layoutSwitches.props('options')).toEqual(availablePresets);
}
},
);
});
});
});
......@@ -44,3 +44,20 @@ describe('generateKey', () => {
expect(epicUtils.generateKey(obj)).toBe('child-epic-3');
});
});
describe('scrollToCurrentDay', () => {
it('scrolls current day indicator into view', () => {
const currentDayIndicator = document.createElement('div');
currentDayIndicator.classList.add('js-current-day-indicator');
document.body.appendChild(currentDayIndicator);
jest.spyOn(currentDayIndicator, 'scrollIntoView').mockImplementation();
epicUtils.scrollToCurrentDay(document.body);
expect(currentDayIndicator.scrollIntoView).toHaveBeenCalledWith({
block: 'nearest',
inline: 'center',
});
});
});
import { PRESET_TYPES } from 'ee/roadmap/constants';
import { PRESET_TYPES, DATE_RANGES } from 'ee/roadmap/constants';
import {
getTimeframeForQuartersView,
extendTimeframeForQuartersView,
......@@ -8,7 +8,10 @@ import {
extendTimeframeForWeeksView,
extendTimeframeForAvailableWidth,
getEpicsTimeframeRange,
getWeeksForDates,
getTimeframeForRangeType,
sortEpics,
getPresetTypeForTimeframeRangeType,
} from 'ee/roadmap/utils/roadmap_utils';
import {
......@@ -25,6 +28,7 @@ import {
const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
const getDateString = (date) => date.toISOString().split('T')[0];
describe('getTimeframeForQuartersView', () => {
let timeframe;
......@@ -295,6 +299,113 @@ describe('extendTimeframeForAvailableWidth', () => {
});
});
describe('getWeeksForDates', () => {
it('returns weeks for given dates', () => {
const weeks = getWeeksForDates(mockTimeframeInitialDate, mockTimeframeMonths[4]);
expect(weeks).toHaveLength(9);
expect(getDateString(weeks[0])).toBe('2017-12-31');
expect(getDateString(weeks[4])).toBe('2018-01-28');
expect(getDateString(weeks[8])).toBe('2018-02-25');
});
});
describe('getTimeframeForRangeType', () => {
beforeEach(() => {
jest.useFakeTimers('modern');
jest.setSystemTime(new Date('2021-01-01'));
});
afterEach(() => {
jest.useFakeTimers('legacy');
jest.runOnlyPendingTimers();
});
it('returns timeframe with weeks when timeframeRangeType is current quarter', () => {
const timeframe = getTimeframeForRangeType({ timeframeRangeType: DATE_RANGES.CURRENT_QUARTER });
expect(timeframe).toHaveLength(14);
expect(getDateString(timeframe[0])).toBe('2020-12-27');
expect(getDateString(timeframe[6])).toBe('2021-02-07');
expect(getDateString(timeframe[13])).toBe('2021-03-28');
});
it('returns timeframe with months when timeframeRangeType is current year and preset type is months', () => {
const timeframe = getTimeframeForRangeType({
timeframeRangeType: DATE_RANGES.CURRENT_YEAR,
presetType: PRESET_TYPES.MONTHS,
});
expect(timeframe).toHaveLength(12);
expect(getDateString(timeframe[0])).toBe('2021-01-01');
expect(getDateString(timeframe[5])).toBe('2021-06-01');
expect(getDateString(timeframe[11])).toBe('2021-12-31');
});
it('returns timeframe with weeks when timeframeRangeType is current year', () => {
const timeframe = getTimeframeForRangeType({
timeframeRangeType: DATE_RANGES.CURRENT_YEAR,
presetType: PRESET_TYPES.WEEKS,
});
expect(timeframe).toHaveLength(53);
expect(getDateString(timeframe[0])).toBe('2020-12-27');
expect(getDateString(timeframe[25])).toBe('2021-06-20');
expect(getDateString(timeframe[52])).toBe('2021-12-26');
});
it('returns timeframe with quarters when timeframeRangeType is within 3 years', () => {
const timeframe = getTimeframeForRangeType({
timeframeRangeType: DATE_RANGES.THREE_YEARS,
presetType: PRESET_TYPES.QUARTERS,
});
expect(timeframe).toHaveLength(12);
expect(timeframe[0]).toMatchObject({
quarterSequence: 3,
year: 2019,
range: expect.any(Array),
});
expect(getDateString(timeframe[0].range[0])).toBe('2019-07-01');
expect(getDateString(timeframe[0].range[1])).toBe('2019-08-01');
expect(getDateString(timeframe[0].range[2])).toBe('2019-09-30');
expect(timeframe[11]).toMatchObject({
quarterSequence: 2,
year: 2022,
range: expect.any(Array),
});
expect(getDateString(timeframe[11].range[0])).toBe('2022-04-01');
expect(getDateString(timeframe[11].range[1])).toBe('2022-05-01');
expect(getDateString(timeframe[11].range[2])).toBe('2022-06-30');
});
it('returns timeframe with months when timeframeRangeType is within 3 years', () => {
const timeframe = getTimeframeForRangeType({
timeframeRangeType: DATE_RANGES.THREE_YEARS,
presetType: PRESET_TYPES.MONTHS,
});
expect(timeframe).toHaveLength(36);
expect(getDateString(timeframe[0])).toBe('2019-07-01');
expect(getDateString(timeframe[35])).toBe('2022-06-30');
});
it('returns timeframe with weeks when timeframeRangeType is within 3 years', () => {
const timeframe = getTimeframeForRangeType({
timeframeRangeType: DATE_RANGES.THREE_YEARS,
presetType: PRESET_TYPES.WEEKS,
});
expect(timeframe).toHaveLength(161);
expect(getDateString(timeframe[0])).toBe('2019-06-30');
expect(getDateString(timeframe[160])).toBe('2022-07-24');
});
});
describe('getEpicsTimeframeRange', () => {
it('returns object containing startDate and dueDate based on provided timeframe for Quarters', () => {
const timeframeQuarters = getTimeframeForQuartersView(new Date(2018, 0, 1));
......@@ -437,3 +548,17 @@ describe('sortEpics', () => {
});
});
});
describe('getPresetTypeForTimeframeRangeType', () => {
it.each`
timeframeRangeType | presetType
${DATE_RANGES.CURRENT_QUARTER} | ${PRESET_TYPES.WEEKS}
${DATE_RANGES.CURRENT_YEAR} | ${PRESET_TYPES.MONTHS}
${DATE_RANGES.THREE_YEARS} | ${PRESET_TYPES.QUARTERS}
`(
'returns presetType as $presetType when $timeframeRangeType',
({ timeframeRangeType, presetType }) => {
expect(getPresetTypeForTimeframeRangeType(timeframeRangeType)).toEqual(presetType);
},
);
});
......@@ -15908,6 +15908,12 @@ msgstr ""
msgid "GroupRoadmap|The roadmap shows the progress of your epics along a timeline"
msgstr ""
msgid "GroupRoadmap|This quarter"
msgstr ""
msgid "GroupRoadmap|This year"
msgstr ""
msgid "GroupRoadmap|To make your epics appear in the roadmap, add start or due dates to them."
msgstr ""
......@@ -15920,6 +15926,9 @@ msgstr ""
msgid "GroupRoadmap|To widen your search, change or remove filters; from %{startDate} to %{endDate}."
msgstr ""
msgid "GroupRoadmap|Within 3 years"
msgstr ""
msgid "GroupSAML|%{strongOpen}Warning%{strongClose} - Enabling %{linkStart}SSO enforcement%{linkEnd} can reduce security risks."
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