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
956fd910
Commit
956fd910
authored
May 17, 2021
by
Brandon Labuschagne
Committed by
Douglas Barbosa Alexandre
May 17, 2021
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Resolve "[DevOps Adoption] Introduce Dev, Sec, Ops tabs"
parent
8f84338e
Changes
17
Show whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
322 additions
and
177 deletions
+322
-177
ee/app/assets/javascripts/analytics/devops_report/devops_adoption/components/devops_adoption_app.vue
...report/devops_adoption/components/devops_adoption_app.vue
+103
-21
ee/app/assets/javascripts/analytics/devops_report/devops_adoption/constants.js
...ipts/analytics/devops_report/devops_adoption/constants.js
+24
-7
ee/app/assets/javascripts/analytics/devops_report/devops_adoption/index.js
...ascripts/analytics/devops_report/devops_adoption/index.js
+10
-1
ee/app/assets/javascripts/analytics/devops_report/tabs.js
ee/app/assets/javascripts/analytics/devops_report/tabs.js
+0
-31
ee/app/assets/javascripts/pages/admin/dev_ops_report/show/index.js
...sets/javascripts/pages/admin/dev_ops_report/show/index.js
+0
-2
ee/app/assets/stylesheets/page_bundles/dev_ops_report.scss
ee/app/assets/stylesheets/page_bundles/dev_ops_report.scss
+4
-0
ee/app/controllers/ee/admin/dev_ops_report_controller.rb
ee/app/controllers/ee/admin/dev_ops_report_controller.rb
+2
-2
ee/app/views/admin/dev_ops_report/_devops_tabs.html.haml
ee/app/views/admin/dev_ops_report/_devops_tabs.html.haml
+9
-8
ee/app/views/admin/dev_ops_report/_tab.html.haml
ee/app/views/admin/dev_ops_report/_tab.html.haml
+0
-3
ee/spec/controllers/admin/dev_ops_report_controller_spec.rb
ee/spec/controllers/admin/dev_ops_report_controller_spec.rb
+12
-4
ee/spec/features/admin/admin_dev_ops_report_spec.rb
ee/spec/features/admin/admin_dev_ops_report_spec.rb
+51
-25
ee/spec/frontend/analytics/devops_report/devops_adoption/components/devops_adoption_app_spec.js
...rt/devops_adoption/components/devops_adoption_app_spec.js
+94
-2
ee/spec/frontend/analytics/devops_report/devops_adoption/mock_data.js
...tend/analytics/devops_report/devops_adoption/mock_data.js
+0
-20
ee/spec/frontend/analytics/devops_report/tabs_spec.js
ee/spec/frontend/analytics/devops_report/tabs_spec.js
+0
-37
ee/spec/views/admin/dev_ops_report/show.html.haml_spec.rb
ee/spec/views/admin/dev_ops_report/show.html.haml_spec.rb
+2
-2
locale/gitlab.pot
locale/gitlab.pot
+9
-6
spec/controllers/admin/dev_ops_report_controller_spec.rb
spec/controllers/admin/dev_ops_report_controller_spec.rb
+2
-6
No files found.
ee/app/assets/javascripts/analytics/devops_report/devops_adoption/components/devops_adoption_app.vue
View file @
956fd910
<
script
>
<
script
>
import
{
GlAlert
}
from
'
@gitlab/ui
'
;
import
{
GlAlert
,
GlTabs
,
GlTab
}
from
'
@gitlab/ui
'
;
import
*
as
Sentry
from
'
@sentry/browser
'
;
import
*
as
Sentry
from
'
@sentry/browser
'
;
import
dateformat
from
'
dateformat
'
;
import
dateformat
from
'
dateformat
'
;
import
DevopsScore
from
'
~/analytics/devops_report/components/devops_score.vue
'
;
import
API
from
'
~/api
'
;
import
{
mergeUrlParams
,
updateHistory
,
getParameterValues
}
from
'
~/lib/utils/url_utility
'
;
import
{
import
{
DEVOPS_ADOPTION_STRINGS
,
DEVOPS_ADOPTION_STRINGS
,
DEVOPS_ADOPTION_ERROR_KEYS
,
DEVOPS_ADOPTION_ERROR_KEYS
,
...
@@ -11,6 +14,8 @@ import {
...
@@ -11,6 +14,8 @@ import {
DEFAULT_POLLING_INTERVAL
,
DEFAULT_POLLING_INTERVAL
,
DEVOPS_ADOPTION_GROUP_LEVEL_LABEL
,
DEVOPS_ADOPTION_GROUP_LEVEL_LABEL
,
DEVOPS_ADOPTION_TABLE_CONFIGURATION
,
DEVOPS_ADOPTION_TABLE_CONFIGURATION
,
TRACK_ADOPTION_TAB_CLICK_EVENT
,
TRACK_DEVOPS_SCORE_TAB_CLICK_EVENT
,
}
from
'
../constants
'
;
}
from
'
../constants
'
;
import
bulkFindOrCreateDevopsAdoptionSegmentsMutation
from
'
../graphql/mutations/bulk_find_or_create_devops_adoption_segments.mutation.graphql
'
;
import
bulkFindOrCreateDevopsAdoptionSegmentsMutation
from
'
../graphql/mutations/bulk_find_or_create_devops_adoption_segments.mutation.graphql
'
;
import
devopsAdoptionSegmentsQuery
from
'
../graphql/queries/devops_adoption_segments.query.graphql
'
;
import
devopsAdoptionSegmentsQuery
from
'
../graphql/queries/devops_adoption_segments.query.graphql
'
;
...
@@ -26,6 +31,9 @@ export default {
...
@@ -26,6 +31,9 @@ export default {
GlAlert
,
GlAlert
,
DevopsAdoptionSection
,
DevopsAdoptionSection
,
DevopsAdoptionSegmentModal
,
DevopsAdoptionSegmentModal
,
DevopsScore
,
GlTabs
,
GlTab
,
},
},
inject
:
{
inject
:
{
isGroup
:
{
isGroup
:
{
...
@@ -34,11 +42,22 @@ export default {
...
@@ -34,11 +42,22 @@ export default {
groupGid
:
{
groupGid
:
{
default
:
null
,
default
:
null
,
},
},
devopsScoreMetrics
:
{
default
:
null
,
},
devopsReportDocsPath
:
{
default
:
''
,
},
noDataImagePath
:
{
default
:
''
,
},
},
},
i18n
:
{
i18n
:
{
groupLevelLabel
:
DEVOPS_ADOPTION_GROUP_LEVEL_LABEL
,
groupLevelLabel
:
DEVOPS_ADOPTION_GROUP_LEVEL_LABEL
,
...
DEVOPS_ADOPTION_STRINGS
.
app
,
...
DEVOPS_ADOPTION_STRINGS
.
app
,
},
},
trackDevopsTabClickEvent
:
TRACK_ADOPTION_TAB_CLICK_EVENT
,
trackDevopsScoreTabClickEvent
:
TRACK_DEVOPS_SCORE_TAB_CLICK_EVENT
,
maxSegments
:
MAX_SEGMENTS
,
maxSegments
:
MAX_SEGMENTS
,
devopsAdoptionTableConfiguration
:
DEVOPS_ADOPTION_TABLE_CONFIGURATION
,
devopsAdoptionTableConfiguration
:
DEVOPS_ADOPTION_TABLE_CONFIGURATION
,
data
()
{
data
()
{
...
@@ -63,6 +82,9 @@ export default {
...
@@ -63,6 +82,9 @@ export default {
directDescendantsOnly
:
false
,
directDescendantsOnly
:
false
,
}
}
:
{},
:
{},
adoptionTabClicked
:
false
,
devopsScoreTabClicked
:
false
,
selectedTab
:
0
,
};
};
},
},
apollo
:
{
apollo
:
{
...
@@ -88,6 +110,9 @@ export default {
...
@@ -88,6 +110,9 @@ export default {
},
},
},
},
computed
:
{
computed
:
{
isAdmin
()
{
return
!
this
.
isGroup
;
},
hasGroupData
()
{
hasGroupData
()
{
return
Boolean
(
this
.
groups
?.
nodes
?.
length
);
return
Boolean
(
this
.
groups
?.
nodes
?.
length
);
},
},
...
@@ -121,9 +146,15 @@ export default {
...
@@ -121,9 +146,15 @@ export default {
canRenderModal
()
{
canRenderModal
()
{
return
this
.
hasGroupData
&&
!
this
.
isLoading
;
return
this
.
hasGroupData
&&
!
this
.
isLoading
;
},
},
tabIndexValues
()
{
const
tabs
=
this
.
$options
.
devopsAdoptionTableConfiguration
.
map
((
item
)
=>
item
.
tab
);
return
this
.
isGroup
?
tabs
:
[...
tabs
,
'
devops-score
'
];
},
},
},
created
()
{
created
()
{
this
.
fetchGroups
();
this
.
fetchGroups
();
this
.
selectTab
();
},
},
beforeDestroy
()
{
beforeDestroy
()
{
clearInterval
(
this
.
pollingTableData
);
clearInterval
(
this
.
pollingTableData
);
...
@@ -219,10 +250,53 @@ export default {
...
@@ -219,10 +250,53 @@ export default {
deleteSegmentsFromCache
(
cache
,
ids
,
this
.
segmentsQueryVariables
);
deleteSegmentsFromCache
(
cache
,
ids
,
this
.
segmentsQueryVariables
);
},
},
selectTab
()
{
const
[
value
]
=
getParameterValues
(
'
tab
'
);
if
(
value
)
{
this
.
selectedTab
=
this
.
tabIndexValues
.
indexOf
(
value
);
}
},
onTabChange
(
index
)
{
if
(
index
>
0
)
{
if
(
index
!==
this
.
selectedTab
)
{
const
path
=
mergeUrlParams
(
{
tab
:
this
.
tabIndexValues
[
index
]
},
window
.
location
.
pathname
,
);
updateHistory
({
url
:
path
,
title
:
window
.
title
});
}
}
else
{
updateHistory
({
url
:
window
.
location
.
pathname
,
title
:
window
.
title
});
}
this
.
selectedTab
=
index
;
},
trackDevopsScoreTabClick
()
{
if
(
!
this
.
devopsScoreTabClicked
)
{
API
.
trackRedisHllUserEvent
(
this
.
$options
.
trackDevopsScoreTabClickEvent
);
this
.
devopsScoreTabClicked
=
true
;
}
},
trackDevopsTabClick
()
{
if
(
!
this
.
adoptionTabClicked
)
{
API
.
trackRedisHllUserEvent
(
this
.
$options
.
trackDevopsTabClickEvent
);
this
.
adoptionTabClicked
=
true
;
}
},
},
},
};
};
</
script
>
</
script
>
<
template
>
<
template
>
<div>
<gl-tabs
:value=
"selectedTab"
@
input=
"onTabChange"
>
<gl-tab
v-for=
"tab in $options.devopsAdoptionTableConfiguration"
:key=
"tab.title"
data-testid=
"devops-adoption-tab"
@
click=
"trackDevopsTabClick"
>
<template
#title
>
{{
tab
.
title
}}
</
template
>
<div
v-if=
"hasLoadingError"
>
<div
v-if=
"hasLoadingError"
>
<
template
v-for=
"(error, key) in errors"
>
<
template
v-for=
"(error, key) in errors"
>
<gl-alert
v-if=
"error"
:key=
"key"
variant=
"danger"
:dismissible=
"false"
class=
"gl-mt-3"
>
<gl-alert
v-if=
"error"
:key=
"key"
variant=
"danger"
:dismissible=
"false"
class=
"gl-mt-3"
>
...
@@ -231,27 +305,35 @@ export default {
...
@@ -231,27 +305,35 @@ export default {
</
template
>
</
template
>
</div>
</div>
<div
v-else
>
<devops-adoption-segment-modal
v-if=
"canRenderModal"
ref=
"addRemoveModal"
:groups=
"groups.nodes"
:enabled-groups=
"devopsAdoptionSegments.nodes"
@
segmentsAdded=
"addSegmentsToCache"
@
segmentsRemoved=
"deleteSegmentsFromCache"
@
trackModalOpenState=
"trackModalOpenState"
/>
<devops-adoption-section
<devops-adoption-section
v-else
:is-loading=
"isLoading"
:is-loading=
"isLoading"
:has-segments-data=
"hasSegmentsData"
:has-segments-data=
"hasSegmentsData"
:timestamp=
"timestamp"
:timestamp=
"timestamp"
:has-group-data=
"hasGroupData"
:has-group-data=
"hasGroupData"
:segment-limit-reached=
"segmentLimitReached"
:segment-limit-reached=
"segmentLimitReached"
:edit-groups-button-label=
"editGroupsButtonLabel"
:edit-groups-button-label=
"editGroupsButtonLabel"
:cols=
"$options.devopsAdoptionTableConfiguration[0]
.cols"
:cols=
"tab
.cols"
:segments=
"devopsAdoptionSegments"
:segments=
"devopsAdoptionSegments"
@
segmentsRemoved=
"deleteSegmentsFromCache"
@
segmentsRemoved=
"deleteSegmentsFromCache"
@
openAddRemoveModal=
"openAddRemoveModal"
@
openAddRemoveModal=
"openAddRemoveModal"
/>
/>
</gl-tab>
<gl-tab
v-if=
"isAdmin"
data-testid=
"devops-score-tab"
@
click=
"trackDevopsScoreTabClick"
>
<
template
#title
>
{{
s__
(
'
DevopsReport|DevOps Score
'
)
}}
</
template
>
<devops-score
/>
</gl-tab>
</gl-tabs>
<devops-adoption-segment-modal
v-if=
"canRenderModal"
ref=
"addRemoveModal"
:groups=
"groups.nodes"
:enabled-groups=
"devopsAdoptionSegments.nodes"
@
segmentsAdded=
"addSegmentsToCache"
@
segmentsRemoved=
"deleteSegmentsFromCache"
@
trackModalOpenState=
"trackModalOpenState"
/>
</div>
</div>
</template>
</template>
ee/app/assets/javascripts/analytics/devops_report/devops_adoption/constants.js
View file @
956fd910
...
@@ -102,7 +102,8 @@ export const DEVOPS_ADOPTION_GROUP_COL_LABEL = __('Group');
...
@@ -102,7 +102,8 @@ export const DEVOPS_ADOPTION_GROUP_COL_LABEL = __('Group');
export
const
DEVOPS_ADOPTION_TABLE_CONFIGURATION
=
[
export
const
DEVOPS_ADOPTION_TABLE_CONFIGURATION
=
[
{
{
title
:
s__
(
'
DevopsAdoption|Adoption
'
),
title
:
s__
(
'
DevopsAdoption|Dev
'
),
tab
:
'
dev
'
,
cols
:
[
cols
:
[
{
{
key
:
'
issueOpened
'
,
key
:
'
issueOpened
'
,
...
@@ -122,6 +123,24 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
...
@@ -122,6 +123,24 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
tooltip
:
s__
(
'
DevopsAdoption|At least 1 approval on an MR
'
),
tooltip
:
s__
(
'
DevopsAdoption|At least 1 approval on an MR
'
),
testId
:
'
approvalsCol
'
,
testId
:
'
approvalsCol
'
,
},
},
],
},
{
title
:
s__
(
'
DevopsAdoption|Sec
'
),
tab
:
'
sec
'
,
cols
:
[
{
key
:
'
securityScanSucceeded
'
,
label
:
s__
(
'
DevopsAdoption|Scanning
'
),
tooltip
:
s__
(
'
DevopsAdoption|At least 1 security scan of any type run in pipeline
'
),
testId
:
'
scanningCol
'
,
},
],
},
{
title
:
s__
(
'
DevopsAdoption|Ops
'
),
tab
:
'
ops
'
,
cols
:
[
{
{
key
:
'
runnerConfigured
'
,
key
:
'
runnerConfigured
'
,
label
:
s__
(
'
DevopsAdoption|Runners
'
),
label
:
s__
(
'
DevopsAdoption|Runners
'
),
...
@@ -140,12 +159,10 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
...
@@ -140,12 +159,10 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
tooltip
:
s__
(
'
DevopsAdoption|At least 1 deploy
'
),
tooltip
:
s__
(
'
DevopsAdoption|At least 1 deploy
'
),
testId
:
'
deploysCol
'
,
testId
:
'
deploysCol
'
,
},
},
{
key
:
'
securityScanSucceeded
'
,
label
:
s__
(
'
DevopsAdoption|Scanning
'
),
tooltip
:
s__
(
'
DevopsAdoption|At least 1 security scan of any type run in pipeline
'
),
testId
:
'
scanningCol
'
,
},
],
],
},
},
];
];
export
const
TRACK_ADOPTION_TAB_CLICK_EVENT
=
'
i_analytics_dev_ops_adoption
'
;
export
const
TRACK_DEVOPS_SCORE_TAB_CLICK_EVENT
=
'
i_analytics_dev_ops_score
'
;
ee/app/assets/javascripts/analytics/devops_report/devops_adoption/index.js
View file @
956fd910
...
@@ -8,7 +8,13 @@ export default () => {
...
@@ -8,7 +8,13 @@ export default () => {
if
(
!
el
)
return
false
;
if
(
!
el
)
return
false
;
const
{
emptyStateSvgPath
,
groupId
}
=
el
.
dataset
;
const
{
emptyStateSvgPath
,
groupId
,
devopsScoreMetrics
,
devopsReportDocsPath
,
noDataImagePath
,
}
=
el
.
dataset
;
const
isGroup
=
Boolean
(
groupId
);
const
isGroup
=
Boolean
(
groupId
);
...
@@ -19,6 +25,9 @@ export default () => {
...
@@ -19,6 +25,9 @@ export default () => {
emptyStateSvgPath
,
emptyStateSvgPath
,
isGroup
,
isGroup
,
groupGid
:
isGroup
?
convertToGraphQLId
(
TYPE_GROUP
,
groupId
)
:
null
,
groupGid
:
isGroup
?
convertToGraphQLId
(
TYPE_GROUP
,
groupId
)
:
null
,
devopsScoreMetrics
:
isGroup
?
null
:
JSON
.
parse
(
devopsScoreMetrics
),
devopsReportDocsPath
,
noDataImagePath
,
},
},
render
(
h
)
{
render
(
h
)
{
return
h
(
DevopsAdoptionApp
);
return
h
(
DevopsAdoptionApp
);
...
...
ee/app/assets/javascripts/analytics/devops_report/tabs.js
deleted
100644 → 0
View file @
8f84338e
import
Api
from
'
~/api
'
;
import
{
historyPushState
}
from
'
~/lib/utils/common_utils
'
;
import
{
mergeUrlParams
}
from
'
~/lib/utils/url_utility
'
;
const
DEVOPS_ADOPTION_PANE
=
'
devops-adoption
'
;
const
DEVOPS_ADOPTION_PANE_TAB_CLICK_EVENT
=
'
i_analytics_dev_ops_adoption
'
;
const
tabClickHandler
=
(
e
)
=>
{
const
{
hash
}
=
e
.
currentTarget
;
let
tab
=
null
;
if
(
hash
===
`#
${
DEVOPS_ADOPTION_PANE
}
`
)
{
tab
=
DEVOPS_ADOPTION_PANE
;
Api
.
trackRedisHllUserEvent
(
DEVOPS_ADOPTION_PANE_TAB_CLICK_EVENT
);
}
const
newUrl
=
mergeUrlParams
({
tab
},
window
.
location
.
href
);
historyPushState
(
newUrl
);
};
const
initTabs
=
()
=>
{
const
tabLinks
=
document
.
querySelectorAll
(
'
.js-devops-tab-item a
'
);
if
(
tabLinks
.
length
)
{
tabLinks
.
forEach
((
tabLink
)
=>
{
tabLink
.
addEventListener
(
'
click
'
,
(
e
)
=>
tabClickHandler
(
e
));
});
}
};
export
default
initTabs
;
ee/app/assets/javascripts/pages/admin/dev_ops_report/show/index.js
View file @
956fd910
import
initDevopAdoption
from
'
ee/analytics/devops_report/devops_adoption
'
;
import
initDevopAdoption
from
'
ee/analytics/devops_report/devops_adoption
'
;
import
initTabs
from
'
ee/analytics/devops_report/tabs
'
;
initTabs
();
initDevopAdoption
();
initDevopAdoption
();
ee/app/assets/stylesheets/page_bundles/dev_ops_report.scss
View file @
956fd910
...
@@ -13,6 +13,10 @@
...
@@ -13,6 +13,10 @@
}
}
}
}
.actions-cell
{
width
:
$gl-spacing-scale-6
;
}
@include
media-breakpoint-down
(
sm
)
{
@include
media-breakpoint-down
(
sm
)
{
.actions-cell
{
.actions-cell
{
div
{
div
{
...
...
ee/app/controllers/ee/admin/dev_ops_report_controller.rb
View file @
956fd910
...
@@ -5,11 +5,11 @@ module EE
...
@@ -5,11 +5,11 @@ module EE
module
DevOpsReportController
module
DevOpsReportController
extend
ActiveSupport
::
Concern
extend
ActiveSupport
::
Concern
prepended
do
prepended
do
track_redis_hll_event
:show
,
name:
'i_analytics_dev_ops_adoption'
,
if:
->
{
params
[
:tab
]
==
'devops-adoption
'
}
track_redis_hll_event
:show
,
name:
'i_analytics_dev_ops_adoption'
,
if:
->
{
params
[
:tab
]
!=
'devops-score
'
}
end
end
def
should_track_devops_score?
def
should_track_devops_score?
params
[
:tab
]
!=
'devops-adoption
'
params
[
:tab
]
==
'devops-score
'
end
end
def
show_adoption?
def
show_adoption?
...
...
ee/app/views/admin/dev_ops_report/_devops_tabs.html.haml
View file @
956fd910
-
usage_ping_enabled
=
Gitlab
::
CurrentSettings
.
usage_ping_enabled
%h2
%h2
=
_
(
'DevOps Report'
)
=
_
(
'DevOps Report'
)
%ul
.nav-links.nav-tabs.nav.js-devops-tabs
{
role:
'tablist'
}
=
render
'tab'
,
active:
params
[
:tab
]
!=
'devops-adoption'
,
title:
s_
(
'DevopsReport|DevOps Score'
),
target:
'#devops-score'
=
render
'tab'
,
active:
params
[
:tab
]
==
'devops-adoption'
,
title:
s_
(
'DevopsReport|Adoption'
),
target:
'#devops-adoption'
.tab-content
-
if
usage_ping_enabled
&&
show_callout?
(
'dev_ops_report_intro_callout_dismissed'
)
.tab-pane
{
id:
'devops-score'
,
class:
(
'active'
if
params
[
:tab
]
!=
'devops-adoption'
)
}
=
render_ce
'admin/dev_ops_report/callout'
=
render_ce
'admin/dev_ops_report/report'
.tab-pane
{
id:
'devops-adoption'
,
class:
(
'active'
if
params
[
:tab
]
==
'devops-adoption'
)
}
-
if
!
usage_ping_enabled
.js-devops-adoption
{
data:
{
empty_state_svg_path:
image_path
(
'illustrations/monitoring/getting_started.svg'
)
}
}
#js-devops-usage-ping-disabled
{
data:
{
is_admin:
current_user
&
.
admin
.
to_s
,
empty_state_svg_path:
image_path
(
'illustrations/convdev/convdev_no_index.svg'
),
enable_usage_ping_link:
metrics_and_profiling_admin_application_settings_path
(
anchor:
'js-usage-settings'
),
docs_link:
help_page_path
(
'development/usage_ping/index.md'
)
}
}
-
else
.js-devops-adoption
{
data:
{
empty_state_svg_path:
image_path
(
'illustrations/monitoring/getting_started.svg'
),
devops_score_metrics:
devops_score_metrics
(
@metric
).
to_json
,
devops_report_docs_path:
help_page_path
(
'user/admin_area/analytics/dev_ops_report'
),
no_data_image_path:
image_path
(
'dev_ops_report_no_data.svg'
)
}
}
ee/app/views/admin/dev_ops_report/_tab.html.haml
deleted
100644 → 0
View file @
8f84338e
%li
.nav-item.js-devops-tab-item
{
role:
'presentation'
}
%a
.nav-link
{
href:
target
,
class:
active_when
(
active
),
data:
{
toggle:
'tab'
},
role:
'tab'
}
=
title
ee/spec/controllers/admin/dev_ops_report_controller_spec.rb
View file @
956fd910
...
@@ -38,13 +38,21 @@ RSpec.describe Admin::DevOpsReportController do
...
@@ -38,13 +38,21 @@ RSpec.describe Admin::DevOpsReportController do
sign_in
(
user
)
sign_in
(
user
)
end
end
context
'when devops_adoption tab selected'
do
shared_examples
'tracks usage event'
do
|
event
,
tab
|
it
'tracks devops_adoption usage event'
do
it
"tracks
#{
event
}
usage event for
#{
tab
}
"
do
expect
(
Gitlab
::
UsageDataCounters
::
HLLRedisCounter
)
expect
(
Gitlab
::
UsageDataCounters
::
HLLRedisCounter
)
.
to
receive
(
:track_event
).
with
(
'i_analytics_dev_ops_adoption'
,
values:
kind_of
(
String
))
.
to
receive
(
:track_event
).
with
(
event
,
values:
kind_of
(
String
))
get
:show
,
params:
{
tab:
'devops-adoption'
},
format: :html
get
:show
,
params:
{
tab:
tab
},
format: :html
end
end
end
end
context
'when browsing to specific tabs'
do
[
''
,
'dev'
,
'sec'
,
'ops'
].
each
do
|
tab
|
it_behaves_like
'tracks usage event'
,
'i_analytics_dev_ops_adoption'
,
tab
end
it_behaves_like
'tracks usage event'
,
'i_analytics_dev_ops_score'
,
'devops-score'
end
end
end
end
end
ee/spec/features/admin/admin_dev_ops_report_spec.rb
View file @
956fd910
...
@@ -3,9 +3,23 @@
...
@@ -3,9 +3,23 @@
require
'spec_helper'
require
'spec_helper'
RSpec
.
describe
'DevOps Report page'
,
:js
do
RSpec
.
describe
'DevOps Report page'
,
:js
do
tabs_selector
=
'.
js-devops-tabs
'
tabs_selector
=
'.
gl-tabs-nav
'
tab_item_selector
=
'.
js-devops-tab
-item'
tab_item_selector
=
'.
nav
-item'
active_tab_selector
=
'.nav-link.active'
active_tab_selector
=
'.nav-link.active'
tabs
=
[
{
value:
'sec'
,
text:
'Sec'
},
{
value:
'ops'
,
text:
'Ops'
},
{
value:
'devops-score'
,
text:
'DevOps Score'
}
]
before
do
before
do
admin
=
create
(
:admin
)
admin
=
create
(
:admin
)
...
@@ -40,56 +54,68 @@ RSpec.describe 'DevOps Report page', :js do
...
@@ -40,56 +54,68 @@ RSpec.describe 'DevOps Report page', :js do
visit
admin_dev_ops_report_path
visit
admin_dev_ops_report_path
within
tabs_selector
do
within
tabs_selector
do
expect
(
page
.
all
(
:css
,
tab_item_selector
).
length
).
to
be
(
2
)
expect
(
page
.
all
(
:css
,
tab_item_selector
).
length
).
to
be
(
4
)
expect
(
page
).
to
have_text
'Dev
Ops Score Adoption
'
expect
(
page
).
to
have_text
'Dev
Sec Ops DevOps Score
'
end
end
end
end
it
'defaults to the Dev
Ops Score
tab'
do
it
'defaults to the Dev tab'
do
visit
admin_dev_ops_report_path
visit
admin_dev_ops_report_path
within
tabs_selector
do
within
tabs_selector
do
expect
(
page
).
to
have_selector
active_tab_selector
,
text:
'Dev
Ops Score
'
expect
(
page
).
to
have_selector
active_tab_selector
,
text:
'Dev'
end
end
end
end
it
'displays the Adoption tab content when selected'
do
shared_examples
'displays tab content'
do
|
tab
|
it
"displays the
#{
tab
}
tab content when selected"
do
visit
admin_dev_ops_report_path
visit
admin_dev_ops_report_path
click_link
'Adoption'
click_link
tab
within
tabs_selector
do
within
tabs_selector
do
expect
(
page
).
to
have_selector
active_tab_selector
,
text:
'Adoption'
expect
(
page
).
to
have_selector
active_tab_selector
,
text:
tab
end
end
end
end
end
it
'does not add the tab param when the DevOps Score tab is selected'
do
tabs
.
each
do
|
tab
|
it_behaves_like
'displays tab content'
,
tab
[
:text
]
end
it
'does not add the tab param when the Dev tab is selected'
do
visit
admin_dev_ops_report_path
visit
admin_dev_ops_report_path
click_link
'Dev
Ops Score
'
click_link
'Dev'
expect
(
page
).
to
have_current_path
(
admin_dev_ops_report_path
)
expect
(
page
).
to
have_current_path
(
admin_dev_ops_report_path
)
end
end
it
'adds the ?tab=devops-adoption param when the Adoption tab is selected'
do
shared_examples
'appends the tab param to the url'
do
|
tab
,
text
|
it
"adds the ?tab=
#{
tab
}
param when the
#{
text
}
tab is selected"
do
visit
admin_dev_ops_report_path
visit
admin_dev_ops_report_path
click_link
'Adoption'
click_link
text
expect
(
page
).
to
have_current_path
(
admin_dev_ops_report_path
(
tab:
tab
))
end
end
expect
(
page
).
to
have_current_path
(
admin_dev_ops_report_path
(
tab:
'devops-adoption'
))
tabs
.
each
do
|
tab
|
it_behaves_like
'appends the tab param to the url'
,
tab
[
:value
],
tab
[
:text
]
end
end
it
'shows the devops
adoption
tab when the tab param is set'
do
it
'shows the devops
core
tab when the tab param is set'
do
visit
admin_dev_ops_report_path
(
tab:
'devops-
adoption
'
)
visit
admin_dev_ops_report_path
(
tab:
'devops-
score
'
)
within
tabs_selector
do
within
tabs_selector
do
expect
(
page
).
to
have_selector
active_tab_selector
,
text:
'
Adoption
'
expect
(
page
).
to
have_selector
active_tab_selector
,
text:
'
DevOps Score
'
end
end
end
end
context
'the devops score tab'
do
context
'the devops score tab'
do
it
'has dismissable intro callout'
do
it
'has dismissable intro callout'
do
visit
admin_dev_ops_report_path
visit
admin_dev_ops_report_path
(
tab:
'devops-score'
)
expect
(
page
).
to
have_content
'Introducing Your DevOps Report'
expect
(
page
).
to
have_content
'Introducing Your DevOps Report'
...
@@ -104,13 +130,13 @@ RSpec.describe 'DevOps Report page', :js do
...
@@ -104,13 +130,13 @@ RSpec.describe 'DevOps Report page', :js do
end
end
it
'shows empty state'
do
it
'shows empty state'
do
visit
admin_dev_ops_report_path
visit
admin_dev_ops_report_path
(
tab:
'devops-score'
)
expect
(
page
).
to
have_selector
(
".js-empty-state"
)
expect
(
page
).
to
have_selector
(
".js-empty-state"
)
end
end
it
'hides the intro callout'
do
it
'hides the intro callout'
do
visit
admin_dev_ops_report_path
visit
admin_dev_ops_report_path
(
tab:
'devops-score'
)
expect
(
page
).
not_to
have_content
'Introducing Your DevOps Report'
expect
(
page
).
not_to
have_content
'Introducing Your DevOps Report'
end
end
...
@@ -120,7 +146,7 @@ RSpec.describe 'DevOps Report page', :js do
...
@@ -120,7 +146,7 @@ RSpec.describe 'DevOps Report page', :js do
it
'shows empty state'
do
it
'shows empty state'
do
stub_application_setting
(
usage_ping_enabled:
true
)
stub_application_setting
(
usage_ping_enabled:
true
)
visit
admin_dev_ops_report_path
visit
admin_dev_ops_report_path
(
tab:
'devops-score'
)
expect
(
page
).
to
have_content
(
'Data is still calculating'
)
expect
(
page
).
to
have_content
(
'Data is still calculating'
)
end
end
...
@@ -131,7 +157,7 @@ RSpec.describe 'DevOps Report page', :js do
...
@@ -131,7 +157,7 @@ RSpec.describe 'DevOps Report page', :js do
stub_application_setting
(
usage_ping_enabled:
true
)
stub_application_setting
(
usage_ping_enabled:
true
)
create
(
:dev_ops_report_metric
)
create
(
:dev_ops_report_metric
)
visit
admin_dev_ops_report_path
visit
admin_dev_ops_report_path
(
tab:
'devops-score'
)
expect
(
page
).
to
have_selector
(
'[data-testid="devops-score-app"]'
)
expect
(
page
).
to
have_selector
(
'[data-testid="devops-score-app"]'
)
end
end
...
...
ee/spec/frontend/analytics/devops_report/devops_adoption/components/devops_adoption_app_spec.js
View file @
956fd910
import
{
GlAlert
}
from
'
@gitlab/ui
'
;
import
{
GlAlert
}
from
'
@gitlab/ui
'
;
import
*
as
Sentry
from
'
@sentry/browser
'
;
import
*
as
Sentry
from
'
@sentry/browser
'
;
import
{
createLocalVue
,
shallowMount
}
from
'
@vue/test-utils
'
;
import
{
createLocalVue
}
from
'
@vue/test-utils
'
;
import
Vue
from
'
vue
'
;
import
Vue
from
'
vue
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
DevopsAdoptionApp
from
'
ee/analytics/devops_report/devops_adoption/components/devops_adoption_app.vue
'
;
import
DevopsAdoptionApp
from
'
ee/analytics/devops_report/devops_adoption/components/devops_adoption_app.vue
'
;
...
@@ -9,13 +9,17 @@ import DevopsAdoptionSegmentModal from 'ee/analytics/devops_report/devops_adopti
...
@@ -9,13 +9,17 @@ import DevopsAdoptionSegmentModal from 'ee/analytics/devops_report/devops_adopti
import
{
import
{
DEVOPS_ADOPTION_STRINGS
,
DEVOPS_ADOPTION_STRINGS
,
DEFAULT_POLLING_INTERVAL
,
DEFAULT_POLLING_INTERVAL
,
DEVOPS_ADOPTION_TABLE_CONFIGURATION
,
}
from
'
ee/analytics/devops_report/devops_adoption/constants
'
;
}
from
'
ee/analytics/devops_report/devops_adoption/constants
'
;
import
bulkFindOrCreateDevopsAdoptionSegmentsMutation
from
'
ee/analytics/devops_report/devops_adoption/graphql/mutations/bulk_find_or_create_devops_adoption_segments.mutation.graphql
'
;
import
bulkFindOrCreateDevopsAdoptionSegmentsMutation
from
'
ee/analytics/devops_report/devops_adoption/graphql/mutations/bulk_find_or_create_devops_adoption_segments.mutation.graphql
'
;
import
devopsAdoptionSegments
from
'
ee/analytics/devops_report/devops_adoption/graphql/queries/devops_adoption_segments.query.graphql
'
;
import
devopsAdoptionSegments
from
'
ee/analytics/devops_report/devops_adoption/graphql/queries/devops_adoption_segments.query.graphql
'
;
import
getGroupsQuery
from
'
ee/analytics/devops_report/devops_adoption/graphql/queries/get_groups.query.graphql
'
;
import
getGroupsQuery
from
'
ee/analytics/devops_report/devops_adoption/graphql/queries/get_groups.query.graphql
'
;
import
{
addSegmentsToCache
}
from
'
ee/analytics/devops_report/devops_adoption/utils/cache_updates
'
;
import
{
addSegmentsToCache
}
from
'
ee/analytics/devops_report/devops_adoption/utils/cache_updates
'
;
import
createMockApollo
from
'
helpers/mock_apollo_helper
'
;
import
createMockApollo
from
'
helpers/mock_apollo_helper
'
;
import
{
shallowMountExtended
}
from
'
helpers/vue_test_utils_helper
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
DevopsScore
from
'
~/analytics/devops_report/components/devops_score.vue
'
;
import
API
from
'
~/api
'
;
import
{
import
{
groupNodes
,
groupNodes
,
nextGroupNode
,
nextGroupNode
,
...
@@ -84,7 +88,7 @@ describe('DevopsAdoptionApp', () => {
...
@@ -84,7 +88,7 @@ describe('DevopsAdoptionApp', () => {
function
createComponent
(
options
=
{})
{
function
createComponent
(
options
=
{})
{
const
{
mockApollo
,
data
=
{},
provide
=
{}
}
=
options
;
const
{
mockApollo
,
data
=
{},
provide
=
{}
}
=
options
;
return
shallowMount
(
DevopsAdoptionApp
,
{
return
shallowMount
Extended
(
DevopsAdoptionApp
,
{
localVue
,
localVue
,
apolloProvider
:
mockApollo
,
apolloProvider
:
mockApollo
,
provide
,
provide
,
...
@@ -94,6 +98,8 @@ describe('DevopsAdoptionApp', () => {
...
@@ -94,6 +98,8 @@ describe('DevopsAdoptionApp', () => {
});
});
}
}
const
findDevopsScoreTab
=
()
=>
wrapper
.
findByTestId
(
'
devops-score-tab
'
);
afterEach
(()
=>
{
afterEach
(()
=>
{
wrapper
.
destroy
();
wrapper
.
destroy
();
wrapper
=
null
;
wrapper
=
null
;
...
@@ -444,4 +450,90 @@ describe('DevopsAdoptionApp', () => {
...
@@ -444,4 +450,90 @@ describe('DevopsAdoptionApp', () => {
});
});
});
});
});
});
describe
(
'
tabs
'
,
()
=>
{
const
eventTrackingBehaviour
=
(
testId
,
event
)
=>
{
describe
(
'
event tracking
'
,
()
=>
{
it
(
`tracks the
${
event
}
event when clicked`
,
()
=>
{
jest
.
spyOn
(
API
,
'
trackRedisHllUserEvent
'
);
expect
(
API
.
trackRedisHllUserEvent
).
not
.
toHaveBeenCalled
();
wrapper
.
findByTestId
(
testId
).
vm
.
$emit
(
'
click
'
);
expect
(
API
.
trackRedisHllUserEvent
).
toHaveBeenCalledWith
(
event
);
});
it
(
'
only tracks the event once
'
,
()
=>
{
jest
.
spyOn
(
API
,
'
trackRedisHllUserEvent
'
);
expect
(
API
.
trackRedisHllUserEvent
).
not
.
toHaveBeenCalled
();
const
{
vm
}
=
wrapper
.
findByTestId
(
testId
);
vm
.
$emit
(
'
click
'
);
vm
.
$emit
(
'
click
'
);
expect
(
API
.
trackRedisHllUserEvent
).
toHaveBeenCalledTimes
(
1
);
});
});
};
const
defaultDevopsAdoptionTabBehavior
=
()
=>
{
describe
(
'
devops adoption tabs
'
,
()
=>
{
it
(
'
displays the configured number of tabs
'
,
()
=>
{
expect
(
wrapper
.
findAllByTestId
(
'
devops-adoption-tab
'
)).
toHaveLength
(
DEVOPS_ADOPTION_TABLE_CONFIGURATION
.
length
,
);
});
it
(
'
displays the devops section component with the tab
'
,
()
=>
{
expect
(
wrapper
.
findByTestId
(
'
devops-adoption-tab
'
).
find
(
DevopsAdoptionSection
).
exists
(),
).
toBe
(
true
);
});
eventTrackingBehaviour
(
'
devops-adoption-tab
'
,
'
i_analytics_dev_ops_adoption
'
);
});
};
describe
(
'
admin level
'
,
()
=>
{
beforeEach
(()
=>
{
const
mockApollo
=
createMockApolloProvider
();
wrapper
=
createComponent
({
mockApollo
});
});
defaultDevopsAdoptionTabBehavior
();
describe
(
'
devops score tab
'
,
()
=>
{
it
(
'
displays the devops score tab
'
,
()
=>
{
expect
(
findDevopsScoreTab
().
exists
()).
toBe
(
true
);
});
it
(
'
displays the devops score component
'
,
()
=>
{
expect
(
findDevopsScoreTab
().
find
(
DevopsScore
).
exists
()).
toBe
(
true
);
});
eventTrackingBehaviour
(
'
devops-score-tab
'
,
'
i_analytics_dev_ops_score
'
);
});
});
describe
(
'
group level
'
,
()
=>
{
beforeEach
(()
=>
{
const
mockApollo
=
createMockApolloProvider
();
wrapper
=
createComponent
({
mockApollo
,
provide
:
{
isGroup
:
true
,
groupGid
:
devopsAdoptionSegmentsData
.
nodes
[
0
].
namespace
.
id
,
},
});
});
defaultDevopsAdoptionTabBehavior
();
it
(
'
does not display the devops score tab
'
,
()
=>
{
expect
(
findDevopsScoreTab
().
exists
()).
toBe
(
false
);
});
});
});
});
});
ee/spec/frontend/analytics/devops_report/devops_adoption/mock_data.js
View file @
956fd910
...
@@ -101,26 +101,6 @@ export const devopsAdoptionTableHeaders = [
...
@@ -101,26 +101,6 @@ export const devopsAdoptionTableHeaders = [
},
},
{
{
index
:
4
,
index
:
4
,
label
:
'
Runners
'
,
tooltip
:
'
Runner configured for project/group
'
,
},
{
index
:
5
,
label
:
'
Pipelines
'
,
tooltip
:
'
At least 1 pipeline successfully run
'
,
},
{
index
:
6
,
label
:
'
Deploys
'
,
tooltip
:
'
At least 1 deploy
'
,
},
{
index
:
7
,
label
:
'
Scanning
'
,
tooltip
:
'
At least 1 security scan of any type run in pipeline
'
,
},
{
index
:
8
,
label
:
''
,
label
:
''
,
tooltip
:
null
,
tooltip
:
null
,
},
},
...
...
ee/spec/frontend/analytics/devops_report/tabs_spec.js
deleted
100644 → 0
View file @
8f84338e
import
initTabs
from
'
ee/analytics/devops_report/tabs
'
;
import
Api
from
'
~/api
'
;
jest
.
mock
(
'
~/api.js
'
);
jest
.
mock
(
'
~/lib/utils/common_utils
'
);
describe
(
'
tabs
'
,
()
=>
{
beforeEach
(()
=>
{
setFixtures
(
`
<div>
<div class="js-devops-tab-item">
<a href="#devops-score" data-testid='score-tab'>Score</a>
</div>
<div class="js-devops-tab-item">
<a href="#devops-adoption" data-testid='devops-adoption-tab'>Adoption</a>
</div>
</div`
);
initTabs
();
});
afterEach
(()
=>
{});
describe
(
'
tracking
'
,
()
=>
{
it
(
'
tracks event when adoption tab is clicked
'
,
()
=>
{
document
.
querySelector
(
'
[data-testid="devops-adoption-tab"]
'
).
click
();
expect
(
Api
.
trackRedisHllUserEvent
).
toHaveBeenCalledWith
(
'
i_analytics_dev_ops_adoption
'
);
});
it
(
'
does not track an event when score tab is clicked
'
,
()
=>
{
document
.
querySelector
(
'
[data-testid="score-tab"]
'
).
click
();
expect
(
Api
.
trackRedisHllUserEvent
).
not
.
toHaveBeenCalled
();
});
});
});
ee/spec/views/admin/dev_ops_report/show.html.haml_spec.rb
View file @
956fd910
...
@@ -15,7 +15,7 @@ RSpec.describe 'admin/dev_ops_report/show.html.haml' do
...
@@ -15,7 +15,7 @@ RSpec.describe 'admin/dev_ops_report/show.html.haml' do
it
'disables the feature'
do
it
'disables the feature'
do
render
render
expect
(
rendered
).
not_to
have_selector
(
'
#
devops-adoption'
)
expect
(
rendered
).
not_to
have_selector
(
'
.js-
devops-adoption'
)
end
end
end
end
...
@@ -25,7 +25,7 @@ RSpec.describe 'admin/dev_ops_report/show.html.haml' do
...
@@ -25,7 +25,7 @@ RSpec.describe 'admin/dev_ops_report/show.html.haml' do
render
render
expect
(
rendered
).
to
have_selector
(
'
#
devops-adoption'
)
expect
(
rendered
).
to
have_selector
(
'
.js-
devops-adoption'
)
end
end
end
end
end
end
locale/gitlab.pot
View file @
956fd910
...
@@ -11339,9 +11339,6 @@ msgstr ""
...
@@ -11339,9 +11339,6 @@ msgstr ""
msgid "DevopsAdoption|Adopted"
msgid "DevopsAdoption|Adopted"
msgstr ""
msgstr ""
msgid "DevopsAdoption|Adoption"
msgstr ""
msgid "DevopsAdoption|An error occurred while removing the group. Please try again."
msgid "DevopsAdoption|An error occurred while removing the group. Please try again."
msgstr ""
msgstr ""
...
@@ -11378,6 +11375,9 @@ msgstr ""
...
@@ -11378,6 +11375,9 @@ msgstr ""
msgid "DevopsAdoption|Deploys"
msgid "DevopsAdoption|Deploys"
msgstr ""
msgstr ""
msgid "DevopsAdoption|Dev"
msgstr ""
msgid "DevopsAdoption|DevOps adoption tracks the use of key features across your favorite groups. Add a group to the table to begin."
msgid "DevopsAdoption|DevOps adoption tracks the use of key features across your favorite groups. Add a group to the table to begin."
msgstr ""
msgstr ""
...
@@ -11405,6 +11405,9 @@ msgstr ""
...
@@ -11405,6 +11405,9 @@ msgstr ""
msgid "DevopsAdoption|Not adopted"
msgid "DevopsAdoption|Not adopted"
msgstr ""
msgstr ""
msgid "DevopsAdoption|Ops"
msgstr ""
msgid "DevopsAdoption|Pipelines"
msgid "DevopsAdoption|Pipelines"
msgstr ""
msgstr ""
...
@@ -11426,6 +11429,9 @@ msgstr ""
...
@@ -11426,6 +11429,9 @@ msgstr ""
msgid "DevopsAdoption|Scanning"
msgid "DevopsAdoption|Scanning"
msgstr ""
msgstr ""
msgid "DevopsAdoption|Sec"
msgstr ""
msgid "DevopsAdoption|There was an error enabling the current group. Please refresh the page."
msgid "DevopsAdoption|There was an error enabling the current group. Please refresh the page."
msgstr ""
msgstr ""
...
@@ -11438,9 +11444,6 @@ msgstr ""
...
@@ -11438,9 +11444,6 @@ msgstr ""
msgid "DevopsAdoption|You cannot remove the group you are currently in."
msgid "DevopsAdoption|You cannot remove the group you are currently in."
msgstr ""
msgstr ""
msgid "DevopsReport|Adoption"
msgstr ""
msgid "DevopsReport|DevOps Score"
msgid "DevopsReport|DevOps Score"
msgstr ""
msgstr ""
...
...
spec/controllers/admin/dev_ops_report_controller_spec.rb
View file @
956fd910
...
@@ -9,12 +9,6 @@ RSpec.describe Admin::DevOpsReportController do
...
@@ -9,12 +9,6 @@ RSpec.describe Admin::DevOpsReportController do
end
end
end
end
describe
'should_track_devops_score?'
do
it
'is always true'
do
expect
(
controller
.
should_track_devops_score?
).
to
be_truthy
end
end
describe
'GET #show'
do
describe
'GET #show'
do
context
'as admin'
do
context
'as admin'
do
let
(
:user
)
{
create
(
:admin
)
}
let
(
:user
)
{
create
(
:admin
)
}
...
@@ -31,6 +25,8 @@ RSpec.describe Admin::DevOpsReportController do
...
@@ -31,6 +25,8 @@ RSpec.describe Admin::DevOpsReportController do
it_behaves_like
'tracking unique visits'
,
:show
do
it_behaves_like
'tracking unique visits'
,
:show
do
let
(
:target_id
)
{
'i_analytics_dev_ops_score'
}
let
(
:target_id
)
{
'i_analytics_dev_ops_score'
}
let
(
:request_params
)
{
{
tab:
'devops-score'
}
}
end
end
end
end
end
end
...
...
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