Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
G
gitlab-ce
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
1
Merge Requests
1
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
nexedi
gitlab-ce
Commits
48318f75
Commit
48318f75
authored
Aug 20, 2021
by
Kushal Pandya
Committed by
Olena Horal-Koretska
Aug 20, 2021
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add daterange picker to filter Roadmap
parent
e2e50bb9
Changes
22
Show whitespace changes
Inline
Side-by-side
Showing
22 changed files
with
601 additions
and
77 deletions
+601
-77
config/feature_flags/development/roadmap_daterange_filter.yml
...ig/feature_flags/development/roadmap_daterange_filter.yml
+8
-0
ee/app/assets/javascripts/roadmap/components/current_day_indicator.vue
.../javascripts/roadmap/components/current_day_indicator.vue
+1
-1
ee/app/assets/javascripts/roadmap/components/epics_list_section.vue
...ets/javascripts/roadmap/components/epics_list_section.vue
+2
-9
ee/app/assets/javascripts/roadmap/components/milestones_list_section.vue
...avascripts/roadmap/components/milestones_list_section.vue
+2
-4
ee/app/assets/javascripts/roadmap/components/roadmap_app.vue
ee/app/assets/javascripts/roadmap/components/roadmap_app.vue
+7
-1
ee/app/assets/javascripts/roadmap/components/roadmap_filters.vue
...assets/javascripts/roadmap/components/roadmap_filters.vue
+104
-13
ee/app/assets/javascripts/roadmap/components/roadmap_shell.vue
...p/assets/javascripts/roadmap/components/roadmap_shell.vue
+23
-18
ee/app/assets/javascripts/roadmap/constants.js
ee/app/assets/javascripts/roadmap/constants.js
+6
-0
ee/app/assets/javascripts/roadmap/mixins/filtered_search_mixin.js
...ssets/javascripts/roadmap/mixins/filtered_search_mixin.js
+2
-0
ee/app/assets/javascripts/roadmap/roadmap_bundle.js
ee/app/assets/javascripts/roadmap/roadmap_bundle.js
+35
-12
ee/app/assets/javascripts/roadmap/store/state.js
ee/app/assets/javascripts/roadmap/store/state.js
+1
-0
ee/app/assets/javascripts/roadmap/utils/epic_utils.js
ee/app/assets/javascripts/roadmap/utils/epic_utils.js
+7
-0
ee/app/assets/javascripts/roadmap/utils/roadmap_utils.js
ee/app/assets/javascripts/roadmap/utils/roadmap_utils.js
+136
-0
ee/app/assets/stylesheets/page_bundles/roadmap.scss
ee/app/assets/stylesheets/page_bundles/roadmap.scss
+13
-0
ee/app/controllers/groups/roadmap_controller.rb
ee/app/controllers/groups/roadmap_controller.rb
+1
-0
ee/app/views/groups/roadmap/show.html.haml
ee/app/views/groups/roadmap/show.html.haml
+1
-0
ee/spec/frontend/roadmap/components/epics_list_section_spec.js
...ec/frontend/roadmap/components/epics_list_section_spec.js
+10
-9
ee/spec/frontend/roadmap/components/milestones_list_section_spec.js
...ontend/roadmap/components/milestones_list_section_spec.js
+14
-0
ee/spec/frontend/roadmap/components/roadmap_filters_spec.js
ee/spec/frontend/roadmap/components/roadmap_filters_spec.js
+76
-9
ee/spec/frontend/roadmap/utils/epic_utils_spec.js
ee/spec/frontend/roadmap/utils/epic_utils_spec.js
+17
-0
ee/spec/frontend/roadmap/utils/roadmap_utils_spec.js
ee/spec/frontend/roadmap/utils/roadmap_utils_spec.js
+126
-1
locale/gitlab.pot
locale/gitlab.pot
+9
-0
No files found.
config/feature_flags/development/roadmap_daterange_filter.yml
0 → 100644
View file @
48318f75
---
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
ee/app/assets/javascripts/roadmap/components/current_day_indicator.vue
View file @
48318f75
...
@@ -34,6 +34,6 @@ export default {
...
@@ -34,6 +34,6 @@ export default {
<span
<span
v-if=
"hasToday"
v-if=
"hasToday"
:style=
"indicatorStyles"
:style=
"indicatorStyles"
class=
"current-day-indicator
position
-absolute"
class=
"current-day-indicator
js-current-day-indicator gl
-absolute"
></span>
></span>
</
template
>
</
template
>
ee/app/assets/javascripts/roadmap/components/epics_list_section.vue
View file @
48318f75
...
@@ -5,7 +5,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
...
@@ -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
{
EPIC_DETAILS_CELL_WIDTH
,
TIMELINE_CELL_MIN_WIDTH
,
EPIC_ITEM_HEIGHT
}
from
'
../constants
'
;
import
eventHub
from
'
../event_hub
'
;
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
CurrentDayIndicator
from
'
./current_day_indicator.vue
'
;
import
EpicItem
from
'
./epic_item.vue
'
;
import
EpicItem
from
'
./epic_item.vue
'
;
...
@@ -115,7 +115,7 @@ export default {
...
@@ -115,7 +115,7 @@ export default {
// to timeline expand, so we wait for another render
// to timeline expand, so we wait for another render
// cycle to complete.
// cycle to complete.
this
.
$nextTick
(()
=>
{
this
.
$nextTick
(()
=>
{
this
.
scrollToTodayIndicator
(
);
scrollToCurrentDay
(
this
.
$el
);
});
});
if
(
!
Object
.
keys
(
this
.
emptyRowContainerStyles
).
length
)
{
if
(
!
Object
.
keys
(
this
.
emptyRowContainerStyles
).
length
)
{
...
@@ -139,13 +139,6 @@ export default {
...
@@ -139,13 +139,6 @@ export default {
}
}
return
{};
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
})
{
handleEpicsListScroll
({
scrollTop
,
clientHeight
,
scrollHeight
})
{
this
.
showBottomShadow
=
Math
.
ceil
(
scrollTop
)
+
clientHeight
<
scrollHeight
;
this
.
showBottomShadow
=
Math
.
ceil
(
scrollTop
)
+
clientHeight
<
scrollHeight
;
},
},
...
...
ee/app/assets/javascripts/roadmap/components/milestones_list_section.vue
View file @
48318f75
...
@@ -4,6 +4,7 @@ import { mapState, mapActions } from 'vuex';
...
@@ -4,6 +4,7 @@ import { mapState, mapActions } from 'vuex';
import
{
__
,
n__
}
from
'
~/locale
'
;
import
{
__
,
n__
}
from
'
~/locale
'
;
import
{
EPIC_DETAILS_CELL_WIDTH
,
EPIC_ITEM_HEIGHT
,
TIMELINE_CELL_MIN_WIDTH
}
from
'
../constants
'
;
import
{
EPIC_DETAILS_CELL_WIDTH
,
EPIC_ITEM_HEIGHT
,
TIMELINE_CELL_MIN_WIDTH
}
from
'
../constants
'
;
import
eventHub
from
'
../event_hub
'
;
import
eventHub
from
'
../event_hub
'
;
import
{
scrollToCurrentDay
}
from
'
../utils/epic_utils
'
;
import
MilestoneTimeline
from
'
./milestone_timeline.vue
'
;
import
MilestoneTimeline
from
'
./milestone_timeline.vue
'
;
const
EXPAND_BUTTON_EXPANDED
=
{
const
EXPAND_BUTTON_EXPANDED
=
{
...
@@ -97,13 +98,10 @@ export default {
...
@@ -97,13 +98,10 @@ export default {
this
.
offsetLeft
=
(
this
.
$el
.
parentElement
&&
this
.
$el
.
parentElement
.
offsetLeft
)
||
0
;
this
.
offsetLeft
=
(
this
.
$el
.
parentElement
&&
this
.
$el
.
parentElement
.
offsetLeft
)
||
0
;
this
.
$nextTick
(()
=>
{
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
})
{
handleEpicsListScroll
({
scrollTop
,
clientHeight
,
scrollHeight
})
{
this
.
showBottomShadow
=
Math
.
ceil
(
scrollTop
)
+
clientHeight
<
scrollHeight
;
this
.
showBottomShadow
=
Math
.
ceil
(
scrollTop
)
+
clientHeight
<
scrollHeight
;
},
},
...
...
ee/app/assets/javascripts/roadmap/components/roadmap_app.vue
View file @
48318f75
...
@@ -9,6 +9,7 @@ import {
...
@@ -9,6 +9,7 @@ import {
EXTEND_AS
,
EXTEND_AS
,
EPICS_LIMIT_DISMISSED_COOKIE_NAME
,
EPICS_LIMIT_DISMISSED_COOKIE_NAME
,
EPICS_LIMIT_DISMISSED_COOKIE_TIMEOUT
,
EPICS_LIMIT_DISMISSED_COOKIE_TIMEOUT
,
DATE_RANGES
,
}
from
'
../constants
'
;
}
from
'
../constants
'
;
import
eventHub
from
'
../event_hub
'
;
import
eventHub
from
'
../event_hub
'
;
import
EpicsListEmpty
from
'
./epics_list_empty.vue
'
;
import
EpicsListEmpty
from
'
./epics_list_empty.vue
'
;
...
@@ -32,6 +33,11 @@ export default {
...
@@ -32,6 +33,11 @@ export default {
},
},
mixins
:
[
glFeatureFlagsMixin
()],
mixins
:
[
glFeatureFlagsMixin
()],
props
:
{
props
:
{
timeframeRangeType
:
{
type
:
String
,
required
:
false
,
default
:
DATE_RANGES
.
CURRENT_QUARTER
,
},
presetType
:
{
presetType
:
{
type
:
String
,
type
:
String
,
required
:
true
,
required
:
true
,
...
@@ -155,7 +161,7 @@ export default {
...
@@ -155,7 +161,7 @@ export default {
<
template
>
<
template
>
<div
class=
"roadmap-app-container gl-h-full"
>
<div
class=
"roadmap-app-container gl-h-full"
>
<roadmap-filters
v-if=
"showFilteredSearchbar"
/>
<roadmap-filters
v-if=
"showFilteredSearchbar"
:timeframe-range-type=
"timeframeRangeType"
/>
<gl-alert
<gl-alert
v-if=
"isWarningVisible"
v-if=
"isWarningVisible"
variant=
"warning"
variant=
"warning"
...
...
ee/app/assets/javascripts/roadmap/components/roadmap_filters.vue
View file @
48318f75
...
@@ -9,18 +9,26 @@ import {
...
@@ -9,18 +9,26 @@ import {
import
{
mapState
,
mapActions
}
from
'
vuex
'
;
import
{
mapState
,
mapActions
}
from
'
vuex
'
;
import
{
visitUrl
,
mergeUrlParams
,
updateHistory
,
setUrlParams
}
from
'
~/lib/utils/url_utility
'
;
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
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
EpicsFilteredSearchMixin
from
'
../mixins/filtered_search_mixin
'
;
import
{
getPresetTypeForTimeframeRangeType
}
from
'
../utils/roadmap_utils
'
;
const
pickerType
=
{
Start
:
'
start
'
,
End
:
'
end
'
,
};
export
default
{
export
default
{
pickerType
,
epicStates
:
EPICS_STATES
,
epicStates
:
EPICS_STATES
,
available
Preset
s
:
[
available
DateRange
s
:
[
{
text
:
__
(
'
Quarters
'
),
value
:
PRESET_TYPES
.
QUARTERS
},
{
text
:
s__
(
'
GroupRoadmap|This quarter
'
),
value
:
DATE_RANGES
.
CURRENT_QUARTER
},
{
text
:
__
(
'
Months
'
),
value
:
PRESET_TYPES
.
MONTHS
},
{
text
:
s__
(
'
GroupRoadmap|This year
'
),
value
:
DATE_RANGES
.
CURRENT_YEAR
},
{
text
:
__
(
'
Weeks
'
),
value
:
PRESET_TYPES
.
WEEK
S
},
{
text
:
s__
(
'
GroupRoadmap|Within 3 years
'
),
value
:
DATE_RANGES
.
THREE_YEAR
S
},
],
],
availableSortOptions
:
[
availableSortOptions
:
[
{
{
...
@@ -48,7 +56,18 @@ export default {
...
@@ -48,7 +56,18 @@ export default {
GlDropdownDivider
,
GlDropdownDivider
,
FilteredSearchBar
,
FilteredSearchBar
,
},
},
mixins
:
[
EpicsFilteredSearchMixin
],
mixins
:
[
EpicsFilteredSearchMixin
,
glFeatureFlagsMixin
()],
props
:
{
timeframeRangeType
:
{
type
:
String
,
required
:
true
,
},
},
data
()
{
return
{
selectedDaterange
:
this
.
timeframeRangeType
,
};
},
computed
:
{
computed
:
{
...
mapState
([
'
presetType
'
,
'
epicsState
'
,
'
sortedBy
'
,
'
filterParams
'
]),
...
mapState
([
'
presetType
'
,
'
epicsState
'
,
'
sortedBy
'
,
'
filterParams
'
]),
selectedEpicStateTitle
()
{
selectedEpicStateTitle
()
{
...
@@ -59,6 +78,34 @@ export default {
...
@@ -59,6 +78,34 @@ export default {
}
}
return
__
(
'
Closed epics
'
);
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
:
{
watch
:
{
urlParams
:
{
urlParams
:
{
...
@@ -77,8 +124,34 @@ export default {
...
@@ -77,8 +124,34 @@ export default {
},
},
methods
:
{
methods
:
{
...
mapActions
([
'
setEpicsState
'
,
'
setFilterParams
'
,
'
setSortedBy
'
,
'
fetchEpics
'
]),
...
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
)
{
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
)
{
handleEpicStateChange
(
epicsState
)
{
this
.
setEpicsState
(
epicsState
);
this
.
setEpicsState
(
epicsState
);
...
@@ -99,12 +172,30 @@ export default {
...
@@ -99,12 +172,30 @@ export default {
<
template
>
<
template
>
<div
class=
"epics-filters epics-roadmap-filters epics-roadmap-filters-gl-ui"
>
<div
class=
"epics-filters epics-roadmap-filters epics-roadmap-filters-gl-ui"
>
<div
<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
<gl-segmented-control
:checked=
"presetType"
:checked=
"presetType"
:options=
"
$options.
availablePresets"
:options=
"availablePresets"
class=
"gl-display-flex d-xl-block"
class=
"gl-display-flex d-xl-block"
buttons
buttons
@
input=
"handleRoadmapLayoutChange"
@
input=
"handleRoadmapLayoutChange"
...
@@ -112,8 +203,8 @@ export default {
...
@@ -112,8 +203,8 @@ export default {
</gl-form-group>
</gl-form-group>
<gl-dropdown
<gl-dropdown
:text=
"selectedEpicStateTitle"
:text=
"selectedEpicStateTitle"
class=
"gl-m
y-2 my-xl-0 mx-xl
-2"
class=
"gl-m
r-0 gl-lg-mr-3 mb-sm
-2"
toggle-class=
"gl-rounded-
small
"
toggle-class=
"gl-rounded-
base!
"
>
>
<gl-dropdown-item
<gl-dropdown-item
:is-check-item=
"true"
:is-check-item=
"true"
...
...
ee/app/assets/javascripts/roadmap/components/roadmap_shell.vue
View file @
48318f75
...
@@ -2,6 +2,7 @@
...
@@ -2,6 +2,7 @@
import
{
mapState
}
from
'
vuex
'
;
import
{
mapState
}
from
'
vuex
'
;
import
{
isInViewport
}
from
'
~/lib/utils/common_utils
'
;
import
{
isInViewport
}
from
'
~/lib/utils/common_utils
'
;
import
glFeatureFlagsMixin
from
'
~/vue_shared/mixins/gl_feature_flags_mixin
'
;
import
{
EXTEND_AS
}
from
'
../constants
'
;
import
{
EXTEND_AS
}
from
'
../constants
'
;
import
eventHub
from
'
../event_hub
'
;
import
eventHub
from
'
../event_hub
'
;
...
@@ -15,6 +16,7 @@ export default {
...
@@ -15,6 +16,7 @@ export default {
milestonesListSection
,
milestonesListSection
,
roadmapTimelineSection
,
roadmapTimelineSection
,
},
},
mixins
:
[
glFeatureFlagsMixin
()],
props
:
{
props
:
{
presetType
:
{
presetType
:
{
type
:
String
,
type
:
String
,
...
@@ -67,6 +69,8 @@ export default {
...
@@ -67,6 +69,8 @@ export default {
methods
:
{
methods
:
{
handleScroll
()
{
handleScroll
()
{
const
{
scrollTop
,
scrollLeft
,
clientHeight
,
scrollHeight
}
=
this
.
$el
;
const
{
scrollTop
,
scrollLeft
,
clientHeight
,
scrollHeight
}
=
this
.
$el
;
if
(
!
this
.
glFeatures
.
roadmapDaterangeFilter
)
{
const
timelineEdgeStartEl
=
this
.
$refs
.
roadmapTimeline
.
$el
const
timelineEdgeStartEl
=
this
.
$refs
.
roadmapTimeline
.
$el
.
querySelector
(
'
.timeline-header-item
'
)
.
querySelector
(
'
.timeline-header-item
'
)
.
querySelector
(
'
.item-sublabel .sublabel-value:first-child
'
);
.
querySelector
(
'
.item-sublabel .sublabel-value:first-child
'
);
...
@@ -87,6 +91,7 @@ export default {
...
@@ -87,6 +91,7 @@ export default {
extendAs
:
EXTEND_AS
.
APPEND
,
extendAs
:
EXTEND_AS
.
APPEND
,
});
});
}
}
}
eventHub
.
$emit
(
'
epicsListScrolled
'
,
{
scrollTop
,
scrollLeft
,
clientHeight
,
scrollHeight
});
eventHub
.
$emit
(
'
epicsListScrolled
'
,
{
scrollTop
,
scrollLeft
,
clientHeight
,
scrollHeight
});
},
},
...
...
ee/app/assets/javascripts/roadmap/constants.js
View file @
48318f75
...
@@ -23,6 +23,12 @@ export const PERCENTAGE = 100;
...
@@ -23,6 +23,12 @@ export const PERCENTAGE = 100;
export
const
SMALL_TIMELINE_BAR
=
40
;
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
=
{
export
const
PRESET_TYPES
=
{
QUARTERS
:
'
QUARTERS
'
,
QUARTERS
:
'
QUARTERS
'
,
MONTHS
:
'
MONTHS
'
,
MONTHS
:
'
MONTHS
'
,
...
...
ee/app/assets/javascripts/roadmap/mixins/filtered_search_mixin.js
View file @
48318f75
...
@@ -40,6 +40,8 @@ export default {
...
@@ -40,6 +40,8 @@ export default {
sort
:
this
.
sortedBy
,
sort
:
this
.
sortedBy
,
prev
:
this
.
prevPageCursor
||
undefined
,
prev
:
this
.
prevPageCursor
||
undefined
,
next
:
this
.
nextPageCursor
||
undefined
,
next
:
this
.
nextPageCursor
||
undefined
,
layout
:
this
.
presetType
||
undefined
,
timeframe_range_type
:
this
.
timeframeRangeType
||
undefined
,
author_username
:
authorUsername
,
author_username
:
authorUsername
,
'
label_name[]
'
:
labelName
,
'
label_name[]
'
:
labelName
,
milestone_title
:
milestoneTitle
,
milestone_title
:
milestoneTitle
,
...
...
ee/app/assets/javascripts/roadmap/roadmap_bundle.js
View file @
48318f75
...
@@ -9,10 +9,14 @@ import EpicItem from './components/epic_item.vue';
...
@@ -9,10 +9,14 @@ import EpicItem from './components/epic_item.vue';
import
EpicItemContainer
from
'
./components/epic_item_container.vue
'
;
import
EpicItemContainer
from
'
./components/epic_item_container.vue
'
;
import
roadmapApp
from
'
./components/roadmap_app.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
createStore
from
'
./store
'
;
import
{
getTimeframeForPreset
}
from
'
./utils/roadmap_utils
'
;
import
{
getTimeframeForPreset
,
getPresetTypeForTimeframeRangeType
,
getTimeframeForRangeType
,
}
from
'
./utils/roadmap_utils
'
;
Vue
.
use
(
Translate
);
Vue
.
use
(
Translate
);
...
@@ -57,18 +61,38 @@ export default () => {
...
@@ -57,18 +61,38 @@ export default () => {
};
};
},
},
data
()
{
data
()
{
const
supportedPresetTypes
=
Object
.
keys
(
PRESET_TYPES
);
const
{
dataset
}
=
this
.
$options
.
el
;
const
{
dataset
}
=
this
.
$options
.
el
;
const
presetType
=
let
timeframe
;
supportedPresetTypes
.
indexOf
(
dataset
.
presetType
)
>
-
1
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
?
dataset
.
presetType
:
PRESET_TYPES
.
MONTHS
;
:
PRESET_TYPES
.
MONTHS
;
timeframe
=
getTimeframeForPreset
(
presetType
,
window
.
innerWidth
-
el
.
offsetLeft
-
EPIC_DETAILS_CELL_WIDTH
,
);
}
const
rawFilterParams
=
queryToObject
(
window
.
location
.
search
,
{
const
rawFilterParams
=
queryToObject
(
window
.
location
.
search
,
{
gatherArrays
:
true
,
gatherArrays
:
true
,
});
});
const
filterParams
=
{
const
filterParams
=
{
...
convertObjectPropsToCamelCase
(
rawFilterParams
,
{
...
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
// We shall put parsed value of `confidential` only
// when it is defined.
// when it is defined.
...
@@ -80,10 +104,6 @@ export default () => {
...
@@ -80,10 +104,6 @@ export default () => {
epicIid
:
rawFilterParams
.
epicIid
,
epicIid
:
rawFilterParams
.
epicIid
,
}),
}),
};
};
const
timeframe
=
getTimeframeForPreset
(
presetType
,
window
.
innerWidth
-
el
.
offsetLeft
-
EPIC_DETAILS_CELL_WIDTH
,
);
return
{
return
{
emptyStateIllustrationPath
:
dataset
.
emptyStateIllustration
,
emptyStateIllustrationPath
:
dataset
.
emptyStateIllustration
,
...
@@ -98,6 +118,7 @@ export default () => {
...
@@ -98,6 +118,7 @@ export default () => {
epicsState
:
dataset
.
epicsState
,
epicsState
:
dataset
.
epicsState
,
sortedBy
:
dataset
.
sortedBy
,
sortedBy
:
dataset
.
sortedBy
,
filterParams
,
filterParams
,
timeframeRangeType
,
presetType
,
presetType
,
timeframe
,
timeframe
,
};
};
...
@@ -108,6 +129,7 @@ export default () => {
...
@@ -108,6 +129,7 @@ export default () => {
fullPath
:
this
.
fullPath
,
fullPath
:
this
.
fullPath
,
epicIid
:
this
.
epicIid
,
epicIid
:
this
.
epicIid
,
sortedBy
:
this
.
sortedBy
,
sortedBy
:
this
.
sortedBy
,
timeframeRangeType
:
this
.
timeframeRangeType
,
presetType
:
this
.
presetType
,
presetType
:
this
.
presetType
,
epicsState
:
this
.
epicsState
,
epicsState
:
this
.
epicsState
,
timeframe
:
this
.
timeframe
,
timeframe
:
this
.
timeframe
,
...
@@ -125,6 +147,7 @@ export default () => {
...
@@ -125,6 +147,7 @@ export default () => {
render
(
createElement
)
{
render
(
createElement
)
{
return
createElement
(
'
roadmap-app
'
,
{
return
createElement
(
'
roadmap-app
'
,
{
props
:
{
props
:
{
timeframeRangeType
:
this
.
timeframeRangeType
,
presetType
:
this
.
presetType
,
presetType
:
this
.
presetType
,
emptyStateIllustrationPath
:
this
.
emptyStateIllustrationPath
,
emptyStateIllustrationPath
:
this
.
emptyStateIllustrationPath
,
},
},
...
...
ee/app/assets/javascripts/roadmap/store/state.js
View file @
48318f75
...
@@ -17,6 +17,7 @@ export default () => ({
...
@@ -17,6 +17,7 @@ export default () => ({
timeframe
:
[],
timeframe
:
[],
extendedTimeframe
:
[],
extendedTimeframe
:
[],
presetType
:
''
,
presetType
:
''
,
timeframeRangeType
:
''
,
sortedBy
:
''
,
sortedBy
:
''
,
milestoneIds
:
[],
milestoneIds
:
[],
milestones
:
[],
milestones
:
[],
...
...
ee/app/assets/javascripts/roadmap/utils/epic_utils.js
View file @
48318f75
...
@@ -10,3 +10,10 @@ export const gqClient = createGqClient(
...
@@ -10,3 +10,10 @@ export const gqClient = createGqClient(
export
const
addIsChildEpicTrueProperty
=
(
obj
)
=>
({
...
obj
,
isChildEpic
:
true
});
export
const
addIsChildEpicTrueProperty
=
(
obj
)
=>
({
...
obj
,
isChildEpic
:
true
});
export
const
generateKey
=
(
epic
)
=>
`
${
epic
.
isChildEpic
?
'
child-epic-
'
:
'
epic-
'
}${
epic
.
id
}
`
;
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
'
});
}
};
ee/app/assets/javascripts/roadmap/utils/roadmap_utils.js
View file @
48318f75
...
@@ -4,6 +4,7 @@ import {
...
@@ -4,6 +4,7 @@ import {
DAYS_IN_WEEK
,
DAYS_IN_WEEK
,
EXTEND_AS
,
EXTEND_AS
,
PRESET_DEFAULTS
,
PRESET_DEFAULTS
,
DATE_RANGES
,
PRESET_TYPES
,
PRESET_TYPES
,
TIMELINE_CELL_MIN_WIDTH
,
TIMELINE_CELL_MIN_WIDTH
,
}
from
'
../constants
'
;
}
from
'
../constants
'
;
...
@@ -364,6 +365,117 @@ export const getTimeframeForPreset = (
...
@@ -364,6 +365,117 @@ export const getTimeframeForPreset = (
return
timeframe
;
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.
* Returns timeframe range in string based on provided config.
*
*
...
@@ -440,3 +552,27 @@ export const sortEpics = (epics, sortedBy) => {
...
@@ -440,3 +552,27 @@ export const sortEpics = (epics, sortedBy) => {
return
0
;
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
;
};
ee/app/assets/stylesheets/page_bundles/roadmap.scss
View file @
48318f75
...
@@ -536,3 +536,16 @@ html.group-epics-roadmap-html {
...
@@ -536,3 +536,16 @@ html.group-epics-roadmap-html {
color
:
var
(
--
gray-500
,
$gray-500
);
color
:
var
(
--
gray-500
,
$gray-500
);
padding-top
:
$gl-spacing-scale-1
;
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
;
}
}
}
}
ee/app/controllers/groups/roadmap_controller.rb
View file @
48318f75
...
@@ -10,6 +10,7 @@ module Groups
...
@@ -10,6 +10,7 @@ module Groups
before_action
do
before_action
do
push_frontend_feature_flag
(
:async_filtering
,
@group
,
default_enabled:
true
)
push_frontend_feature_flag
(
:async_filtering
,
@group
,
default_enabled:
true
)
push_frontend_feature_flag
(
:performance_roadmap
,
@group
,
default_enabled: :yaml
)
push_frontend_feature_flag
(
:performance_roadmap
,
@group
,
default_enabled: :yaml
)
push_frontend_feature_flag
(
:roadmap_daterange_filter
,
@group
,
type: :development
,
default_enabled: :yaml
)
end
end
feature_category
:roadmaps
feature_category
:roadmaps
...
...
ee/app/views/groups/roadmap/show.html.haml
View file @
48318f75
...
@@ -25,6 +25,7 @@
...
@@ -25,6 +25,7 @@
epics_docs_path:
help_page_path
(
'user/group/epics/index'
),
epics_docs_path:
help_page_path
(
'user/group/epics/index'
),
group_labels_endpoint:
group_labels_path
(
@group
,
format: :json
),
group_labels_endpoint:
group_labels_path
(
@group
,
format: :json
),
group_milestones_endpoint:
group_milestones_path
(
@group
,
format: :json
),
group_milestones_endpoint:
group_milestones_path
(
@group
,
format: :json
),
timeframe_range_type:
params
[
:timeframe_range_type
],
preset_type:
roadmap_layout
,
preset_type:
roadmap_layout
,
epics_state:
@epics_state
,
epics_state:
@epics_state
,
sorted_by:
@sort
,
sorted_by:
@sort
,
...
...
ee/spec/frontend/roadmap/components/epics_list_section_spec.js
View file @
48318f75
...
@@ -10,6 +10,7 @@ import {
...
@@ -10,6 +10,7 @@ import {
}
from
'
ee/roadmap/constants
'
;
}
from
'
ee/roadmap/constants
'
;
import
createStore
from
'
ee/roadmap/store
'
;
import
createStore
from
'
ee/roadmap/store
'
;
import
{
REQUEST_EPICS_FOR_NEXT_PAGE
}
from
'
ee/roadmap/store/mutation_types
'
;
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
{
getTimeframeForMonthsView
}
from
'
ee/roadmap/utils/roadmap_utils
'
;
import
{
import
{
mockFormattedChildEpic1
,
mockFormattedChildEpic1
,
...
@@ -24,6 +25,11 @@ import {
...
@@ -24,6 +25,11 @@ import {
}
from
'
ee_jest/roadmap/mock_data
'
;
}
from
'
ee_jest/roadmap/mock_data
'
;
import
{
shallowMountExtended
}
from
'
helpers/vue_test_utils_helper
'
;
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
mockTimeframeMonths
=
getTimeframeForMonthsView
(
mockTimeframeInitialDate
);
const
store
=
createStore
();
const
store
=
createStore
();
store
.
dispatch
(
'
setInitialData
'
,
{
store
.
dispatch
(
'
setInitialData
'
,
{
...
@@ -166,8 +172,6 @@ describe('EpicsListSectionComponent', () => {
...
@@ -166,8 +172,6 @@ describe('EpicsListSectionComponent', () => {
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27992#note_319213990
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27992#note_319213990
wrapper
.
destroy
();
wrapper
.
destroy
();
wrapper
=
createComponent
();
wrapper
=
createComponent
();
jest
.
spyOn
(
wrapper
.
vm
,
'
scrollToTodayIndicator
'
).
mockImplementation
(()
=>
{});
});
});
it
(
'
calls action `setBufferSize` with value based on window.innerHeight and component element position
'
,
()
=>
{
it
(
'
calls action `setBufferSize` with value based on window.innerHeight and component element position
'
,
()
=>
{
...
@@ -182,15 +186,12 @@ describe('EpicsListSectionComponent', () => {
...
@@ -182,15 +186,12 @@ describe('EpicsListSectionComponent', () => {
});
});
});
});
it
(
'
calls `scrollTo
TodayIndicator` following the component render
'
,
()
=>
{
it
(
'
calls `scrollTo
CurrentDay` following the component render
'
,
async
()
=>
{
// Original method implementation waits for render cycle
// Original method implementation waits for render cycle
// to complete at 2 levels before scrolling.
// to complete at 2 levels before scrolling.
return
wrapper
.
vm
await
wrapper
.
vm
.
$nextTick
();
// set offsetLeft value
.
$nextTick
()
await
wrapper
.
vm
.
$nextTick
();
// Wait for nextTick before scroll
.
then
(()
=>
wrapper
.
vm
.
$nextTick
())
expect
(
scrollToCurrentDay
).
toHaveBeenCalledWith
(
wrapper
.
vm
.
$el
);
.
then
(()
=>
{
expect
(
wrapper
.
vm
.
scrollToTodayIndicator
).
toHaveBeenCalled
();
});
});
});
it
(
'
sets style object to `emptyRowContainerStyles`
'
,
()
=>
{
it
(
'
sets style object to `emptyRowContainerStyles`
'
,
()
=>
{
...
...
ee/spec/frontend/roadmap/components/milestones_list_section_spec.js
View file @
48318f75
...
@@ -8,6 +8,7 @@ import {
...
@@ -8,6 +8,7 @@ import {
TIMELINE_CELL_MIN_WIDTH
,
TIMELINE_CELL_MIN_WIDTH
,
}
from
'
ee/roadmap/constants
'
;
}
from
'
ee/roadmap/constants
'
;
import
createStore
from
'
ee/roadmap/store
'
;
import
createStore
from
'
ee/roadmap/store
'
;
import
{
scrollToCurrentDay
}
from
'
ee/roadmap/utils/epic_utils
'
;
import
{
getTimeframeForMonthsView
}
from
'
ee/roadmap/utils/roadmap_utils
'
;
import
{
getTimeframeForMonthsView
}
from
'
ee/roadmap/utils/roadmap_utils
'
;
import
{
import
{
mockTimeframeInitialDate
,
mockTimeframeInitialDate
,
...
@@ -16,6 +17,11 @@ import {
...
@@ -16,6 +17,11 @@ import {
}
from
'
ee_jest/roadmap/mock_data
'
;
}
from
'
ee_jest/roadmap/mock_data
'
;
import
{
createMockDirective
,
getBinding
}
from
'
helpers/vue_mock_directive
'
;
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
initializeStore
=
(
mockTimeframeMonths
)
=>
{
const
store
=
createStore
();
const
store
=
createStore
();
store
.
dispatch
(
'
setInitialData
'
,
{
store
.
dispatch
(
'
setInitialData
'
,
{
...
@@ -104,6 +110,14 @@ describe('MilestonesListSectionComponent', () => {
...
@@ -104,6 +110,14 @@ describe('MilestonesListSectionComponent', () => {
it
(
'
sets value of `roadmapShellEl` with root component element
'
,
()
=>
{
it
(
'
sets value of `roadmapShellEl` with root component element
'
,
()
=>
{
expect
(
wrapper
.
vm
.
roadmapShellEl
instanceof
HTMLElement
).
toBe
(
true
);
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
'
,
()
=>
{
describe
(
'
handleEpicsListScroll
'
,
()
=>
{
...
...
ee/spec/frontend/roadmap/components/roadmap_filters_spec.js
View file @
48318f75
import
{
GlSegmentedControl
,
GlDropdown
,
GlDropdownItem
}
from
'
@gitlab/ui
'
;
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
Vuex
from
'
vuex
'
;
import
RoadmapFilters
from
'
ee/roadmap/components/roadmap_filters.vue
'
;
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
createStore
from
'
ee/roadmap/store
'
;
import
{
getTimeframeForMonthsView
}
from
'
ee/roadmap/utils/roadmap_utils
'
;
import
{
getTimeframeForMonthsView
}
from
'
ee/roadmap/utils/roadmap_utils
'
;
import
{
import
{
...
@@ -18,6 +18,7 @@ import {
...
@@ -18,6 +18,7 @@ import {
}
from
'
ee_jest/roadmap/mock_data
'
;
}
from
'
ee_jest/roadmap/mock_data
'
;
import
{
TEST_HOST
}
from
'
helpers/test_constants
'
;
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
{
visitUrl
,
mergeUrlParams
,
updateHistory
}
from
'
~/lib/utils/url_utility
'
;
import
FilteredSearchBar
from
'
~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
'
;
import
FilteredSearchBar
from
'
~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
'
;
...
@@ -37,6 +38,8 @@ const createComponent = ({
...
@@ -37,6 +38,8 @@ const createComponent = ({
groupMilestonesPath
=
'
/groups/gitlab-org/-/milestones.json
'
,
groupMilestonesPath
=
'
/groups/gitlab-org/-/milestones.json
'
,
timeframe
=
getTimeframeForMonthsView
(
mockTimeframeInitialDate
),
timeframe
=
getTimeframeForMonthsView
(
mockTimeframeInitialDate
),
filterParams
=
{},
filterParams
=
{},
roadmapDaterangeFilter
=
false
,
timeframeRangeType
=
DATE_RANGES
.
CURRENT_QUARTER
,
}
=
{})
=>
{
}
=
{})
=>
{
const
localVue
=
createLocalVue
();
const
localVue
=
createLocalVue
();
const
store
=
createStore
();
const
store
=
createStore
();
...
@@ -51,13 +54,19 @@ const createComponent = ({
...
@@ -51,13 +54,19 @@ const createComponent = ({
timeframe
,
timeframe
,
});
});
return
shallowMount
(
RoadmapFilters
,
{
return
shallowMount
Extended
(
RoadmapFilters
,
{
localVue
,
localVue
,
store
,
store
,
provide
:
{
provide
:
{
groupFullPath
,
groupFullPath
,
groupMilestonesPath
,
groupMilestonesPath
,
listEpicsPath
,
listEpicsPath
,
glFeatures
:
{
roadmapDaterangeFilter
,
},
},
props
:
{
timeframeRangeType
,
},
},
});
});
};
};
...
@@ -106,13 +115,17 @@ describe('RoadmapFilters', () => {
...
@@ -106,13 +115,17 @@ describe('RoadmapFilters', () => {
await
wrapper
.
vm
.
$nextTick
();
await
wrapper
.
vm
.
$nextTick
();
expect
(
global
.
window
.
location
.
href
).
toBe
(
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
'
,
()
=>
{
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
(()
=>
{
beforeEach
(()
=>
{
updateHistory
({
url
:
TEST_HOST
,
title
:
document
.
title
,
replace
:
true
});
updateHistory
({
url
:
TEST_HOST
,
title
:
document
.
title
,
replace
:
true
});
});
});
...
@@ -122,11 +135,7 @@ describe('RoadmapFilters', () => {
...
@@ -122,11 +135,7 @@ describe('RoadmapFilters', () => {
expect
(
layoutSwitches
.
exists
()).
toBe
(
true
);
expect
(
layoutSwitches
.
exists
()).
toBe
(
true
);
expect
(
layoutSwitches
.
props
(
'
checked
'
)).
toBe
(
PRESET_TYPES
.
MONTHS
);
expect
(
layoutSwitches
.
props
(
'
checked
'
)).
toBe
(
PRESET_TYPES
.
MONTHS
);
expect
(
layoutSwitches
.
props
(
'
options
'
)).
toEqual
([
expect
(
layoutSwitches
.
props
(
'
options
'
)).
toEqual
([
quarters
,
months
,
weeks
]);
{
text
:
'
Quarters
'
,
value
:
PRESET_TYPES
.
QUARTERS
},
{
text
:
'
Months
'
,
value
:
PRESET_TYPES
.
MONTHS
},
{
text
:
'
Weeks
'
,
value
:
PRESET_TYPES
.
WEEKS
},
]);
});
});
it
(
'
switching layout using roadmap layout switching buttons causes page to reload with selected layout
'
,
()
=>
{
it
(
'
switching layout using roadmap layout switching buttons causes page to reload with selected layout
'
,
()
=>
{
...
@@ -302,5 +311,63 @@ describe('RoadmapFilters', () => {
...
@@ -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
);
}
},
);
});
});
});
});
});
ee/spec/frontend/roadmap/utils/epic_utils_spec.js
View file @
48318f75
...
@@ -44,3 +44,20 @@ describe('generateKey', () => {
...
@@ -44,3 +44,20 @@ describe('generateKey', () => {
expect
(
epicUtils
.
generateKey
(
obj
)).
toBe
(
'
child-epic-3
'
);
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
'
,
});
});
});
ee/spec/frontend/roadmap/utils/roadmap_utils_spec.js
View file @
48318f75
import
{
PRESET_TYPES
}
from
'
ee/roadmap/constants
'
;
import
{
PRESET_TYPES
,
DATE_RANGES
}
from
'
ee/roadmap/constants
'
;
import
{
import
{
getTimeframeForQuartersView
,
getTimeframeForQuartersView
,
extendTimeframeForQuartersView
,
extendTimeframeForQuartersView
,
...
@@ -8,7 +8,10 @@ import {
...
@@ -8,7 +8,10 @@ import {
extendTimeframeForWeeksView
,
extendTimeframeForWeeksView
,
extendTimeframeForAvailableWidth
,
extendTimeframeForAvailableWidth
,
getEpicsTimeframeRange
,
getEpicsTimeframeRange
,
getWeeksForDates
,
getTimeframeForRangeType
,
sortEpics
,
sortEpics
,
getPresetTypeForTimeframeRangeType
,
}
from
'
ee/roadmap/utils/roadmap_utils
'
;
}
from
'
ee/roadmap/utils/roadmap_utils
'
;
import
{
import
{
...
@@ -25,6 +28,7 @@ import {
...
@@ -25,6 +28,7 @@ import {
const
mockTimeframeQuarters
=
getTimeframeForQuartersView
(
mockTimeframeInitialDate
);
const
mockTimeframeQuarters
=
getTimeframeForQuartersView
(
mockTimeframeInitialDate
);
const
mockTimeframeMonths
=
getTimeframeForMonthsView
(
mockTimeframeInitialDate
);
const
mockTimeframeMonths
=
getTimeframeForMonthsView
(
mockTimeframeInitialDate
);
const
mockTimeframeWeeks
=
getTimeframeForWeeksView
(
mockTimeframeInitialDate
);
const
mockTimeframeWeeks
=
getTimeframeForWeeksView
(
mockTimeframeInitialDate
);
const
getDateString
=
(
date
)
=>
date
.
toISOString
().
split
(
'
T
'
)[
0
];
describe
(
'
getTimeframeForQuartersView
'
,
()
=>
{
describe
(
'
getTimeframeForQuartersView
'
,
()
=>
{
let
timeframe
;
let
timeframe
;
...
@@ -295,6 +299,113 @@ describe('extendTimeframeForAvailableWidth', () => {
...
@@ -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
'
,
()
=>
{
describe
(
'
getEpicsTimeframeRange
'
,
()
=>
{
it
(
'
returns object containing startDate and dueDate based on provided timeframe for Quarters
'
,
()
=>
{
it
(
'
returns object containing startDate and dueDate based on provided timeframe for Quarters
'
,
()
=>
{
const
timeframeQuarters
=
getTimeframeForQuartersView
(
new
Date
(
2018
,
0
,
1
));
const
timeframeQuarters
=
getTimeframeForQuartersView
(
new
Date
(
2018
,
0
,
1
));
...
@@ -437,3 +548,17 @@ describe('sortEpics', () => {
...
@@ -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
);
},
);
});
locale/gitlab.pot
View file @
48318f75
...
@@ -15908,6 +15908,12 @@ msgstr ""
...
@@ -15908,6 +15908,12 @@ msgstr ""
msgid "GroupRoadmap|The roadmap shows the progress of your epics along a timeline"
msgid "GroupRoadmap|The roadmap shows the progress of your epics along a timeline"
msgstr ""
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."
msgid "GroupRoadmap|To make your epics appear in the roadmap, add start or due dates to them."
msgstr ""
msgstr ""
...
@@ -15920,6 +15926,9 @@ msgstr ""
...
@@ -15920,6 +15926,9 @@ msgstr ""
msgid "GroupRoadmap|To widen your search, change or remove filters; from %{startDate} to %{endDate}."
msgid "GroupRoadmap|To widen your search, change or remove filters; from %{startDate} to %{endDate}."
msgstr ""
msgstr ""
msgid "GroupRoadmap|Within 3 years"
msgstr ""
msgid "GroupSAML|%{strongOpen}Warning%{strongClose} - Enabling %{linkStart}SSO enforcement%{linkEnd} can reduce security risks."
msgid "GroupSAML|%{strongOpen}Warning%{strongClose} - Enabling %{linkStart}SSO enforcement%{linkEnd} can reduce security risks."
msgstr ""
msgstr ""
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment