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
afe6e9e1
Commit
afe6e9e1
authored
May 19, 2021
by
David O'Regan
Committed by
Natalia Tepluhina
May 19, 2021
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Allow issue type change for incidents
parent
57422633
Changes
34
Hide whitespace changes
Inline
Side-by-side
Showing
34 changed files
with
820 additions
and
206 deletions
+820
-206
app/assets/javascripts/issue_show/components/app.vue
app/assets/javascripts/issue_show/components/app.vue
+36
-4
app/assets/javascripts/issue_show/components/edit_actions.vue
...assets/javascripts/issue_show/components/edit_actions.vue
+95
-43
app/assets/javascripts/issue_show/components/fields/description_template.vue
...pts/issue_show/components/fields/description_template.vue
+2
-2
app/assets/javascripts/issue_show/components/fields/title.vue
...assets/javascripts/issue_show/components/fields/title.vue
+1
-1
app/assets/javascripts/issue_show/components/fields/type.vue
app/assets/javascripts/issue_show/components/fields/type.vue
+79
-0
app/assets/javascripts/issue_show/components/form.vue
app/assets/javascripts/issue_show/components/form.vue
+27
-21
app/assets/javascripts/issue_show/constants.js
app/assets/javascripts/issue_show/constants.js
+11
-0
app/assets/javascripts/issue_show/graphql.js
app/assets/javascripts/issue_show/graphql.js
+9
-0
app/assets/javascripts/issue_show/incident.js
app/assets/javascripts/issue_show/incident.js
+15
-7
app/assets/javascripts/issue_show/issue.js
app/assets/javascripts/issue_show/issue.js
+23
-8
app/assets/javascripts/issue_show/queries/get_issue_state.query.graphql
...ascripts/issue_show/queries/get_issue_state.query.graphql
+3
-0
app/assets/javascripts/issue_show/queries/update_issue_state.mutation.graphql
...ts/issue_show/queries/update_issue_state.mutation.graphql
+3
-0
app/assets/javascripts/sidebar/graphql.js
app/assets/javascripts/sidebar/graphql.js
+18
-7
app/services/issues/base_service.rb
app/services/issues/base_service.rb
+32
-0
app/services/issues/create_service.rb
app/services/issues/create_service.rb
+0
-17
app/services/issues/update_service.rb
app/services/issues/update_service.rb
+10
-0
changelogs/unreleased/268370-change-issue-type-fe-delete-button.yml
.../unreleased/268370-change-issue-type-fe-delete-button.yml
+5
-0
doc/user/project/issues/img/issue_type_change_v13_12.png
doc/user/project/issues/img/issue_type_change_v13_12.png
+0
-0
doc/user/project/issues/managing_issues.md
doc/user/project/issues/managing_issues.md
+11
-0
ee/spec/features/epics/delete_epic_spec.rb
ee/spec/features/epics/delete_epic_spec.rb
+3
-3
locale/gitlab.pot
locale/gitlab.pot
+3
-0
spec/features/incidents/incident_details_spec.rb
spec/features/incidents/incident_details_spec.rb
+38
-0
spec/features/issues/issue_detail_spec.rb
spec/features/issues/issue_detail_spec.rb
+58
-5
spec/frontend/issue_show/components/app_spec.js
spec/frontend/issue_show/components/app_spec.js
+23
-18
spec/frontend/issue_show/components/description_spec.js
spec/frontend/issue_show/components/description_spec.js
+1
-1
spec/frontend/issue_show/components/edit_actions_spec.js
spec/frontend/issue_show/components/edit_actions_spec.js
+115
-65
spec/frontend/issue_show/components/fields/type_spec.js
spec/frontend/issue_show/components/fields/type_spec.js
+84
-0
spec/frontend/issue_show/components/form_spec.js
spec/frontend/issue_show/components/form_spec.js
+17
-0
spec/frontend/issue_show/components/incidents/incident_tabs_spec.js
...end/issue_show/components/incidents/incident_tabs_spec.js
+1
-1
spec/frontend/issue_show/issue_spec.js
spec/frontend/issue_show/issue_spec.js
+1
-1
spec/frontend/issue_show/mock_data/apollo_mock.js
spec/frontend/issue_show/mock_data/apollo_mock.js
+9
-0
spec/frontend/issue_show/mock_data/mock_data.js
spec/frontend/issue_show/mock_data/mock_data.js
+1
-0
spec/services/issues/create_service_spec.rb
spec/services/issues/create_service_spec.rb
+2
-2
spec/services/issues/update_service_spec.rb
spec/services/issues/update_service_spec.rb
+84
-0
No files found.
app/assets/javascripts/issue_show/components/app.vue
View file @
afe6e9e1
...
...
@@ -5,8 +5,16 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import
Poll
from
'
~/lib/utils/poll
'
;
import
{
visitUrl
}
from
'
~/lib/utils/url_utility
'
;
import
{
__
,
s__
,
sprintf
}
from
'
~/locale
'
;
import
{
IssuableStatus
,
IssuableStatusText
,
IssuableType
}
from
'
../constants
'
;
import
{
IssuableStatus
,
IssuableStatusText
,
IssuableType
,
IssueTypePath
,
IncidentTypePath
,
IncidentType
,
}
from
'
../constants
'
;
import
eventHub
from
'
../event_hub
'
;
import
getIssueStateQuery
from
'
../queries/get_issue_state.query.graphql
'
;
import
Service
from
'
../services/index
'
;
import
Store
from
'
../stores
'
;
import
descriptionComponent
from
'
./description.vue
'
;
...
...
@@ -195,8 +203,14 @@ export default {
showForm
:
false
,
templatesRequested
:
false
,
isStickyHeaderShowing
:
false
,
issueState
:
{},
};
},
apollo
:
{
issueState
:
{
query
:
getIssueStateQuery
,
},
},
computed
:
{
issuableTemplates
()
{
return
this
.
store
.
formState
.
issuableTemplates
;
...
...
@@ -288,7 +302,7 @@ export default {
methods
:
{
handleBeforeUnloadEvent
(
e
)
{
const
event
=
e
;
if
(
this
.
showForm
&&
this
.
issueChanged
)
{
if
(
this
.
showForm
&&
this
.
issueChanged
&&
!
this
.
issueState
.
isDirty
)
{
event
.
returnValue
=
__
(
'
Are you sure you want to lose your issue information?
'
);
}
return
undefined
;
...
...
@@ -346,14 +360,32 @@ export default {
},
updateIssuable
()
{
const
{
store
:
{
formState
},
issueState
,
}
=
this
;
const
issuablePayload
=
issueState
.
isDirty
?
{
...
formState
,
issue_type
:
issueState
.
issueType
}
:
formState
;
this
.
clearFlash
();
return
this
.
service
.
updateIssuable
(
this
.
store
.
formState
)
.
updateIssuable
(
issuablePayload
)
.
then
((
res
)
=>
res
.
data
)
.
then
((
data
)
=>
{
if
(
!
window
.
location
.
pathname
.
includes
(
data
.
web_url
))
{
if
(
!
window
.
location
.
pathname
.
includes
(
data
.
web_url
)
&&
issueState
.
issueType
!==
IncidentType
)
{
visitUrl
(
data
.
web_url
);
}
if
(
issueState
.
isDirty
)
{
const
URI
=
issueState
.
issueType
===
IncidentType
?
data
.
web_url
.
replace
(
IssueTypePath
,
IncidentTypePath
)
:
data
.
web_url
;
visitUrl
(
URI
);
}
})
.
then
(
this
.
updateStoreState
)
.
then
(()
=>
{
...
...
app/assets/javascripts/issue_show/components/edit_actions.vue
View file @
afe6e9e1
<
script
>
import
{
GlButton
}
from
'
@gitlab/ui
'
;
import
{
GlButton
,
GlModal
,
GlModalDirective
}
from
'
@gitlab/ui
'
;
import
{
uniqueId
}
from
'
lodash
'
;
import
{
__
,
sprintf
}
from
'
~/locale
'
;
import
eventHub
from
'
../event_hub
'
;
import
updateMixin
from
'
../mixins/update
'
;
import
getIssueStateQuery
from
'
../queries/get_issue_state.query.graphql
'
;
const
issuableTypes
=
{
issue
:
__
(
'
Issue
'
),
epic
:
__
(
'
Epic
'
),
incident
:
__
(
'
Incident
'
),
};
export
default
{
components
:
{
GlButton
,
GlModal
,
},
directives
:
{
GlModal
:
GlModalDirective
,
},
mixins
:
[
updateMixin
],
props
:
{
...
...
@@ -36,19 +43,56 @@ export default {
data
()
{
return
{
deleteLoading
:
false
,
skipApollo
:
false
,
issueState
:
{},
modalId
:
uniqueId
(
'
delete-issuable-modal-
'
),
};
},
apollo
:
{
issueState
:
{
query
:
getIssueStateQuery
,
skip
()
{
return
this
.
skipApollo
;
},
result
()
{
this
.
skipApollo
=
true
;
},
},
},
computed
:
{
deleteIssuableButtonText
()
{
return
sprintf
(
__
(
'
Delete %{issuableType}
'
),
{
issuableType
:
this
.
typeToShow
.
toLowerCase
(),
});
},
deleteIssuableModalText
()
{
return
this
.
issuableType
===
'
epic
'
?
__
(
'
Delete this epic and all descendants?
'
)
:
sprintf
(
__
(
'
%{issuableType} will be removed! Are you sure?
'
),
{
issuableType
:
this
.
typeToShow
,
});
},
isSubmitEnabled
()
{
return
this
.
formState
.
title
.
trim
()
!==
''
;
},
modalActionProps
()
{
return
{
primary
:
{
text
:
this
.
deleteIssuableButtonText
,
attributes
:
[{
variant
:
'
danger
'
},
{
loading
:
this
.
deleteLoading
}],
},
cancel
:
{
text
:
__
(
'
Cancel
'
),
},
};
},
shouldShowDeleteButton
()
{
return
this
.
canDestroy
&&
this
.
showDeleteButton
;
},
deleteIssuableButtonText
()
{
return
sprintf
(
__
(
'
Delete %{issuableType}
'
),
{
issuableType
:
issuableTypes
[
this
.
issuableType
].
toLowerCase
(),
})
;
typeToShow
()
{
const
{
issueState
,
issuableType
}
=
this
;
const
type
=
issueState
.
issueType
??
issuableType
;
return
issuableTypes
[
type
]
;
},
},
methods
:
{
...
...
@@ -56,49 +100,57 @@ export default {
eventHub
.
$emit
(
'
close.form
'
);
},
deleteIssuable
()
{
const
confirmMessage
=
this
.
issuableType
===
'
epic
'
?
__
(
'
Delete this epic and all descendants?
'
)
:
sprintf
(
__
(
'
%{issuableType} will be removed! Are you sure?
'
),
{
issuableType
:
issuableTypes
[
this
.
issuableType
],
});
// eslint-disable-next-line no-alert
if
(
window
.
confirm
(
confirmMessage
))
{
this
.
deleteLoading
=
true
;
eventHub
.
$emit
(
'
delete.issuable
'
,
{
destroy_confirm
:
true
});
}
this
.
deleteLoading
=
true
;
eventHub
.
$emit
(
'
delete.issuable
'
,
{
destroy_confirm
:
true
});
},
},
};
</
script
>
<
template
>
<div
class=
"gl-mt-3 gl-mb-3 clearfix"
>
<gl-button
:loading=
"formState.updateLoading"
:disabled=
"formState.updateLoading || !isSubmitEnabled"
category=
"primary"
variant=
"confirm"
class=
"float-left qa-save-button gl-mr-3"
type=
"submit"
@
click.prevent=
"updateIssuable"
>
{{
__
(
'
Save changes
'
)
}}
</gl-button>
<gl-button
@
click=
"closeForm"
>
{{
__
(
'
Cancel
'
)
}}
</gl-button>
<gl-button
v-if=
"shouldShowDeleteButton"
:loading=
"deleteLoading"
:disabled=
"deleteLoading"
category=
"secondary"
variant=
"danger"
class=
"float-right qa-delete-button"
@
click=
"deleteIssuable"
>
{{
deleteIssuableButtonText
}}
</gl-button>
<div
class=
"gl-mt-3 gl-mb-3 gl-display-flex gl-justify-content-space-between"
>
<div>
<gl-button
:loading=
"formState.updateLoading"
:disabled=
"formState.updateLoading || !isSubmitEnabled"
category=
"primary"
variant=
"confirm"
class=
"qa-save-button gl-mr-3"
data-testid=
"issuable-save-button"
type=
"submit"
@
click.prevent=
"updateIssuable"
>
{{
__
(
'
Save changes
'
)
}}
</gl-button>
<gl-button
data-testid=
"issuable-cancel-button"
@
click=
"closeForm"
>
{{
__
(
'
Cancel
'
)
}}
</gl-button>
</div>
<div
v-if=
"shouldShowDeleteButton"
>
<gl-button
v-gl-modal=
"modalId"
:loading=
"deleteLoading"
:disabled=
"deleteLoading"
category=
"secondary"
variant=
"danger"
class=
"qa-delete-button"
data-testid=
"issuable-delete-button"
>
{{
deleteIssuableButtonText
}}
</gl-button>
<gl-modal
ref=
"removeModal"
:modal-id=
"modalId"
size=
"sm"
:action-primary=
"modalActionProps.primary"
:action-cancel=
"modalActionProps.cancel"
@
primary=
"deleteIssuable"
>
<template
#modal-title
>
{{
deleteIssuableButtonText
}}
</
template
>
<div>
<p
class=
"gl-mb-1"
>
{{ deleteIssuableModalText }}
</p>
</div>
</gl-modal>
</div>
</div>
</template>
app/assets/javascripts/issue_show/components/fields/description_template.vue
View file @
afe6e9e1
...
...
@@ -54,14 +54,14 @@ export default {
<
template
>
<!-- eslint-disable @gitlab/vue-no-data-toggle -->
<div
class=
"dropdown js-issuable-selector-wrap"
data-issuable-type=
"issues"
>
<div
class=
"dropdown js-issuable-selector-wrap
gl-mb-0
"
data-issuable-type=
"issues"
>
<button
ref=
"toggle"
:data-namespace-path=
"projectNamespace"
:data-project-path=
"projectPath"
:data-project-id=
"projectId"
:data-data=
"issuableTemplatesJson"
class=
"dropdown-menu-toggle js-issuable-selector"
class=
"dropdown-menu-toggle js-issuable-selector
gl-button
"
type=
"button"
data-field-name=
"issuable_template"
data-selected=
"null"
...
...
app/assets/javascripts/issue_show/components/fields/title.vue
View file @
afe6e9e1
...
...
@@ -20,7 +20,7 @@ export default {
id=
"issuable-title"
ref=
"input"
v-model=
"formState.title"
class=
"form-control qa-title-input"
class=
"form-control qa-title-input
gl-border-gray-200
"
dir=
"auto"
type=
"text"
:placeholder=
"__('Title')"
...
...
app/assets/javascripts/issue_show/components/fields/type.vue
0 → 100644
View file @
afe6e9e1
<
script
>
import
{
GlFormGroup
,
GlDropdown
,
GlDropdownItem
}
from
'
@gitlab/ui
'
;
import
{
capitalize
}
from
'
lodash
'
;
import
{
__
}
from
'
~/locale
'
;
import
{
IssuableTypes
}
from
'
../../constants
'
;
import
getIssueStateQuery
from
'
../../queries/get_issue_state.query.graphql
'
;
import
updateIssueStateMutation
from
'
../../queries/update_issue_state.mutation.graphql
'
;
export
const
i18n
=
{
label
:
__
(
'
Issue Type
'
),
};
export
default
{
i18n
,
IssuableTypes
,
components
:
{
GlFormGroup
,
GlDropdown
,
GlDropdownItem
,
},
data
()
{
return
{
issueState
:
{},
};
},
apollo
:
{
issueState
:
{
query
:
getIssueStateQuery
,
},
},
computed
:
{
dropdownText
()
{
const
{
issueState
:
{
issueType
},
}
=
this
;
return
capitalize
(
issueType
);
},
},
methods
:
{
updateIssueType
(
issueType
)
{
this
.
$apollo
.
mutate
({
mutation
:
updateIssueStateMutation
,
variables
:
{
issueType
,
isDirty
:
true
,
},
});
},
},
};
</
script
>
<
template
>
<gl-form-group
:label=
"$options.i18n.label"
label-class=
"sr-only"
label-for=
"issuable-type"
class=
"mb-2 mb-md-0"
>
<gl-dropdown
id=
"issuable-type"
:aria-labelledby=
"$options.i18n.label"
:text=
"dropdownText"
:header-text=
"$options.i18n.label"
class=
"gl-w-full"
toggle-class=
"dropdown-menu-toggle"
>
<gl-dropdown-item
v-for=
"type in $options.IssuableTypes"
:key=
"type.value"
:is-checked=
"issueState.issueType === type.value"
is-check-item
@
click=
"updateIssueType(type.value)"
>
{{
type
.
text
}}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
</
template
>
app/assets/javascripts/issue_show/components/form.vue
View file @
afe6e9e1
...
...
@@ -2,21 +2,24 @@
import
{
GlAlert
}
from
'
@gitlab/ui
'
;
import
$
from
'
jquery
'
;
import
Autosave
from
'
~/autosave
'
;
import
{
IssuableType
}
from
'
~/issue_show/constants
'
;
import
eventHub
from
'
../event_hub
'
;
import
editActions
from
'
./edit_actions.vue
'
;
import
descriptionField
from
'
./fields/description.vue
'
;
import
descriptionTemplate
from
'
./fields/description_template.vue
'
;
import
titleField
from
'
./fields/title.vue
'
;
import
lockedWarning
from
'
./locked_warning.vue
'
;
import
EditActions
from
'
./edit_actions.vue
'
;
import
DescriptionField
from
'
./fields/description.vue
'
;
import
DescriptionTemplateField
from
'
./fields/description_template.vue
'
;
import
IssuableTitleField
from
'
./fields/title.vue
'
;
import
IssuableTypeField
from
'
./fields/type.vue
'
;
import
LockedWarning
from
'
./locked_warning.vue
'
;
export
default
{
components
:
{
lockedWarning
,
titleField
,
descriptionField
,
descriptionTemplate
,
editActions
,
DescriptionField
,
DescriptionTemplateField
,
EditActions
,
GlAlert
,
IssuableTitleField
,
IssuableTypeField
,
LockedWarning
,
},
props
:
{
canDestroy
:
{
...
...
@@ -89,6 +92,9 @@ export default {
showLockedWarning
()
{
return
this
.
formState
.
lockedWarningVisible
&&
!
this
.
formState
.
updateLoading
;
},
isIssueType
()
{
return
this
.
issuableType
===
IssuableType
.
Issue
;
},
},
created
()
{
eventHub
.
$on
(
'
delete.issuable
'
,
this
.
resetAutosave
);
...
...
@@ -162,7 +168,7 @@ export default {
</
script
>
<
template
>
<form>
<form
data-testid=
"issuable-form"
>
<locked-warning
v-if=
"showLockedWarning"
/>
<gl-alert
v-if=
"showOutdatedDescriptionWarning"
...
...
@@ -179,9 +185,17 @@ export default {
)
}}
</gl-alert
>
<div
class=
"row gl-mb-3"
>
<div
class=
"col-12"
>
<issuable-title-field
ref=
"title"
:form-state=
"formState"
/>
</div>
</div>
<div
class=
"row"
>
<div
v-if=
"hasIssuableTemplates"
class=
"col-sm-4 col-lg-3"
>
<description-template
<div
v-if=
"isIssueType"
class=
"col-12 col-md-4 pr-md-0"
>
<issuable-type-field
ref=
"issue-type"
/>
</div>
<div
v-if=
"hasIssuableTemplates"
class=
"col-12 col-md-4 pl-md-2"
>
<description-template-field
:form-state=
"formState"
:issuable-templates=
"issuableTemplates"
:project-path=
"projectPath"
...
...
@@ -189,14 +203,6 @@ export default {
:project-namespace=
"projectNamespace"
/>
</div>
<div
:class=
"
{
'col-sm-8 col-lg-9': hasIssuableTemplates,
'col-12': !hasIssuableTemplates,
}"
>
<title-field
ref=
"title"
:form-state=
"formState"
:issuable-templates=
"issuableTemplates"
/>
</div>
</div>
<description-field
ref=
"description"
...
...
app/assets/javascripts/issue_show/constants.js
View file @
afe6e9e1
...
...
@@ -25,3 +25,14 @@ export const IssueStateEvent = {
export
const
STATUS_PAGE_PUBLISHED
=
__
(
'
Published on status page
'
);
export
const
JOIN_ZOOM_MEETING
=
__
(
'
Join Zoom meeting
'
);
export
const
IssuableTypes
=
[
{
value
:
'
issue
'
,
text
:
__
(
'
Issue
'
)
},
{
value
:
'
incident
'
,
text
:
__
(
'
Incident
'
)
},
];
export
const
IssueTypePath
=
'
issues
'
;
export
const
IncidentTypePath
=
'
issues/incident
'
;
export
const
IncidentType
=
'
incident
'
;
export
const
issueState
=
{
issueType
:
undefined
,
isDirty
:
false
};
app/assets/javascripts/issue_show/graphql.js
0 → 100644
View file @
afe6e9e1
import
Vue
from
'
vue
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
{
defaultClient
}
from
'
~/sidebar/graphql
'
;
Vue
.
use
(
VueApollo
);
export
default
new
VueApollo
({
defaultClient
,
});
app/assets/javascripts/issue_show/incident.js
View file @
afe6e9e1
import
Vue
from
'
vue
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
createDefaultClient
from
'
~/lib/graphql
'
;
import
{
parseBoolean
}
from
'
~/lib/utils/common_utils
'
;
import
issuableApp
from
'
./components/app.vue
'
;
import
incidentTabs
from
'
./components/incidents/incident_tabs.vue
'
;
Vue
.
use
(
VueApollo
);
import
{
issueState
}
from
'
./constants
'
;
import
apolloProvider
from
'
./graphql
'
;
import
getIssueStateQuery
from
'
./queries/get_issue_state.query.graphql
'
;
export
default
function
initIssuableApp
(
issuableData
=
{})
{
const
apolloProvider
=
new
VueApollo
({
defaultClient
:
createDefaultClient
(),
const
el
=
document
.
getElementById
(
'
js-issuable-app
'
);
if
(
!
el
)
{
return
undefined
;
}
apolloProvider
.
clients
.
defaultClient
.
cache
.
writeQuery
({
query
:
getIssueStateQuery
,
data
:
{
issueState
:
{
...
issueState
,
issueType
:
el
.
dataset
.
issueType
},
},
});
const
{
...
...
@@ -25,7 +33,7 @@ export default function initIssuableApp(issuableData = {}) {
const
fullPath
=
`
${
projectNamespace
}
/
${
projectPath
}
`
;
return
new
Vue
({
el
:
document
.
getElementById
(
'
js-issuable-app
'
)
,
el
,
apolloProvider
,
components
:
{
issuableApp
,
...
...
app/assets/javascripts/issue_show/issue.js
View file @
afe6e9e1
import
Vue
from
'
vue
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
{
mapGetters
}
from
'
vuex
'
;
import
createDefaultClient
from
'
~/lib/graphql
'
;
import
{
parseBoolean
}
from
'
~/lib/utils/common_utils
'
;
import
IssuableApp
from
'
./components/app.vue
'
;
import
HeaderActions
from
'
./components/header_actions.vue
'
;
import
{
issueState
}
from
'
./constants
'
;
import
apolloProvider
from
'
./graphql
'
;
import
getIssueStateQuery
from
'
./queries/get_issue_state.query.graphql
'
;
const
bootstrapApollo
=
(
state
=
{})
=>
{
return
apolloProvider
.
clients
.
defaultClient
.
cache
.
writeQuery
({
query
:
getIssueStateQuery
,
data
:
{
issueState
:
state
,
},
});
};
export
function
initIssuableApp
(
issuableData
,
store
)
{
const
el
=
document
.
getElementById
(
'
js-issuable-app
'
);
if
(
!
el
)
{
return
undefined
;
}
bootstrapApollo
({
...
issueState
,
issueType
:
el
.
dataset
.
issueType
});
return
new
Vue
({
el
:
document
.
getElementById
(
'
js-issuable-app
'
),
el
,
apolloProvider
,
store
,
computed
:
{
...
mapGetters
([
'
getNoteableData
'
]),
...
...
@@ -33,11 +52,7 @@ export function initIssueHeaderActions(store) {
return
undefined
;
}
Vue
.
use
(
VueApollo
);
const
apolloProvider
=
new
VueApollo
({
defaultClient
:
createDefaultClient
(),
});
bootstrapApollo
({
...
issueState
,
issueType
:
el
.
dataset
.
issueType
});
return
new
Vue
({
el
,
...
...
app/assets/javascripts/issue_show/queries/get_issue_state.query.graphql
0 → 100644
View file @
afe6e9e1
query
issueState
{
issueState
@client
}
app/assets/javascripts/issue_show/queries/update_issue_state.mutation.graphql
0 → 100644
View file @
afe6e9e1
mutation
updateIssueState
(
$issueType
:
String
,
$isDirty
:
Boolean
)
{
updateIssueState
(
issueType
:
$issueType
,
isDirty
:
$isDirty
)
@client
}
app/assets/javascripts/sidebar/graphql.js
View file @
afe6e9e1
import
{
IntrospectionFragmentMatcher
}
from
'
apollo-cache-inmemory
'
;
import
produce
from
'
immer
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
getIssueStateQuery
from
'
~/issue_show/queries/get_issue_state.query.graphql
'
;
import
createDefaultClient
from
'
~/lib/graphql
'
;
import
introspectionQueryResultData
from
'
./fragmentTypes.json
'
;
...
...
@@ -7,15 +9,24 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData
,
});
export
const
defaultClient
=
createDefaultClient
(
{},
{
cacheConfig
:
{
fragmentMatcher
,
const
resolvers
=
{
Mutation
:
{
updateIssueState
:
(
_
,
{
issueType
=
undefined
,
isDirty
=
false
},
{
cache
})
=>
{
const
sourceData
=
cache
.
readQuery
({
query
:
getIssueStateQuery
});
const
data
=
produce
(
sourceData
,
(
draftData
)
=>
{
draftData
.
issueState
=
{
issueType
,
isDirty
};
});
cache
.
writeQuery
({
query
:
getIssueStateQuery
,
data
});
},
assumeImmutableResults
:
true
,
},
);
};
export
const
defaultClient
=
createDefaultClient
(
resolvers
,
{
cacheConfig
:
{
fragmentMatcher
,
},
assumeImmutableResults
:
true
,
});
export
const
apolloProvider
=
new
VueApollo
({
defaultClient
,
...
...
app/services/issues/base_service.rb
View file @
afe6e9e1
...
...
@@ -38,6 +38,7 @@ module Issues
super
params
.
delete
(
:issue_type
)
unless
issue_type_allowed?
(
issue
)
filter_incident_label
(
issue
)
if
params
[
:issue_type
]
moved_issue
=
params
.
delete
(
:moved_issue
)
...
...
@@ -82,6 +83,37 @@ module Issues
def
issue_type_allowed?
(
object
)
can?
(
current_user
,
:"create_
#{
params
[
:issue_type
]
}
"
,
object
)
end
# @param issue [Issue]
def
filter_incident_label
(
issue
)
return
unless
add_incident_label?
(
issue
)
||
remove_incident_label?
(
issue
)
label
=
::
IncidentManagement
::
CreateIncidentLabelService
.
new
(
project
,
current_user
)
.
execute
.
payload
[
:label
]
# These(add_label_ids, remove_label_ids) are being added ahead of time
# to be consumed by #process_label_ids, this allows system notes
# to be applied correctly alongside the label updates.
if
add_incident_label?
(
issue
)
params
[
:add_label_ids
]
||=
[]
params
[
:add_label_ids
]
<<
label
.
id
else
params
[
:remove_label_ids
]
||=
[]
params
[
:remove_label_ids
]
<<
label
.
id
end
end
# @param issue [Issue]
def
add_incident_label?
(
issue
)
issue
.
incident?
end
# @param _issue [Issue, nil]
def
remove_incident_label?
(
_issue
)
false
end
end
end
...
...
app/services/issues/create_service.rb
View file @
afe6e9e1
...
...
@@ -34,7 +34,6 @@ module Issues
# Add new items to Issues::AfterCreateService if they can be performed in Sidekiq
def
after_create
(
issue
)
add_incident_label
(
issue
)
user_agent_detail_service
.
create
resolve_discussions_with_issue
(
issue
)
...
...
@@ -56,22 +55,6 @@ module Issues
def
user_agent_detail_service
UserAgentDetailService
.
new
(
@issue
,
request
)
end
# Applies label "incident" (creates it if missing) to incident issues.
# For use in "after" hooks only to ensure we are not appyling
# labels prematurely.
def
add_incident_label
(
issue
)
return
unless
issue
.
incident?
label
=
::
IncidentManagement
::
CreateIncidentLabelService
.
new
(
project
,
current_user
)
.
execute
.
payload
[
:label
]
return
if
issue
.
label_ids
.
include?
(
label
.
id
)
issue
.
labels
<<
label
end
end
end
...
...
app/services/issues/update_service.rb
View file @
afe6e9e1
...
...
@@ -204,6 +204,16 @@ module Issues
def
create_confidentiality_note
(
issue
)
SystemNoteService
.
change_issue_confidentiality
(
issue
,
issue
.
project
,
current_user
)
end
override
:add_incident_label?
def
add_incident_label?
(
issue
)
issue
.
issue_type
!=
params
[
:issue_type
]
&&
!
issue
.
incident?
end
override
:remove_incident_label?
def
remove_incident_label?
(
issue
)
issue
.
issue_type
!=
params
[
:issue_type
]
&&
issue
.
incident?
end
end
end
...
...
changelogs/unreleased/268370-change-issue-type-fe-delete-button.yml
0 → 100644
View file @
afe6e9e1
---
title
:
Allow issue type change for incidents
merge_request
:
61363
author
:
type
:
changed
doc/user/project/issues/img/issue_type_change_v13_12.png
0 → 100644
View file @
afe6e9e1
51.2 KB
doc/user/project/issues/managing_issues.md
View file @
afe6e9e1
...
...
@@ -326,6 +326,17 @@ In order to change the default issue closing pattern, GitLab administrators must
[
`gitlab.rb` or `gitlab.yml` file
](
../../../administration/issue_closing_pattern.md
)
of your installation.
## Change the issue type
Users with
[
developer permission
](
../../permissions.md
)
can change an issue's type. To do this, edit the issue and select an issue type from the
**Issue type**
selector menu:
-
[
Issue
](
index.md
)
-
[
Incident
](
../../../operations/incident_management/index.md
)
![
Change the issue type
](
img/issue_type_change_v13_12.png
)
## Deleting issues
Users with
[
project owner permission
](
../../permissions.md
)
can delete an issue by
...
...
ee/spec/features/epics/delete_epic_spec.rb
View file @
afe6e9e1
...
...
@@ -31,10 +31,10 @@ RSpec.describe 'Delete Epic', :js do
end
it
'deletes the issue and redirect to epic list'
do
page
.
accept_alert
'Delete this epic and all descendants?'
do
find
(
:button
,
text:
'Delete epic'
).
click
end
find
(
'.qa-delete-button'
).
click
wait_for_requests
find
(
'.js-modal-action-primary'
).
click
wait_for_requests
expect
(
find
(
'.issuable-list'
)).
not_to
have_content
(
epic
.
title
)
...
...
locale/gitlab.pot
View file @
afe6e9e1
...
...
@@ -18230,6 +18230,9 @@ msgstr ""
msgid "Issue Boards"
msgstr ""
msgid "Issue Type"
msgstr ""
msgid "Issue already promoted to epic."
msgstr ""
...
...
spec/features/incidents/incident_details_spec.rb
View file @
afe6e9e1
...
...
@@ -49,4 +49,42 @@ RSpec.describe 'Incident details', :js do
end
end
end
context
'when an incident `issue_type` is edited by a signed in user'
do
it
'routes the user to the incident details page when the `issue_type` is set to incident'
do
wait_for_requests
project_path
=
"/
#{
project
.
full_path
}
"
click_button
'Edit title and description'
wait_for_requests
page
.
within
(
'[data-testid="issuable-form"]'
)
do
click_button
'Incident'
click_button
'Issue'
click_button
'Save changes'
wait_for_requests
expect
(
page
).
to
have_current_path
(
"
#{
project_path
}
/-/issues/
#{
incident
.
iid
}
"
)
end
end
end
context
'when incident details are edited by a signed in user'
do
it
'routes the user to the incident details page when the `issue_type` is set to incident'
do
wait_for_requests
project_path
=
"/
#{
project
.
full_path
}
"
click_button
'Edit title and description'
wait_for_requests
page
.
within
(
'[data-testid="issuable-form"]'
)
do
click_button
'Incident'
click_button
'Issue'
click_button
'Save changes'
wait_for_requests
expect
(
page
).
to
have_current_path
(
"
#{
project_path
}
/-/issues/
#{
incident
.
iid
}
"
)
end
end
end
end
spec/features/issues/issue_detail_spec.rb
View file @
afe6e9e1
...
...
@@ -6,6 +6,7 @@ RSpec.describe 'Issue Detail', :js do
let
(
:user
)
{
create
(
:user
)
}
let
(
:project
)
{
create
(
:project
,
:public
)
}
let
(
:issue
)
{
create
(
:issue
,
project:
project
,
author:
user
)
}
let
(
:incident
)
{
create
(
:incident
,
project:
project
,
author:
user
)
}
context
'when user displays the issue'
do
before
do
...
...
@@ -21,10 +22,8 @@ RSpec.describe 'Issue Detail', :js do
end
context
'when user displays the issue as an incident'
do
let
(
:issue
)
{
create
(
:incident
,
project:
project
,
author:
user
)
}
before
do
visit
project_issue_path
(
project
,
i
ssue
)
visit
project_issue_path
(
project
,
i
ncident
)
wait_for_requests
end
...
...
@@ -58,9 +57,9 @@ RSpec.describe 'Issue Detail', :js do
visit
project_issue_path
(
project
,
issue
)
wait_for_requests
page
.
find
(
'.js-issuable-edit'
).
click
click_button
'Edit title and description'
fill_in
'issuable-title'
,
with:
'issue title'
click_button
'Save'
click_button
'Save
changes
'
wait_for_requests
Users
::
DestroyService
.
new
(
user
).
execute
(
user
)
...
...
@@ -74,4 +73,58 @@ RSpec.describe 'Issue Detail', :js do
end
end
end
describe
'user updates `issue_type` via the issue type dropdown'
do
context
'when an issue `issue_type` is edited by a signed in user'
do
before
do
sign_in
(
user
)
visit
project_issue_path
(
project
,
issue
)
wait_for_requests
end
it
'routes the user to the incident details page when the `issue_type` is set to incident'
do
open_issue_edit_form
page
.
within
(
'[data-testid="issuable-form"]'
)
do
update_type_select
(
'Issue'
,
'Incident'
)
expect
(
page
).
to
have_current_path
(
project_issues_incident_path
(
project
,
issue
))
end
end
end
context
'when an incident `issue_type` is edited by a signed in user'
do
before
do
sign_in
(
user
)
visit
project_issue_path
(
project
,
incident
)
wait_for_requests
end
it
'routes the user to the issue details page when the `issue_type` is set to issue'
do
open_issue_edit_form
page
.
within
(
'[data-testid="issuable-form"]'
)
do
update_type_select
(
'Incident'
,
'Issue'
)
expect
(
page
).
to
have_current_path
(
project_issue_path
(
project
,
incident
))
end
end
end
end
def
update_type_select
(
from
,
to
)
click_button
from
click_button
to
click_button
'Save changes'
wait_for_requests
end
def
open_issue_edit_form
wait_for_requests
click_button
'Edit title and description'
wait_for_requests
end
end
spec/frontend/issue_show/components/app_spec.js
View file @
afe6e9e1
import
{
GlIntersectionObserver
}
from
'
@gitlab/ui
'
;
import
{
mount
}
from
'
@vue/test-utils
'
;
import
MockAdapter
from
'
axios-mock-adapter
'
;
import
{
nextTick
}
from
'
vue
'
;
import
{
useMockIntersectionObserver
}
from
'
helpers/mock_dom_observer
'
;
import
'
~/behaviors/markdown/render_gfm
'
;
import
IssuableApp
from
'
~/issue_show/components/app.vue
'
;
...
...
@@ -17,7 +18,7 @@ import {
publishedIncidentUrl
,
secondRequest
,
zoomMeetingUrl
,
}
from
'
../mock_data
'
;
}
from
'
../mock_data
/mock_data
'
;
function
formatText
(
text
)
{
return
text
.
trim
().
replace
(
/
\s\s
+/g
,
'
'
);
...
...
@@ -36,12 +37,11 @@ describe('Issuable output', () => {
let
wrapper
;
const
findStickyHeader
=
()
=>
wrapper
.
find
(
'
[data-testid="issue-sticky-header"]
'
);
const
findLockedBadge
=
()
=>
wrapper
.
find
(
'
[data-testid="locked"]
'
);
const
findConfidentialBadge
=
()
=>
wrapper
.
find
(
'
[data-testid="confidential"]
'
);
const
findAlert
=
()
=>
wrapper
.
find
(
'
.alert
'
);
const
mountComponent
=
(
props
=
{},
options
=
{})
=>
{
const
mountComponent
=
(
props
=
{},
options
=
{}
,
data
=
{}
)
=>
{
wrapper
=
mount
(
IssuableApp
,
{
propsData
:
{
...
appProps
,
...
props
},
provide
:
{
...
...
@@ -53,6 +53,11 @@ describe('Issuable output', () => {
HighlightBar
:
true
,
IncidentTabs
:
true
,
},
data
()
{
return
{
...
data
,
};
},
...
options
,
});
};
...
...
@@ -91,10 +96,8 @@ describe('Issuable output', () => {
afterEach
(()
=>
{
mock
.
restore
();
realtimeRequestCount
=
0
;
wrapper
.
vm
.
poll
.
stop
();
wrapper
.
destroy
();
wrapper
=
null
;
});
it
(
'
should render a title/description/edited and update title/description/edited on update
'
,
()
=>
{
...
...
@@ -115,7 +118,7 @@ describe('Issuable output', () => {
expect
(
formatText
(
editedText
.
text
())).
toMatch
(
/Edited
[\s\S]
+
?
by Some User/
);
expect
(
editedText
.
find
(
'
.author-link
'
).
attributes
(
'
href
'
)).
toMatch
(
/
\/
some_user$/
);
expect
(
editedText
.
find
(
'
time
'
).
text
()).
toBeTruthy
();
expect
(
wrapper
.
vm
.
state
.
lock_version
).
to
Equal
(
1
);
expect
(
wrapper
.
vm
.
state
.
lock_version
).
to
Be
(
initialRequest
.
lock_version
);
})
.
then
(()
=>
{
wrapper
.
vm
.
poll
.
makeRequest
();
...
...
@@ -133,7 +136,9 @@ describe('Issuable output', () => {
expect
(
editedText
.
find
(
'
.author-link
'
).
attributes
(
'
href
'
)).
toMatch
(
/
\/
other_user$/
);
expect
(
editedText
.
find
(
'
time
'
).
text
()).
toBeTruthy
();
expect
(
wrapper
.
vm
.
state
.
lock_version
).
toEqual
(
2
);
// As the lock_version value does not differ from the server,
// we should not see an alert
expect
(
findAlert
().
exists
()).
toBe
(
false
);
});
});
...
...
@@ -172,7 +177,7 @@ describe('Issuable output', () => {
${
'
zoomMeetingUrl
'
}
|
${
zoomMeetingUrl
}
${
'
publishedIncidentUrl
'
}
|
${
publishedIncidentUrl
}
`
(
'
sets the $prop correctly on underlying pinned links
'
,
({
prop
,
value
})
=>
{
expect
(
wrapper
.
vm
[
prop
]).
to
Equal
(
value
);
expect
(
wrapper
.
vm
[
prop
]).
to
Be
(
value
);
expect
(
wrapper
.
find
(
`[data-testid="
${
prop
}
"]`
).
attributes
(
'
href
'
)).
toBe
(
value
);
});
});
...
...
@@ -374,9 +379,9 @@ describe('Issuable output', () => {
});
})
.
then
(()
=>
{
expect
(
wrapper
.
vm
.
formState
.
lockedWarningVisible
).
to
Equal
(
true
);
expect
(
wrapper
.
vm
.
formState
.
lock_version
).
to
Equal
(
1
);
expect
(
wrapper
.
find
(
'
.alert
'
).
exists
()).
toBe
(
true
);
expect
(
wrapper
.
vm
.
formState
.
lockedWarningVisible
).
to
Be
(
true
);
expect
(
wrapper
.
vm
.
formState
.
lock_version
).
to
Be
(
1
);
expect
(
findAlert
(
).
exists
()).
toBe
(
true
);
});
});
});
...
...
@@ -530,7 +535,7 @@ describe('Issuable output', () => {
`
(
'
$title
'
,
async
({
state
})
=>
{
wrapper
.
setProps
({
issuableStatus
:
state
});
await
wrapper
.
vm
.
$
nextTick
();
await
nextTick
();
expect
(
findStickyHeader
().
text
()).
toContain
(
IssuableStatusText
[
state
]);
});
...
...
@@ -542,7 +547,7 @@ describe('Issuable output', () => {
`
(
'
$title
'
,
async
({
isConfidential
})
=>
{
wrapper
.
setProps
({
isConfidential
});
await
wrapper
.
vm
.
$
nextTick
();
await
nextTick
();
expect
(
findConfidentialBadge
().
exists
()).
toBe
(
isConfidential
);
});
...
...
@@ -554,7 +559,7 @@ describe('Issuable output', () => {
`
(
'
$title
'
,
async
({
isLocked
})
=>
{
wrapper
.
setProps
({
isLocked
});
await
wrapper
.
vm
.
$
nextTick
();
await
nextTick
();
expect
(
findLockedBadge
().
exists
()).
toBe
(
isLocked
);
});
...
...
@@ -562,9 +567,9 @@ describe('Issuable output', () => {
});
describe
(
'
Composable description component
'
,
()
=>
{
const
findIncidentTabs
=
()
=>
wrapper
.
find
(
IncidentTabs
);
const
findDescriptionComponent
=
()
=>
wrapper
.
find
(
DescriptionComponent
);
const
findPinnedLinks
=
()
=>
wrapper
.
find
(
PinnedLinks
);
const
findIncidentTabs
=
()
=>
wrapper
.
find
Component
(
IncidentTabs
);
const
findDescriptionComponent
=
()
=>
wrapper
.
find
Component
(
DescriptionComponent
);
const
findPinnedLinks
=
()
=>
wrapper
.
find
Component
(
PinnedLinks
);
const
borderClass
=
'
gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6
'
;
describe
(
'
when using description component
'
,
()
=>
{
...
...
spec/frontend/issue_show/components/description_spec.js
View file @
afe6e9e1
...
...
@@ -5,7 +5,7 @@ import { TEST_HOST } from 'helpers/test_constants';
import
mountComponent
from
'
helpers/vue_mount_component_helper
'
;
import
Description
from
'
~/issue_show/components/description.vue
'
;
import
TaskList
from
'
~/task_list
'
;
import
{
descriptionProps
as
props
}
from
'
../mock_data
'
;
import
{
descriptionProps
as
props
}
from
'
../mock_data
/mock_data
'
;
jest
.
mock
(
'
~/task_list
'
);
...
...
spec/frontend/issue_show/components/edit_actions_spec.js
View file @
afe6e9e1
import
Vue
from
'
vue
'
;
import
editActions
from
'
~/issue_show/components/edit_actions.vue
'
;
import
{
GlButton
,
GlModal
}
from
'
@gitlab/ui
'
;
import
{
createLocalVue
}
from
'
@vue/test-utils
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
createMockApollo
from
'
helpers/mock_apollo_helper
'
;
import
{
shallowMountExtended
}
from
'
helpers/vue_test_utils_helper
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
IssuableEditActions
from
'
~/issue_show/components/edit_actions.vue
'
;
import
eventHub
from
'
~/issue_show/event_hub
'
;
import
Store
from
'
~/issue_show/stores
'
;
describe
(
'
Edit Actions components
'
,
()
=>
{
let
vm
;
import
{
getIssueStateQueryResponse
,
updateIssueStateQueryResponse
,
}
from
'
../mock_data/apollo_mock
'
;
const
localVue
=
createLocalVue
();
localVue
.
use
(
VueApollo
);
describe
(
'
Edit Actions component
'
,
()
=>
{
let
wrapper
;
let
fakeApollo
;
let
mockIssueStateData
;
const
mockResolvers
=
{
Query
:
{
issueState
()
{
return
{
__typename
:
'
IssueState
'
,
rawData
:
mockIssueStateData
(),
};
},
},
};
beforeEach
((
done
)
=>
{
const
Component
=
Vue
.
extend
(
editActions
);
const
store
=
new
Store
({
titleHtml
:
''
,
descriptionHtml
:
''
,
issuableRef
:
''
,
});
store
.
formState
.
title
=
'
test
'
;
const
modalId
=
'
delete-issuable-modal-1
'
;
jest
.
spyOn
(
eventHub
,
'
$emit
'
).
mockImplementation
(()
=>
{});
const
createComponent
=
({
props
,
data
}
=
{})
=>
{
fakeApollo
=
createMockApollo
([],
mockResolvers
);
vm
=
new
Component
({
wrapper
=
shallowMountExtended
(
IssuableEditActions
,
{
apolloProvider
:
fakeApollo
,
propsData
:
{
formState
:
{
title
:
'
GitLab Issue
'
,
},
canDestroy
:
true
,
formState
:
store
.
formState
,
issuableType
:
'
issue
'
,
...
props
,
},
}).
$mount
();
data
()
{
return
{
issueState
:
{},
modalId
,
...
data
,
};
},
});
};
Vue
.
nextTick
(
done
);
});
async
function
deleteIssuable
(
localWrapper
)
{
localWrapper
.
findComponent
(
GlModal
).
vm
.
$emit
(
'
primary
'
);
}
it
(
'
renders all buttons as enabled
'
,
()
=>
{
expect
(
vm
.
$el
.
querySelectorAll
(
'
.disabled
'
).
length
).
toBe
(
0
);
const
findModal
=
()
=>
wrapper
.
findComponent
(
GlModal
);
const
findEditButtons
=
()
=>
wrapper
.
findAllComponents
(
GlButton
);
const
findDeleteButton
=
()
=>
wrapper
.
findByTestId
(
'
issuable-delete-button
'
);
const
findSaveButton
=
()
=>
wrapper
.
findByTestId
(
'
issuable-save-button
'
);
const
findCancelButton
=
()
=>
wrapper
.
findByTestId
(
'
issuable-cancel-button
'
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
[disabled]
'
).
length
).
toBe
(
0
);
beforeEach
(()
=>
{
mockIssueStateData
=
jest
.
fn
();
createComponent
();
});
it
(
'
does not render delete button if canUpdate is false
'
,
(
done
)
=>
{
vm
.
canDestroy
=
false
;
Vue
.
nextTick
(()
=>
{
expect
(
vm
.
$el
.
querySelector
(
'
.btn-danger
'
)).
toBeNull
();
afterEach
(()
=>
{
wrapper
.
destroy
();
});
done
();
it
(
'
renders all buttons as enabled
'
,
()
=>
{
const
buttons
=
findEditButtons
().
wrappers
;
buttons
.
forEach
((
button
)
=>
{
expect
(
button
.
attributes
(
'
disabled
'
)).
toBeFalsy
();
});
});
it
(
'
disables submit button when title is blank
'
,
(
done
)
=>
{
vm
.
formState
.
title
=
''
;
it
(
'
does not render the delete button if canDestroy is false
'
,
()
=>
{
createComponent
({
props
:
{
canDestroy
:
false
}
});
expect
(
findDeleteButton
().
exists
()).
toBe
(
false
);
});
Vue
.
nextTick
(
()
=>
{
expect
(
vm
.
$el
.
querySelector
(
'
.btn-confirm
'
).
getAttribute
(
'
disabled
'
)).
toBe
(
'
disabled
'
);
it
(
'
disables save button when title is blank
'
,
()
=>
{
createComponent
({
props
:
{
formState
:
{
title
:
''
,
issue_type
:
''
}
}
}
);
done
();
});
expect
(
findSaveButton
().
attributes
(
'
disabled
'
)).
toBe
(
'
true
'
);
});
it
(
'
should not show delete button if showDeleteButton is false
'
,
(
done
)
=>
{
vm
.
showDeleteButton
=
false
;
it
(
'
does not render the delete button if showDeleteButton is false
'
,
(
)
=>
{
createComponent
({
props
:
{
showDeleteButton
:
false
}
})
;
Vue
.
nextTick
(()
=>
{
expect
(
vm
.
$el
.
querySelector
(
'
.btn-danger
'
)).
toBeNull
();
done
();
});
expect
(
findDeleteButton
().
exists
()).
toBe
(
false
);
});
describe
(
'
updateIssuable
'
,
()
=>
{
it
(
'
sends update.issauble event when clicking save button
'
,
()
=>
{
vm
.
$el
.
querySelector
(
'
.btn-confirm
'
).
click
();
expect
(
eventHub
.
$emit
).
toHaveBeenCalledWith
(
'
update.issuable
'
);
beforeEach
(()
=>
{
jest
.
spyOn
(
eventHub
,
'
$emit
'
).
mockImplementation
(()
=>
{});
});
it
(
'
disabled button after clicking save button
'
,
(
done
)
=>
{
vm
.
$el
.
querySelector
(
'
.btn-confirm
'
).
click
();
Vue
.
nextTick
(()
=>
{
expect
(
vm
.
$el
.
querySelector
(
'
.btn-confirm
'
).
getAttribute
(
'
disabled
'
)).
toBe
(
'
disabled
'
);
it
(
'
sends update.issauble event when clicking save button
'
,
()
=>
{
findSaveButton
().
vm
.
$emit
(
'
click
'
,
{
preventDefault
:
jest
.
fn
()
});
done
();
});
expect
(
eventHub
.
$emit
).
toHaveBeenCalledWith
(
'
update.issuable
'
);
});
});
describe
(
'
closeForm
'
,
()
=>
{
beforeEach
(()
=>
{
jest
.
spyOn
(
eventHub
,
'
$emit
'
).
mockImplementation
(()
=>
{});
});
it
(
'
emits close.form when clicking cancel
'
,
()
=>
{
vm
.
$el
.
querySelector
(
'
.btn-default
'
).
click
(
);
findCancelButton
().
vm
.
$emit
(
'
click
'
);
expect
(
eventHub
.
$emit
).
toHaveBeenCalledWith
(
'
close.form
'
);
});
});
describe
(
'
deleteIssuable
'
,
()
=>
{
it
(
'
sends delete.issuable event when clicking save button
'
,
()
=>
{
jest
.
spyOn
(
window
,
'
confirm
'
).
mockReturnValue
(
true
);
vm
.
$el
.
querySelector
(
'
.btn-danger
'
).
click
();
describe
(
'
renders create modal with the correct information
'
,
()
=>
{
it
(
'
renders correct modal id
'
,
()
=>
{
expect
(
findModal
().
attributes
(
'
modalid
'
)).
toBe
(
modalId
);
});
});
expect
(
eventHub
.
$emit
).
toHaveBeenCalledWith
(
'
delete.issuable
'
,
{
destroy_confirm
:
true
});
describe
(
'
deleteIssuable
'
,
()
=>
{
beforeEach
(()
=>
{
jest
.
spyOn
(
eventHub
,
'
$emit
'
).
mockImplementation
(()
=>
{});
});
it
(
'
does no actions when confirm is false
'
,
(
done
)
=>
{
jest
.
spyOn
(
window
,
'
confirm
'
).
mockReturnValue
(
false
);
vm
.
$el
.
querySelector
(
'
.btn-danger
'
).
click
();
it
(
'
does not send the `delete.issuable` event when clicking delete button
'
,
()
=>
{
findDeleteButton
().
vm
.
$emit
(
'
click
'
);
expect
(
eventHub
.
$emit
).
not
.
toHaveBeenCalled
();
});
Vue
.
nextTick
(()
=>
{
expect
(
eventHub
.
$emit
).
not
.
toHaveBeenCalledWith
(
'
delete.issuable
'
);
it
(
'
sends the `delete.issuable` event when clicking the delete confirm button
'
,
async
()
=>
{
expect
(
eventHub
.
$emit
).
toHaveBeenCalledTimes
(
0
);
await
deleteIssuable
(
wrapper
);
expect
(
eventHub
.
$emit
).
toHaveBeenCalledWith
(
'
delete.issuable
'
,
{
destroy_confirm
:
true
});
expect
(
eventHub
.
$emit
).
toHaveBeenCalledTimes
(
1
);
});
});
expect
(
vm
.
$el
.
querySelector
(
'
.btn-danger .fa
'
)).
toBeNull
();
describe
(
'
with Apollo cache mock
'
,
()
=>
{
it
(
'
renders the right delete button text per apollo cache type
'
,
async
()
=>
{
mockIssueStateData
.
mockResolvedValue
(
getIssueStateQueryResponse
);
await
waitForPromises
();
expect
(
findDeleteButton
().
text
()).
toBe
(
'
Delete issue
'
);
});
done
();
});
it
(
'
should not change the delete button text per apollo cache mutation
'
,
async
()
=>
{
mockIssueStateData
.
mockResolvedValue
(
updateIssueStateQueryResponse
);
await
waitForPromises
();
expect
(
findDeleteButton
().
text
()).
toBe
(
'
Delete issue
'
);
});
});
});
spec/frontend/issue_show/components/fields/type_spec.js
0 → 100644
View file @
afe6e9e1
import
{
GlFormGroup
,
GlDropdown
,
GlDropdownItem
}
from
'
@gitlab/ui
'
;
import
{
shallowMount
,
createLocalVue
}
from
'
@vue/test-utils
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
createMockApollo
from
'
helpers/mock_apollo_helper
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
IssueTypeField
,
{
i18n
}
from
'
~/issue_show/components/fields/type.vue
'
;
import
{
IssuableTypes
}
from
'
~/issue_show/constants
'
;
import
{
getIssueStateQueryResponse
,
updateIssueStateQueryResponse
,
}
from
'
../../mock_data/apollo_mock
'
;
const
localVue
=
createLocalVue
();
localVue
.
use
(
VueApollo
);
describe
(
'
Issue type field component
'
,
()
=>
{
let
wrapper
;
let
fakeApollo
;
let
mockIssueStateData
;
const
mockResolvers
=
{
Query
:
{
issueState
()
{
return
{
__typename
:
'
IssueState
'
,
rawData
:
mockIssueStateData
(),
};
},
},
Mutation
:
{
updateIssueState
:
jest
.
fn
().
mockResolvedValue
(
updateIssueStateQueryResponse
),
},
};
const
findTypeFromGroup
=
()
=>
wrapper
.
findComponent
(
GlFormGroup
);
const
findTypeFromDropDown
=
()
=>
wrapper
.
findComponent
(
GlDropdown
);
const
findTypeFromDropDownItems
=
()
=>
wrapper
.
findAllComponents
(
GlDropdownItem
);
const
createComponent
=
({
data
}
=
{})
=>
{
fakeApollo
=
createMockApollo
([],
mockResolvers
);
wrapper
=
shallowMount
(
IssueTypeField
,
{
localVue
,
apolloProvider
:
fakeApollo
,
data
()
{
return
{
issueState
:
{},
...
data
,
};
},
});
};
beforeEach
(()
=>
{
mockIssueStateData
=
jest
.
fn
();
createComponent
();
});
afterEach
(()
=>
{
wrapper
.
destroy
();
});
it
(
'
renders a form group with the correct label
'
,
()
=>
{
expect
(
findTypeFromGroup
().
attributes
(
'
label
'
)).
toBe
(
i18n
.
label
);
});
it
(
'
renders a form select with the `issue_type` value
'
,
()
=>
{
expect
(
findTypeFromDropDown
().
attributes
(
'
value
'
)).
toBe
(
IssuableTypes
.
issue
);
});
describe
(
'
with Apollo cache mock
'
,
()
=>
{
it
(
'
renders the selected issueType
'
,
async
()
=>
{
mockIssueStateData
.
mockResolvedValue
(
getIssueStateQueryResponse
);
await
waitForPromises
();
expect
(
findTypeFromDropDown
().
attributes
(
'
value
'
)).
toBe
(
IssuableTypes
.
issue
);
});
it
(
'
updates the `issue_type` in the apollo cache when the value is changed
'
,
async
()
=>
{
findTypeFromDropDownItems
().
at
(
1
).
vm
.
$emit
(
'
click
'
,
IssuableTypes
.
incident
);
await
wrapper
.
vm
.
$nextTick
();
expect
(
findTypeFromDropDown
().
attributes
(
'
value
'
)).
toBe
(
IssuableTypes
.
incident
);
});
});
});
spec/frontend/issue_show/components/form_spec.js
View file @
afe6e9e1
...
...
@@ -2,6 +2,7 @@ import { GlAlert } from '@gitlab/ui';
import
{
shallowMount
}
from
'
@vue/test-utils
'
;
import
Autosave
from
'
~/autosave
'
;
import
DescriptionTemplate
from
'
~/issue_show/components/fields/description_template.vue
'
;
import
IssueTypeField
from
'
~/issue_show/components/fields/type.vue
'
;
import
formComponent
from
'
~/issue_show/components/form.vue
'
;
import
LockedWarning
from
'
~/issue_show/components/locked_warning.vue
'
;
import
eventHub
from
'
~/issue_show/event_hub
'
;
...
...
@@ -39,6 +40,7 @@ describe('Inline edit form component', () => {
};
const
findDescriptionTemplate
=
()
=>
wrapper
.
findComponent
(
DescriptionTemplate
);
const
findIssuableTypeField
=
()
=>
wrapper
.
findComponent
(
IssueTypeField
);
const
findLockedWarning
=
()
=>
wrapper
.
findComponent
(
LockedWarning
);
const
findAlert
=
()
=>
wrapper
.
findComponent
(
GlAlert
);
...
...
@@ -68,6 +70,21 @@ describe('Inline edit form component', () => {
expect
(
findDescriptionTemplate
().
exists
()).
toBe
(
true
);
});
it
.
each
`
issuableType | value
${
'
issue
'
}
|
${
true
}
${
'
epic
'
}
|
${
false
}
`
(
'
when `issue_type` is set to "$issuableType" rendering the type select will be "$value"
'
,
({
issuableType
,
value
})
=>
{
createComponent
({
issuableType
,
});
expect
(
findIssuableTypeField
().
exists
()).
toBe
(
value
);
},
);
it
(
'
hides locked warning by default
'
,
()
=>
{
createComponent
();
...
...
spec/frontend/issue_show/components/incidents/incident_tabs_spec.js
View file @
afe6e9e1
...
...
@@ -9,7 +9,7 @@ import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
import
INVALID_URL
from
'
~/lib/utils/invalid_url
'
;
import
Tracking
from
'
~/tracking
'
;
import
AlertDetailsTable
from
'
~/vue_shared/components/alert_details_table.vue
'
;
import
{
descriptionProps
}
from
'
../../mock_data
'
;
import
{
descriptionProps
}
from
'
../../mock_data
/mock_data
'
;
const
mockAlert
=
{
__typename
:
'
AlertManagementAlert
'
,
...
...
spec/frontend/issue_show/issue_spec.js
View file @
afe6e9e1
...
...
@@ -5,7 +5,7 @@ import { initIssuableApp } from '~/issue_show/issue';
import
*
as
parseData
from
'
~/issue_show/utils/parse_data
'
;
import
axios
from
'
~/lib/utils/axios_utils
'
;
import
createStore
from
'
~/notes/stores
'
;
import
{
appProps
}
from
'
./mock_data
'
;
import
{
appProps
}
from
'
./mock_data
/mock_data
'
;
const
mock
=
new
MockAdapter
(
axios
);
mock
.
onGet
().
reply
(
200
);
...
...
spec/frontend/issue_show/mock_data/apollo_mock.js
0 → 100644
View file @
afe6e9e1
export
const
getIssueStateQueryResponse
=
{
issueType
:
'
issue
'
,
isDirty
:
false
,
};
export
const
updateIssueStateQueryResponse
=
{
issueType
:
'
incident
'
,
isDirty
:
true
,
};
spec/frontend/issue_show/mock_data.js
→
spec/frontend/issue_show/mock_data
/mock_data
.js
View file @
afe6e9e1
...
...
@@ -48,6 +48,7 @@ export const appProps = {
initialDescriptionHtml
:
'
test
'
,
initialDescriptionText
:
'
test
'
,
lockVersion
:
1
,
issueType
:
'
issue
'
,
markdownPreviewPath
:
'
/
'
,
markdownDocsPath
:
'
/
'
,
projectNamespace
:
'
/
'
,
...
...
spec/services/issues/create_service_spec.rb
View file @
afe6e9e1
...
...
@@ -78,8 +78,8 @@ RSpec.describe Issues::CreateService do
opts
.
merge!
(
title:
''
)
end
it
'does not
create
an incident label prematurely'
do
expect
{
subject
}.
not_to
change
(
Label
,
:count
)
it
'does not
apply
an incident label prematurely'
do
expect
{
subject
}.
to
not_change
(
LabelLink
,
:count
).
and
not_change
(
Issue
,
:count
)
end
end
end
...
...
spec/services/issues/update_service_spec.rb
View file @
afe6e9e1
...
...
@@ -158,6 +158,90 @@ RSpec.describe Issues::UpdateService, :mailer do
end
end
context
'changing issue_type'
do
let!
(
:label_1
)
{
create
(
:label
,
project:
project
,
title:
'incident'
)
}
let!
(
:label_2
)
{
create
(
:label
,
project:
project
,
title:
'missed-sla'
)
}
before
do
stub_licensed_features
(
quality_management:
true
)
end
context
'from issue to incident'
do
it
'adds a `incident` label if one does not exist'
do
expect
{
update_issue
(
issue_type:
'incident'
)
}.
to
change
(
issue
.
labels
,
:count
).
by
(
1
)
expect
(
issue
.
labels
.
pluck
(
:title
)).
to
eq
([
'incident'
])
end
context
'for an issue with multiple labels'
do
let
(
:issue
)
{
create
(
:incident
,
project:
project
,
labels:
[
label_1
])
}
before
do
update_issue
(
issue_type:
'incident'
)
end
it
'does not add an `incident` label if one already exist'
do
expect
(
issue
.
labels
).
to
eq
([
label_1
])
end
end
context
'filtering the incident label'
do
let
(
:params
)
{
{
add_label_ids:
[]
}
}
before
do
update_issue
(
issue_type:
'incident'
)
end
it
'creates and add a incident label id to add_label_ids'
do
expect
(
issue
.
label_ids
).
to
contain_exactly
(
label_1
.
id
)
end
end
end
context
'from incident to issue'
do
let
(
:issue
)
{
create
(
:incident
,
project:
project
)
}
context
'for an incident with multiple labels'
do
let
(
:issue
)
{
create
(
:incident
,
project:
project
,
labels:
[
label_1
,
label_2
])
}
before
do
update_issue
(
issue_type:
'issue'
)
end
it
'removes an `incident` label if one exists on the incident'
do
expect
(
issue
.
labels
).
to
eq
([
label_2
])
end
end
context
'filtering the incident label'
do
let
(
:issue
)
{
create
(
:incident
,
project:
project
,
labels:
[
label_1
,
label_2
])
}
let
(
:params
)
{
{
label_ids:
[
label_1
.
id
,
label_2
.
id
],
remove_label_ids:
[]
}
}
before
do
update_issue
(
issue_type:
'issue'
)
end
it
'adds an incident label id to remove_label_ids for it to be removed'
do
expect
(
issue
.
label_ids
).
to
contain_exactly
(
label_2
.
id
)
end
end
end
context
'from issue to restricted issue types'
do
context
'without sufficient permissions'
do
let
(
:user
)
{
create
(
:user
)
}
before
do
project
.
add_guest
(
user
)
end
it
'does nothing to the labels'
do
expect
{
update_issue
(
issue_type:
'issue'
)
}.
not_to
change
(
issue
.
labels
,
:count
)
expect
(
issue
.
reload
.
labels
).
to
eq
([])
end
end
end
end
it
'updates open issue counter for assignees when issue is reassigned'
do
update_issue
(
assignee_ids:
[
user2
.
id
])
...
...
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