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
db04e3ee
Commit
db04e3ee
authored
Feb 13, 2018
by
GitLab Bot
Browse files
Options
Browse Files
Download
Plain Diff
Merge remote-tracking branch 'upstream/master' into ce-to-ee-2018-02-13
parents
7afd29db
41285af4
Changes
27
Hide whitespace changes
Inline
Side-by-side
Showing
27 changed files
with
399 additions
and
202 deletions
+399
-202
app/assets/javascripts/create_merge_request_dropdown.js
app/assets/javascripts/create_merge_request_dropdown.js
+1
-0
app/assets/javascripts/droplab/constants.js
app/assets/javascripts/droplab/constants.js
+0
-2
app/assets/javascripts/droplab/drop_down.js
app/assets/javascripts/droplab/drop_down.js
+8
-5
app/assets/javascripts/issue.js
app/assets/javascripts/issue.js
+47
-28
app/assets/javascripts/notes/components/comment_form.vue
app/assets/javascripts/notes/components/comment_form.vue
+41
-20
app/assets/javascripts/notes/index.js
app/assets/javascripts/notes/index.js
+2
-0
app/assets/javascripts/notes/services/notes_service.js
app/assets/javascripts/notes/services/notes_service.js
+3
-0
app/assets/javascripts/notes/stores/actions.js
app/assets/javascripts/notes/stores/actions.js
+33
-0
app/assets/javascripts/notes/stores/getters.js
app/assets/javascripts/notes/stores/getters.js
+1
-0
app/assets/javascripts/notes/stores/mutation_types.js
app/assets/javascripts/notes/stores/mutation_types.js
+4
-0
app/assets/javascripts/notes/stores/mutations.js
app/assets/javascripts/notes/stores/mutations.js
+8
-0
app/assets/javascripts/vue_shared/components/loading_button.vue
...sets/javascripts/vue_shared/components/loading_button.vue
+1
-1
app/assets/stylesheets/framework/common.scss
app/assets/stylesheets/framework/common.scss
+2
-0
app/assets/stylesheets/framework/dropdowns.scss
app/assets/stylesheets/framework/dropdowns.scss
+0
-4
app/assets/stylesheets/framework/forms.scss
app/assets/stylesheets/framework/forms.scss
+1
-0
app/assets/stylesheets/pages/issues.scss
app/assets/stylesheets/pages/issues.scss
+11
-65
app/services/merge_requests/build_service.rb
app/services/merge_requests/build_service.rb
+5
-3
app/views/projects/issues/_discussion.html.haml
app/views/projects/issues/_discussion.html.haml
+2
-0
app/views/projects/issues/_new_branch.html.haml
app/views/projects/issues/_new_branch.html.haml
+30
-27
changelogs/unreleased/42923-close-issue.yml
changelogs/unreleased/42923-close-issue.yml
+5
-0
changelogs/unreleased/winh-new-branch-dropdown-style.yml
changelogs/unreleased/winh-new-branch-dropdown-style.yml
+5
-0
spec/javascripts/droplab/drop_down_spec.js
spec/javascripts/droplab/drop_down_spec.js
+77
-36
spec/javascripts/notes/helpers.js
spec/javascripts/notes/helpers.js
+12
-0
spec/javascripts/notes/mock_data.js
spec/javascripts/notes/mock_data.js
+2
-0
spec/javascripts/notes/stores/actions_spec.js
spec/javascripts/notes/stores/actions_spec.js
+71
-0
spec/javascripts/notes/stores/getters_spec.js
spec/javascripts/notes/stores/getters_spec.js
+6
-0
spec/services/merge_requests/build_service_spec.rb
spec/services/merge_requests/build_service_spec.rb
+21
-11
No files found.
app/assets/javascripts/create_merge_request_dropdown.js
View file @
db04e3ee
...
...
@@ -180,6 +180,7 @@ export default class CreateMergeRequestDropdown {
valueAttribute
:
'
data-text
'
,
},
],
hideOnClick
:
false
,
};
}
...
...
app/assets/javascripts/droplab/constants.js
View file @
db04e3ee
...
...
@@ -3,7 +3,6 @@ const DATA_DROPDOWN = 'data-dropdown';
const
SELECTED_CLASS
=
'
droplab-item-selected
'
;
const
ACTIVE_CLASS
=
'
droplab-item-active
'
;
const
IGNORE_CLASS
=
'
droplab-item-ignore
'
;
const
IGNORE_HIDING_CLASS
=
'
droplab-item-ignore-hiding
'
;
// Matches `{{anything}}` and `{{ everything }}`.
const
TEMPLATE_REGEX
=
/
\{\{(
.+
?)\}\}
/g
;
...
...
@@ -14,5 +13,4 @@ export {
ACTIVE_CLASS
,
TEMPLATE_REGEX
,
IGNORE_CLASS
,
IGNORE_HIDING_CLASS
,
};
app/assets/javascripts/droplab/drop_down.js
View file @
db04e3ee
import
utils
from
'
./utils
'
;
import
{
SELECTED_CLASS
,
IGNORE_CLASS
,
IGNORE_HIDING_CLASS
}
from
'
./constants
'
;
import
{
SELECTED_CLASS
,
IGNORE_CLASS
}
from
'
./constants
'
;
class
DropDown
{
constructor
(
list
,
config
=
{})
{
constructor
(
list
,
config
=
{
})
{
this
.
currentIndex
=
0
;
this
.
hidden
=
true
;
this
.
list
=
typeof
list
===
'
string
'
?
document
.
querySelector
(
list
)
:
list
;
this
.
items
=
[];
this
.
eventWrapper
=
{};
this
.
hideOnClick
=
config
.
hideOnClick
!==
false
;
if
(
config
.
addActiveClassToDropdownButton
)
{
this
.
dropdownToggle
=
this
.
list
.
parentNode
.
querySelector
(
'
.js-dropdown-toggle
'
);
...
...
@@ -37,15 +38,17 @@ class DropDown {
clickEvent
(
e
)
{
if
(
e
.
target
.
tagName
===
'
UL
'
)
return
;
if
(
e
.
target
.
cl
assList
.
contains
(
IGNORE_CLASS
))
return
;
if
(
e
.
target
.
cl
osest
(
`.
${
IGNORE_CLASS
}
`
))
return
;
const
selected
=
utils
.
closest
(
e
.
target
,
'
LI
'
);
const
selected
=
e
.
target
.
closest
(
'
li
'
);
if
(
!
selected
)
return
;
this
.
addSelectedClass
(
selected
);
e
.
preventDefault
();
if
(
!
e
.
target
.
classList
.
contains
(
IGNORE_HIDING_CLASS
))
this
.
hide
();
if
(
this
.
hideOnClick
)
{
this
.
hide
();
}
const
listEvent
=
new
CustomEvent
(
'
click.dl
'
,
{
detail
:
{
...
...
app/assets/javascripts/issue.js
View file @
db04e3ee
...
...
@@ -24,6 +24,51 @@ export default class Issue {
if
(
Issue
.
createMrDropdownWrap
)
{
this
.
createMergeRequestDropdown
=
new
CreateMergeRequestDropdown
(
Issue
.
createMrDropdownWrap
);
}
// Listen to state changes in the Vue app
document
.
addEventListener
(
'
issuable_vue_app:change
'
,
(
event
)
=>
{
this
.
updateTopState
(
event
.
detail
.
isClosed
,
event
.
detail
.
data
);
});
}
/**
* This method updates the top area of the issue.
*
* Once the issue state changes, either through a click on the top area (jquery)
* or a click on the bottom area (Vue) we need to update the top area.
*
* @param {Boolean} isClosed
* @param {Array} data
* @param {String} issueFailMessage
*/
updateTopState
(
isClosed
,
data
,
issueFailMessage
=
'
Unable to update this issue at this time.
'
)
{
if
(
'
id
'
in
data
)
{
const
isClosedBadge
=
$
(
'
div.status-box-issue-closed
'
);
const
isOpenBadge
=
$
(
'
div.status-box-open
'
);
const
projectIssuesCounter
=
$
(
'
.issue_counter
'
);
isClosedBadge
.
toggleClass
(
'
hidden
'
,
!
isClosed
);
isOpenBadge
.
toggleClass
(
'
hidden
'
,
isClosed
);
$
(
document
).
trigger
(
'
issuable:change
'
,
isClosed
);
this
.
toggleCloseReopenButton
(
isClosed
);
let
numProjectIssues
=
Number
(
projectIssuesCounter
.
first
().
text
().
trim
().
replace
(
/
[^\d]
/
,
''
));
numProjectIssues
=
isClosed
?
numProjectIssues
-
1
:
numProjectIssues
+
1
;
projectIssuesCounter
.
text
(
addDelimiter
(
numProjectIssues
));
if
(
this
.
createMergeRequestDropdown
)
{
if
(
isClosed
)
{
this
.
createMergeRequestDropdown
.
unavailable
();
this
.
createMergeRequestDropdown
.
disable
();
}
else
{
// We should check in case a branch was created in another tab
this
.
createMergeRequestDropdown
.
checkAbilityToCreateBranch
();
}
}
}
else
{
flash
(
issueFailMessage
);
}
}
initIssueBtnEventListeners
()
{
...
...
@@ -44,34 +89,8 @@ export default class Issue {
url
=
$button
.
attr
(
'
href
'
);
return
axios
.
put
(
url
)
.
then
(({
data
})
=>
{
const
isClosedBadge
=
$
(
'
div.status-box-issue-closed
'
);
const
isOpenBadge
=
$
(
'
div.status-box-open
'
);
const
projectIssuesCounter
=
$
(
'
.issue_counter
'
);
if
(
'
id
'
in
data
)
{
const
isClosed
=
$button
.
hasClass
(
'
btn-close
'
);
isClosedBadge
.
toggleClass
(
'
hidden
'
,
!
isClosed
);
isOpenBadge
.
toggleClass
(
'
hidden
'
,
isClosed
);
$
(
document
).
trigger
(
'
issuable:change
'
,
isClosed
);
this
.
toggleCloseReopenButton
(
isClosed
);
let
numProjectIssues
=
Number
(
projectIssuesCounter
.
first
().
text
().
trim
().
replace
(
/
[^\d]
/
,
''
));
numProjectIssues
=
isClosed
?
numProjectIssues
-
1
:
numProjectIssues
+
1
;
projectIssuesCounter
.
text
(
addDelimiter
(
numProjectIssues
));
if
(
this
.
createMergeRequestDropdown
)
{
if
(
isClosed
)
{
this
.
createMergeRequestDropdown
.
unavailable
();
this
.
createMergeRequestDropdown
.
disable
();
}
else
{
// We should check in case a branch was created in another tab
this
.
createMergeRequestDropdown
.
checkAbilityToCreateBranch
();
}
}
}
else
{
flash
(
issueFailMessage
);
}
const
isClosed
=
$button
.
hasClass
(
'
btn-close
'
);
this
.
updateTopState
(
isClosed
,
data
);
})
.
catch
(()
=>
flash
(
issueFailMessage
))
.
then
(()
=>
{
...
...
app/assets/javascripts/notes/components/comment_form.vue
View file @
db04e3ee
...
...
@@ -2,16 +2,18 @@
import
{
mapActions
,
mapGetters
}
from
'
vuex
'
;
import
_
from
'
underscore
'
;
import
Autosize
from
'
autosize
'
;
import
{
__
}
from
'
~/locale
'
;
import
Flash
from
'
../../flash
'
;
import
Autosave
from
'
../../autosave
'
;
import
TaskList
from
'
../../task_list
'
;
import
*
as
constants
from
'
../constants
'
;
import
eventHub
from
'
../event_hub
'
;
import
issueWarning
from
'
../../vue_shared/components/issue/issue_warning.vue
'
;
import
noteSignedOutWidget
from
'
./note_signed_out_widget.vue
'
;
import
discussionLockedWidget
from
'
./discussion_locked_widget.vue
'
;
import
markdownField
from
'
../../vue_shared/components/markdown/field.vue
'
;
import
userAvatarLink
from
'
../../vue_shared/components/user_avatar/user_avatar_link.vue
'
;
import
loadingButton
from
'
../../vue_shared/components/loading_button.vue
'
;
import
noteSignedOutWidget
from
'
./note_signed_out_widget.vue
'
;
import
discussionLockedWidget
from
'
./discussion_locked_widget.vue
'
;
import
issuableStateMixin
from
'
../mixins/issuable_state
'
;
export
default
{
...
...
@@ -22,6 +24,7 @@
discussionLockedWidget
,
markdownField
,
userAvatarLink
,
loadingButton
,
},
mixins
:
[
issuableStateMixin
,
...
...
@@ -30,9 +33,6 @@
return
{
note
:
''
,
noteType
:
constants
.
COMMENT
,
// Can't use mapGetters,
// this needs to be in the data object because it belongs to the state
issueState
:
this
.
$store
.
getters
.
getNoteableData
.
state
,
isSubmitting
:
false
,
isSubmitButtonDisabled
:
true
,
};
...
...
@@ -43,6 +43,7 @@
'
getUserData
'
,
'
getNoteableData
'
,
'
getNotesData
'
,
'
issueState
'
,
]),
isLoggedIn
()
{
return
this
.
getUserData
.
id
;
...
...
@@ -105,7 +106,7 @@
mounted
()
{
// jQuery is needed here because it is a custom event being dispatched with jQuery.
$
(
document
).
on
(
'
issuable:change
'
,
(
e
,
isClosed
)
=>
{
this
.
issueState
=
isClosed
?
constants
.
CLOSED
:
constants
.
REOPENED
;
this
.
toggleIssueLocalState
(
isClosed
?
constants
.
CLOSED
:
constants
.
REOPENED
)
;
});
this
.
initAutoSave
();
...
...
@@ -117,6 +118,9 @@
'
stopPolling
'
,
'
restartPolling
'
,
'
removePlaceholderNotes
'
,
'
closeIssue
'
,
'
reopenIssue
'
,
'
toggleIssueLocalState
'
,
]),
setIsSubmitButtonDisabled
(
note
,
isSubmitting
)
{
if
(
!
_
.
isEmpty
(
note
)
&&
!
isSubmitting
)
{
...
...
@@ -126,6 +130,8 @@
}
},
handleSave
(
withIssueAction
)
{
this
.
isSubmitting
=
true
;
if
(
this
.
note
.
length
)
{
const
noteData
=
{
endpoint
:
this
.
endpoint
,
...
...
@@ -142,7 +148,6 @@
if
(
this
.
noteType
===
constants
.
DISCUSSION
)
{
noteData
.
data
.
note
.
type
=
constants
.
DISCUSSION_NOTE
;
}
this
.
isSubmitting
=
true
;
this
.
note
=
''
;
// Empty textarea while being requested. Repopulate in catch
this
.
resizeTextarea
();
this
.
stopPolling
();
...
...
@@ -184,13 +189,25 @@ Please check your network connection and try again.`;
this
.
toggleIssueState
();
}
},
enableButton
()
{
this
.
isSubmitting
=
false
;
},
toggleIssueState
()
{
this
.
issueState
=
this
.
isIssueOpen
?
constants
.
CLOSED
:
constants
.
REOPENED
;
// This is out of scope for the Notes Vue component.
// It was the shortest path to update the issue state and relevant places.
const
btnClass
=
this
.
isIssueOpen
?
'
btn-reopen
'
:
'
btn-close
'
;
$
(
`.js-btn-issue-action.
${
btnClass
}
:visible`
).
trigger
(
'
click
'
);
if
(
this
.
isIssueOpen
)
{
this
.
closeIssue
()
.
then
(()
=>
this
.
enableButton
())
.
catch
(()
=>
{
this
.
enableButton
();
Flash
(
__
(
'
Something went wrong while closing the issue. Please try again later
'
));
});
}
else
{
this
.
reopenIssue
()
.
then
(()
=>
this
.
enableButton
())
.
catch
(()
=>
{
this
.
enableButton
();
Flash
(
__
(
'
Something went wrong while reopening the issue. Please try again later
'
));
});
}
},
discard
(
shouldClear
=
true
)
{
// `blur` is needed to clear slash commands autocomplete cache if event fired.
...
...
@@ -368,15 +385,19 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
</li>
</ul>
</div>
<button
type=
"button"
@
click=
"handleSave(true)"
<loading-button
v-if=
"canUpdateIssue"
:class=
"actionButtonClassNames"
:loading=
"isSubmitting"
@
click=
"handleSave(true)"
:container-class=
"[
actionButtonClassNames,
'btn btn-comment btn-comment-and-close js-action-button'
]"
:disabled=
"isSubmitting"
class=
"btn btn-comment btn-comment-and-close js-action-button"
>
{{
issueActionButtonTitle
}}
</button>
:label=
"issueActionButtonTitle"
/>
<button
type=
"button"
v-if=
"note.length"
...
...
app/assets/javascripts/notes/index.js
View file @
db04e3ee
...
...
@@ -28,6 +28,8 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
notesPath
:
notesDataset
.
notesPath
,
markdownDocsPath
:
notesDataset
.
markdownDocsPath
,
quickActionsDocsPath
:
notesDataset
.
quickActionsDocsPath
,
closeIssuePath
:
notesDataset
.
closeIssuePath
,
reopenIssuePath
:
notesDataset
.
reopenIssuePath
,
},
};
},
...
...
app/assets/javascripts/notes/services/notes_service.js
View file @
db04e3ee
...
...
@@ -32,4 +32,7 @@ export default {
toggleAward
(
endpoint
,
data
)
{
return
Vue
.
http
.
post
(
endpoint
,
data
,
{
emulateJSON
:
true
});
},
toggleIssueState
(
endpoint
,
data
)
{
return
Vue
.
http
.
put
(
endpoint
,
data
);
},
};
app/assets/javascripts/notes/stores/actions.js
View file @
db04e3ee
...
...
@@ -61,6 +61,39 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service
export
const
removePlaceholderNotes
=
({
commit
})
=>
commit
(
types
.
REMOVE_PLACEHOLDER_NOTES
);
export
const
closeIssue
=
({
commit
,
dispatch
,
state
})
=>
service
.
toggleIssueState
(
state
.
notesData
.
closeIssuePath
)
.
then
(
res
=>
res
.
json
())
.
then
((
data
)
=>
{
commit
(
types
.
CLOSE_ISSUE
);
dispatch
(
'
emitStateChangedEvent
'
,
data
);
});
export
const
reopenIssue
=
({
commit
,
dispatch
,
state
})
=>
service
.
toggleIssueState
(
state
.
notesData
.
reopenIssuePath
)
.
then
(
res
=>
res
.
json
())
.
then
((
data
)
=>
{
commit
(
types
.
REOPEN_ISSUE
);
dispatch
(
'
emitStateChangedEvent
'
,
data
);
});
export
const
emitStateChangedEvent
=
({
commit
,
getters
},
data
)
=>
{
const
event
=
new
CustomEvent
(
'
issuable_vue_app:change
'
,
{
detail
:
{
data
,
isClosed
:
getters
.
issueState
===
constants
.
CLOSED
,
}
});
document
.
dispatchEvent
(
event
);
};
export
const
toggleIssueLocalState
=
({
commit
},
newState
)
=>
{
if
(
newState
===
constants
.
CLOSED
)
{
commit
(
types
.
CLOSE_ISSUE
);
}
else
if
(
newState
===
constants
.
REOPENED
)
{
commit
(
types
.
REOPEN_ISSUE
);
}
};
export
const
saveNote
=
({
commit
,
dispatch
},
noteData
)
=>
{
const
{
note
}
=
noteData
.
data
.
note
;
let
placeholderText
=
note
;
...
...
app/assets/javascripts/notes/stores/getters.js
View file @
db04e3ee
...
...
@@ -8,6 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop];
export
const
getNoteableData
=
state
=>
state
.
noteableData
;
export
const
getNoteableDataByProp
=
state
=>
prop
=>
state
.
noteableData
[
prop
];
export
const
issueState
=
state
=>
state
.
noteableData
.
state
;
export
const
getUserData
=
state
=>
state
.
userData
||
{};
export
const
getUserDataByProp
=
state
=>
prop
=>
state
.
userData
&&
state
.
userData
[
prop
];
...
...
app/assets/javascripts/notes/stores/mutation_types.js
View file @
db04e3ee
...
...
@@ -12,3 +12,7 @@ export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
export
const
TOGGLE_AWARD
=
'
TOGGLE_AWARD
'
;
export
const
TOGGLE_DISCUSSION
=
'
TOGGLE_DISCUSSION
'
;
export
const
UPDATE_NOTE
=
'
UPDATE_NOTE
'
;
// Issue
export
const
CLOSE_ISSUE
=
'
CLOSE_ISSUE
'
;
export
const
REOPEN_ISSUE
=
'
REOPEN_ISSUE
'
;
app/assets/javascripts/notes/stores/mutations.js
View file @
db04e3ee
...
...
@@ -152,4 +152,12 @@ export default {
noteObj
.
notes
.
splice
(
noteObj
.
notes
.
indexOf
(
comment
),
1
,
note
);
}
},
[
types
.
CLOSE_ISSUE
](
state
)
{
Object
.
assign
(
state
.
noteableData
,
{
state
:
constants
.
CLOSED
});
},
[
types
.
REOPEN_ISSUE
](
state
)
{
Object
.
assign
(
state
.
noteableData
,
{
state
:
constants
.
REOPENED
});
},
};
app/assets/javascripts/vue_shared/components/loading_button.vue
View file @
db04e3ee
...
...
@@ -39,7 +39,7 @@
required
:
false
,
},
containerClass
:
{
type
:
String
,
type
:
[
String
,
Array
,
Object
]
,
required
:
false
,
default
:
'
btn btn-align-content
'
,
},
...
...
app/assets/stylesheets/framework/common.scss
View file @
db04e3ee
...
...
@@ -457,9 +457,11 @@ img.emoji {
.prepend-top-10
{
margin-top
:
10px
;
}
.prepend-top-15
{
margin-top
:
15px
;
}
.prepend-top-default
{
margin-top
:
$gl-padding
!
important
;
}
.prepend-top-16
{
margin-top
:
16px
;
}
.prepend-top-20
{
margin-top
:
20px
;
}
.prepend-left-4
{
margin-left
:
4px
;
}
.prepend-left-5
{
margin-left
:
5px
;
}
.prepend-left-8
{
margin-left
:
8px
;
}
.prepend-left-10
{
margin-left
:
10px
;
}
.prepend-left-15
{
margin-left
:
15px
;
}
.prepend-left-default
{
margin-left
:
$gl-padding
;
}
...
...
app/assets/stylesheets/framework/dropdowns.scss
View file @
db04e3ee
...
...
@@ -736,10 +736,6 @@
}
}
.droplab-item-ignore
{
pointer-events
:
none
;
}
.pika-single.animate-picker.is-bound
,
.pika-single.animate-picker.is-bound.is-hidden
{
/*
...
...
app/assets/stylesheets/framework/forms.scss
View file @
db04e3ee
...
...
@@ -182,6 +182,7 @@ label {
.help-block
{
margin-bottom
:
0
;
margin-top
:
#{
$grid-size
/
2
}
;
}
.gl-field-error
{
...
...
app/assets/stylesheets/pages/issues.scss
View file @
db04e3ee
...
...
@@ -201,11 +201,6 @@ ul.related-merge-requests > li {
}
.create-mr-dropdown-wrap
{
.branch-message
,
.ref-message
{
display
:
none
;
}
.
ref
:
:
selection
{
color
:
$placeholder-text-color
;
}
...
...
@@ -236,6 +231,17 @@ ul.related-merge-requests > li {
transform
:
translateY
(
0
);
display
:
none
;
margin-top
:
4px
;
// override dropdown item styles
.btn.btn-success
{
@include
btn-default
;
@include
btn-green
;
border-style
:
solid
;
border-width
:
1px
;
line-height
:
$line-height-base
;
width
:
auto
;
}
}
.create-merge-request-dropdown-toggle
{
...
...
@@ -245,66 +251,6 @@ ul.related-merge-requests > li {
margin-left
:
0
;
}
}
.droplab-item-ignore
{
pointer-events
:
auto
;
}
.create-item
{
cursor
:
pointer
;
margin
:
0
1px
;
&
:hover
,
&
:focus
{
background-color
:
$dropdown-item-hover-bg
;
color
:
$gl-text-color
;
}
}
li
.divider
{
margin
:
8px
10px
;
}
li
:not
(
.divider
)
{
padding
:
8px
9px
;
&
:last-child
{
padding-bottom
:
8px
;
}
&
.droplab-item-selected
{
.icon-container
{
i
{
visibility
:
visible
;
}
}
.description
{
display
:
block
;
}
}
&
.droplab-item-ignore
{
padding-top
:
8px
;
}
.icon-container
{
float
:
left
;
i
{
visibility
:
hidden
;
}
}
.description
{
padding-left
:
22px
;
}
input
,
span
{
margin
:
4px
0
0
;
}
}
}
.discussion-reply-holder
.note-edit-form
{
...
...
app/services/merge_requests/build_service.rb
View file @
db04e3ee
...
...
@@ -161,10 +161,12 @@ module MergeRequests
merge_request
.
title
=
"Resolve
\"
#{
issue
.
title
}
\"
"
if
issue
.
is_a?
(
Issue
)
unless
merge_request
.
title
branch_title
=
source_branch
.
downcase
.
remove
(
issue_iid
.
downcase
).
titleize
.
humanize
return
if
merge_request
.
title
.
present?
if
issue_iid
.
present?
merge_request
.
title
=
"Resolve
#{
issue_iid
}
"
merge_request
.
title
+=
"
\"
#{
branch_title
}
\"
"
unless
branch_title
.
empty?
branch_title
=
source_branch
.
downcase
.
remove
(
issue_iid
.
downcase
).
titleize
.
humanize
merge_request
.
title
+=
"
\"
#{
branch_title
}
\"
"
if
branch_title
.
present?
end
end
...
...
app/views/projects/issues/_discussion.html.haml
View file @
db04e3ee
...
...
@@ -12,6 +12,8 @@
markdown_docs_path:
help_page_path
(
'user/markdown'
),
quick_actions_docs_path:
help_page_path
(
'user/project/quick_actions'
),
notes_path:
notes_url
,
close_issue_path:
issue_path
(
@issue
,
issue:
{
state_event: :close
},
format:
'json'
),
reopen_issue_path:
issue_path
(
@issue
,
issue:
{
state_event: :reopen
},
format:
'json'
),
last_fetched_at:
Time
.
now
.
to_i
,
noteable_data:
serialize_issuable
(
@issue
),
current_user_data:
UserSerializer
.
new
.
represent
(
current_user
,
only_path:
true
).
to_json
}
}
app/views/projects/issues/_new_branch.html.haml
View file @
db04e3ee
...
...
@@ -21,30 +21,33 @@
%button
.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle
{
type:
'button'
,
data:
{
dropdown:
{
trigger:
'#create-merge-request-dropdown'
}
}
}
=
icon
(
'caret-down'
)
%ul
#create-merge-request-dropdown
.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors
{
data:
{
dropdown:
true
}
}
-
if
can_create_merge_request
%li
.create-item.droplab-item-selected.droplab-item-ignore-hiding
{
role:
'button'
,
data:
{
value:
'create-mr'
,
text:
'Create merge request'
}
}
.menu-item.droplab-item-ignore-hiding
.icon-container.droplab-item-ignore-hiding
=
icon
(
'check'
)
.description.droplab-item-ignore-hiding
Create merge request and branch
%li
.create-item.droplab-item-ignore-hiding
{
class:
[
!
can_create_merge_request
&&
'droplab-item-selected'
],
role:
'button'
,
data:
{
value:
'create-branch'
,
text:
'Create branch'
}
}
.menu-item.droplab-item-ignore-hiding
.icon-container.droplab-item-ignore-hiding
=
icon
(
'check'
)
.description.droplab-item-ignore-hiding
Create branch
%li
.divider
%li
.droplab-item-ignore
Branch name
%input
.js-branch-name.form-control.droplab-item-ignore
{
type:
'text'
,
placeholder:
"#{@issue.to_branch_name}"
,
value:
"#{@issue.to_branch_name}"
}
%span
.js-branch-message.branch-message.droplab-item-ignore
%li
.droplab-item-ignore
Source (branch or tag)
%input
.js-ref.ref.form-control.droplab-item-ignore
{
type:
'text'
,
placeholder:
"#{@project.default_branch}"
,
value:
"#{@project.default_branch}"
,
data:
{
value:
"#{@project.default_branch}"
}
}
%span
.js-ref-message.ref-message.droplab-item-ignore
%li
.droplab-item-ignore
%button
.btn.btn-success.js-create-target.droplab-item-ignore
{
type:
'button'
,
data:
{
action:
'create-mr'
}
}
Create merge request
.droplab-dropdown
%ul
#create-merge-request-dropdown
.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors
{
data:
{
dropdown:
true
}
}
-
if
can_create_merge_request
%li
.droplab-item-selected
{
role:
'button'
,
data:
{
value:
'create-mr'
,
text:
_
(
'Create merge request'
)
}
}
.menu-item
=
icon
(
'check'
,
class:
'icon'
)
=
_
(
'Create merge request and branch'
)
%li
{
class:
[
!
can_create_merge_request
&&
'droplab-item-selected'
],
role:
'button'
,
data:
{
value:
'create-branch'
,
text:
_
(
'Create branch'
)
}
}
.menu-item
=
icon
(
'check'
,
class:
'icon'
)
=
_
(
'Create branch'
)
%li
.divider.droplab-item-ignore
%li
.droplab-item-ignore.prepend-left-8.append-right-8.prepend-top-16
.form-group
%label
{
for:
'new-branch-name'
}
=
_
(
'Branch name'
)
%input
#new-branch-name
.js-branch-name.form-control
{
type:
'text'
,
placeholder:
"#{@issue.to_branch_name}"
,
value:
"#{@issue.to_branch_name}"
}
%span
.js-branch-message.help-block
.form-group
%label
{
for:
'source-name'
}
=
_
(
'Source (branch or tag)'
)
%input
#source-name
.js-ref.ref.form-control
{
type:
'text'
,
placeholder:
"#{@project.default_branch}"
,
value:
"#{@project.default_branch}"
,
data:
{
value:
"#{@project.default_branch}"
}
}
%span
.js-ref-message.help-block
.form-group
%button
.btn.btn-success.js-create-target
{
type:
'button'
,
data:
{
action:
'create-mr'
}
}
=
_
(
'Create merge request'
)
changelogs/unreleased/42923-close-issue.yml
0 → 100644
View file @
db04e3ee
---
title
:
Fix close button on issues not working on mobile
merge_request
:
author
:
type
:
fixed
changelogs/unreleased/winh-new-branch-dropdown-style.yml
0 → 100644
View file @
db04e3ee
---
title
:
Cleanup new branch/merge request form in issues
merge_request
:
16854
author
:
type
:
fixed
spec/javascripts/droplab/drop_down_spec.js
View file @
db04e3ee
import
DropDown
from
'
~/droplab/drop_down
'
;
import
utils
from
'
~/droplab/utils
'
;
import
{
SELECTED_CLASS
,
IGNORE_CLASS
}
from
'
~/droplab/constants
'
;
import
{
SELECTED_CLASS
}
from
'
~/droplab/constants
'
;
describe
(
'
DropDown
'
,
function
()
{
describe
(
'
Drop
Lab Drop
Down
'
,
function
()
{
describe
(
'
class constructor
'
,
function
()
{
beforeEach
(
function
()
{
spyOn
(
DropDown
.
prototype
,
'
getItems
'
);
...
...
@@ -128,93 +128,131 @@ describe('DropDown', function () {
beforeEach
(
function
()
{
this
.
classList
=
jasmine
.
createSpyObj
(
'
classList
'
,
[
'
contains
'
]);
this
.
list
=
{
dispatchEvent
:
()
=>
{}
};
this
.
dropdown
=
{
hide
:
()
=>
{},
list
:
this
.
list
,
addSelectedClass
:
()
=>
{}
};
this
.
event
=
{
preventDefault
:
()
=>
{},
target
:
{
classList
:
this
.
classList
}
};
this
.
dropdown
=
{
hideOnClick
:
true
,
hide
:
()
=>
{},
list
:
this
.
list
,
addSelectedClass
:
()
=>
{},
};
this
.
event
=
{
preventDefault
:
()
=>
{},
target
:
{
classList
:
this
.
classList
,
closest
:
()
=>
null
,
},
};
this
.
customEvent
=
{};
this
.
closestElement
=
{};
this
.
dummyListItem
=
document
.
createElement
(
'
li
'
);
spyOn
(
this
.
event
.
target
,
'
closest
'
).
and
.
callFake
((
selector
)
=>
{
if
(
selector
===
'
li
'
)
{
return
this
.
dummyListItem
;
}
return
null
;
});
spyOn
(
this
.
dropdown
,
'
hide
'
);
spyOn
(
this
.
dropdown
,
'
addSelectedClass
'
);
spyOn
(
this
.
list
,
'
dispatchEvent
'
);
spyOn
(
this
.
event
,
'
preventDefault
'
);
spyOn
(
window
,
'
CustomEvent
'
).
and
.
returnValue
(
this
.
customEvent
);
spyOn
(
utils
,
'
closest
'
).
and
.
returnValues
(
this
.
closestElement
,
undefined
);
this
.
classList
.
contains
.
and
.
returnValue
(
false
);
});
it
(
'
should call event.target.closest
'
,
function
()
{
DropDown
.
prototype
.
clickEvent
.
call
(
this
.
dropdown
,
this
.
event
);
});
it
(
'
should call utils.closest
'
,
function
()
{
expect
(
utils
.
closest
).
toHaveBeenCalledWith
(
this
.
event
.
target
,
'
LI
'
);
expect
(
this
.
event
.
target
.
closest
).
toHaveBeenCalledWith
(
'
.droplab-item-ignore
'
);
expect
(
this
.
event
.
target
.
closest
).
toHaveBeenCalledWith
(
'
li
'
);
});
it
(
'
should call addSelectedClass
'
,
function
()
{
expect
(
this
.
dropdown
.
addSelectedClass
).
toHaveBeenCalledWith
(
this
.
closestElement
);
DropDown
.
prototype
.
clickEvent
.
call
(
this
.
dropdown
,
this
.
event
);
expect
(
this
.
dropdown
.
addSelectedClass
).
toHaveBeenCalledWith
(
this
.
dummyListItem
);
});
it
(
'
should call .preventDefault
'
,
function
()
{
DropDown
.
prototype
.
clickEvent
.
call
(
this
.
dropdown
,
this
.
event
);
expect
(
this
.
event
.
preventDefault
).
toHaveBeenCalled
();
});
it
(
'
should call .hide
'
,
function
()
{
DropDown
.
prototype
.
clickEvent
.
call
(
this
.
dropdown
,
this
.
event
);
expect
(
this
.
dropdown
.
hide
).
toHaveBeenCalled
();
});
it
(
'
should construct CustomEvent
'
,
function
()
{
expect
(
window
.
CustomEvent
).
toHaveBeenCalledWith
(
'
click.dl
'
,
jasmine
.
any
(
Object
));
});
DropDown
.
prototype
.
clickEvent
.
call
(
this
.
dropdown
,
this
.
event
);
it
(
'
should call .classList.contains checking for IGNORE_CLASS
'
,
function
()
{
expect
(
this
.
classList
.
contains
).
toHaveBeenCalledWith
(
IGNORE_CLASS
);
expect
(
window
.
CustomEvent
).
toHaveBeenCalledWith
(
'
click.dl
'
,
jasmine
.
any
(
Object
));
});
it
(
'
should call .dispatchEvent with the customEvent
'
,
function
()
{
DropDown
.
prototype
.
clickEvent
.
call
(
this
.
dropdown
,
this
.
event
);
expect
(
this
.
list
.
dispatchEvent
).
toHaveBeenCalledWith
(
this
.
customEvent
);
});
describe
(
'
if the target is a UL element
'
,
function
()
{
beforeEach
(
function
()
{
this
.
event
=
{
preventDefault
:
()
=>
{},
target
:
{
tagName
:
'
UL
'
,
classList
:
this
.
classList
}
};
spyOn
(
this
.
event
,
'
preventDefault
'
);
utils
.
closest
.
calls
.
reset
();
this
.
event
.
target
=
document
.
createElement
(
'
ul
'
);
DropDown
.
prototype
.
clickEvent
.
call
(
this
.
dropdown
,
this
.
event
);
spyOn
(
this
.
event
.
target
,
'
closest
'
);
});
it
(
'
should return immediately
'
,
function
()
{
expect
(
utils
.
closest
).
not
.
toHaveBeenCalled
();
DropDown
.
prototype
.
clickEvent
.
call
(
this
.
dropdown
,
this
.
event
);
expect
(
this
.
event
.
target
.
closest
).
not
.
toHaveBeenCalled
();
expect
(
this
.
dropdown
.
addSelectedClass
).
not
.
toHaveBeenCalled
();
});
});
describe
(
'
if the target has the
IGNORE_CLASS
class
'
,
function
()
{
describe
(
'
if the target has the
droplab-item-ignore
class
'
,
function
()
{
beforeEach
(
function
()
{
this
.
event
=
{
preventDefault
:
()
=>
{},
target
:
{
tagName
:
'
LI
'
,
classList
:
this
.
classList
}
};
this
.
ignoredButton
=
document
.
createElement
(
'
button
'
);
this
.
ignoredButton
.
classList
.
add
(
'
droplab-item-ignore
'
);
this
.
event
.
target
=
this
.
ignoredButton
;
spyOn
(
this
.
event
,
'
preventDefault
'
);
this
.
classList
.
contains
.
and
.
returnValue
(
true
);
utils
.
closest
.
calls
.
reset
();
spyOn
(
this
.
ignoredButton
,
'
closest
'
).
and
.
callThrough
();
});
it
(
'
does not select element
'
,
function
()
{
DropDown
.
prototype
.
clickEvent
.
call
(
this
.
dropdown
,
this
.
event
);
});
it
(
'
should return immediately
'
,
function
()
{
expect
(
utils
.
closest
).
not
.
toHaveBeenCalled
();
expect
(
this
.
ignoredButton
.
closest
.
calls
.
count
()).
toBe
(
1
);
expect
(
this
.
ignoredButton
.
closest
).
toHaveBeenCalledWith
(
'
.droplab-item-ignore
'
);
expect
(
this
.
dropdown
.
addSelectedClass
).
not
.
toHaveBeenCalled
();
});
});
describe
(
'
if no selected element exists
'
,
function
()
{
beforeEach
(
function
()
{
this
.
event
.
preventDefault
.
calls
.
reset
();
this
.
clickEvent
=
DropDown
.
prototype
.
clickEvent
.
call
(
this
.
dropdown
,
this
.
event
);
});
it
(
'
should return undefined
'
,
function
()
{
expect
(
this
.
clickEvent
).
toBe
(
undefined
);
this
.
dummyListItem
=
null
;
});
it
(
'
should return before .preventDefault is called
'
,
function
()
{
DropDown
.
prototype
.
clickEvent
.
call
(
this
.
dropdown
,
this
.
event
);
expect
(
this
.
event
.
preventDefault
).
not
.
toHaveBeenCalled
();
expect
(
this
.
dropdown
.
addSelectedClass
).
not
.
toHaveBeenCalled
();
});
});
describe
(
'
if hideOnClick is false
'
,
()
=>
{
beforeEach
(
function
()
{
this
.
dropdown
.
hideOnClick
=
false
;
this
.
dropdown
.
hide
.
calls
.
reset
();
});
it
(
'
should not call .hide
'
,
function
()
{
DropDown
.
prototype
.
clickEvent
.
call
(
this
.
dropdown
,
this
.
event
);
expect
(
this
.
dropdown
.
hide
).
not
.
toHaveBeenCalled
();
});
});
});
...
...
@@ -278,20 +316,23 @@ describe('DropDown', function () {
describe
(
'
addEvents
'
,
function
()
{
beforeEach
(
function
()
{
this
.
list
=
{
addEventListener
:
()
=>
{}
};
this
.
list
=
{
addEventListener
:
()
=>
{},
querySelectorAll
:
()
=>
[],
};
this
.
dropdown
=
{
list
:
this
.
list
,
clickEvent
:
()
=>
{},
closeDropdown
:
()
=>
{},
eventWrapper
:
{},
};
});
it
(
'
should call .addEventListener
'
,
function
()
{
spyOn
(
this
.
list
,
'
addEventListener
'
);
DropDown
.
prototype
.
addEvents
.
call
(
this
.
dropdown
);
});
it
(
'
should call .addEventListener
'
,
function
()
{
expect
(
this
.
list
.
addEventListener
).
toHaveBeenCalledWith
(
'
click
'
,
jasmine
.
any
(
Function
));
expect
(
this
.
list
.
addEventListener
).
toHaveBeenCalledWith
(
'
keyup
'
,
jasmine
.
any
(
Function
));
});
...
...
spec/javascripts/notes/helpers.js
0 → 100644
View file @
db04e3ee
// eslint-disable-next-line import/prefer-default-export
export
const
resetStore
=
(
store
)
=>
{
store
.
replaceState
({
notes
:
[],
targetNoteHash
:
null
,
lastFetchedAt
:
null
,
notesData
:
{},
userData
:
{},
noteableData
:
{},
});
};
spec/javascripts/notes/mock_data.js
View file @
db04e3ee
...
...
@@ -7,6 +7,8 @@ export const notesDataMock = {
notesPath
:
'
/gitlab-org/gitlab-ce/noteable/issue/98/notes
'
,
quickActionsDocsPath
:
'
/help/user/project/quick_actions
'
,
registerPath
:
'
/users/sign_in?redirect_to_referer=yes#register-pane
'
,
closeIssuePath
:
'
/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close
'
,
reopenIssuePath
:
'
/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen
'
,
};
export
const
userDataMock
=
{
...
...
spec/javascripts/notes/stores/actions_spec.js
View file @
db04e3ee
import
Vue
from
'
vue
'
;
import
_
from
'
underscore
'
;
import
*
as
actions
from
'
~/notes/stores/actions
'
;
import
store
from
'
~/notes/stores
'
;
import
testAction
from
'
../../helpers/vuex_action_helper
'
;
import
{
resetStore
}
from
'
../helpers
'
;
import
{
discussionMock
,
notesDataMock
,
userDataMock
,
noteableDataMock
,
individualNote
}
from
'
../mock_data
'
;
describe
(
'
Actions Notes Store
'
,
()
=>
{
afterEach
(()
=>
{
resetStore
(
store
);
});
describe
(
'
setNotesData
'
,
()
=>
{
it
(
'
should set received notes data
'
,
(
done
)
=>
{
testAction
(
actions
.
setNotesData
,
null
,
{
notesData
:
{}
},
[
...
...
@@ -58,4 +66,67 @@ describe('Actions Notes Store', () => {
],
done
);
});
});
describe
(
'
async methods
'
,
()
=>
{
const
interceptor
=
(
request
,
next
)
=>
{
next
(
request
.
respondWith
(
JSON
.
stringify
({}),
{
status
:
200
,
}));
};
beforeEach
(()
=>
{
Vue
.
http
.
interceptors
.
push
(
interceptor
);
});
afterEach
(()
=>
{
Vue
.
http
.
interceptors
=
_
.
without
(
Vue
.
http
.
interceptors
,
interceptor
);
});
describe
(
'
closeIssue
'
,
()
=>
{
it
(
'
sets state as closed
'
,
(
done
)
=>
{
store
.
dispatch
(
'
closeIssue
'
,
{
notesData
:
{
closeIssuePath
:
''
}
})
.
then
(()
=>
{
expect
(
store
.
state
.
noteableData
.
state
).
toEqual
(
'
closed
'
);
done
();
})
.
catch
(
done
.
fail
);
});
});
describe
(
'
reopenIssue
'
,
()
=>
{
it
(
'
sets state as reopened
'
,
(
done
)
=>
{
store
.
dispatch
(
'
reopenIssue
'
,
{
notesData
:
{
reopenIssuePath
:
''
}
})
.
then
(()
=>
{
expect
(
store
.
state
.
noteableData
.
state
).
toEqual
(
'
reopened
'
);
done
();
})
.
catch
(
done
.
fail
);
});
});
});
describe
(
'
emitStateChangedEvent
'
,
()
=>
{
it
(
'
emits an event on the document
'
,
()
=>
{
document
.
addEventListener
(
'
issuable_vue_app:change
'
,
(
event
)
=>
{
expect
(
event
.
detail
.
data
).
toEqual
({
id
:
'
1
'
,
state
:
'
closed
'
});
expect
(
event
.
detail
.
isClosed
).
toEqual
(
false
);
});
store
.
dispatch
(
'
emitStateChangedEvent
'
,
{
id
:
'
1
'
,
state
:
'
closed
'
});
});
});
describe
(
'
toggleIssueLocalState
'
,
()
=>
{
it
(
'
sets issue state as closed
'
,
(
done
)
=>
{
testAction
(
actions
.
toggleIssueLocalState
,
'
closed
'
,
{},
[
{
type
:
'
CLOSE_ISSUE
'
,
payload
:
'
closed
'
},
],
done
);
});
it
(
'
sets issue state as reopened
'
,
(
done
)
=>
{
testAction
(
actions
.
toggleIssueLocalState
,
'
reopened
'
,
{},
[
{
type
:
'
REOPEN_ISSUE
'
,
payload
:
'
reopened
'
},
],
done
);
});
});
});
spec/javascripts/notes/stores/getters_spec.js
View file @
db04e3ee
...
...
@@ -55,4 +55,10 @@ describe('Getters Notes Store', () => {
expect
(
getters
.
getCurrentUserLastNote
(
state
)).
toEqual
(
individualNote
.
notes
[
0
]);
});
});
describe
(
'
issueState
'
,
()
=>
{
it
(
'
should return the issue state
'
,
()
=>
{
expect
(
getters
.
issueState
(
state
)).
toEqual
(
noteableDataMock
.
state
);
});
});
});
spec/services/merge_requests/build_service_spec.rb
View file @
db04e3ee
...
...
@@ -286,33 +286,43 @@ describe MergeRequests::BuildService do
end
end
context
'branch starts with JIRA-formatted external issue IID'
do
let
(
:source_branch
)
{
'EXMPL-12345'
}
describe
'with JIRA enabled'
do
before
do
allow
(
project
).
to
receive
(
:external_issue_tracker
).
and_return
(
true
)
allow
(
project
).
to
receive
(
:issues_enabled?
).
and_return
(
false
)
allow
(
project
).
to
receive
(
:external_issue_reference_pattern
).
and_return
(
IssueTrackerService
.
reference_pattern
)
end
it
'sets the title to the humanized branch title'
do
expect
(
merge_request
.
title
).
to
eq
(
'Resolve EXMPL-12345'
)
end
context
'branch does not start with JIRA-formatted external issue IID'
do
let
(
:source_branch
)
{
'test-branch'
}
it
'appends the closes text'
do
expect
(
merge_request
.
description
).
to
eq
(
'Closes EXMPL-12345'
)
it
'sets the title to the humanized branch title'
do
expect
(
merge_request
.
title
).
to
eq
(
'Test branch'
)
end
end
context
'
followed by hyphenated text
'
do
let
(
:source_branch
)
{
'EXMPL-12345
-fix-issue
'
}
context
'
branch starts with JIRA-formatted external issue IID
'
do
let
(
:source_branch
)
{
'EXMPL-12345'
}
it
'sets the title to the humanized branch title'
do
expect
(
merge_request
.
title
).
to
eq
(
'Resolve EXMPL-12345
"Fix issue"
'
)
expect
(
merge_request
.
title
).
to
eq
(
'Resolve EXMPL-12345'
)
end
it
'appends the closes text'
do
expect
(
merge_request
.
description
).
to
eq
(
'Closes EXMPL-12345'
)
end
context
'followed by hyphenated text'
do
let
(
:source_branch
)
{
'EXMPL-12345-fix-issue'
}
it
'sets the title to the humanized branch title'
do
expect
(
merge_request
.
title
).
to
eq
(
'Resolve EXMPL-12345 "Fix issue"'
)
end
it
'appends the closes text'
do
expect
(
merge_request
.
description
).
to
eq
(
'Closes EXMPL-12345'
)
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