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
0
Merge Requests
0
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
Léo-Paul Géneau
gitlab-ce
Commits
645593e5
Commit
645593e5
authored
May 05, 2017
by
Kushal Pandya
Committed by
Filipa Lacerda
May 05, 2017
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add instant comments support
parent
a5347fe5
Changes
16
Hide whitespace changes
Inline
Side-by-side
Showing
16 changed files
with
607 additions
and
65 deletions
+607
-65
app/assets/javascripts/behaviors/quick_submit.js
app/assets/javascripts/behaviors/quick_submit.js
+1
-1
app/assets/javascripts/lib/utils/common_utils.js
app/assets/javascripts/lib/utils/common_utils.js
+8
-0
app/assets/javascripts/notes.js
app/assets/javascripts/notes.js
+287
-58
app/assets/stylesheets/framework/animations.scss
app/assets/stylesheets/framework/animations.scss
+28
-0
app/assets/stylesheets/pages/notes.scss
app/assets/stylesheets/pages/notes.scss
+23
-0
app/views/discussions/_notes.html.haml
app/views/discussions/_notes.html.haml
+1
-0
app/views/projects/notes/_edit_form.html.haml
app/views/projects/notes/_edit_form.html.haml
+1
-1
changelogs/unreleased/27614-instant-comments.yml
changelogs/unreleased/27614-instant-comments.yml
+4
-0
features/steps/project/merge_requests.rb
features/steps/project/merge_requests.rb
+7
-0
features/steps/shared/note.rb
features/steps/shared/note.rb
+4
-0
spec/features/merge_requests/user_posts_notes_spec.rb
spec/features/merge_requests/user_posts_notes_spec.rb
+1
-0
spec/features/merge_requests/user_uses_slash_commands_spec.rb
.../features/merge_requests/user_uses_slash_commands_spec.rb
+1
-0
spec/javascripts/lib/utils/common_utils_spec.js
spec/javascripts/lib/utils/common_utils_spec.js
+11
-0
spec/javascripts/notes_spec.js
spec/javascripts/notes_spec.js
+224
-5
spec/support/features/issuable_slash_commands_shared_examples.rb
...pport/features/issuable_slash_commands_shared_examples.rb
+1
-0
spec/support/time_tracking_shared_examples.rb
spec/support/time_tracking_shared_examples.rb
+5
-0
No files found.
app/assets/javascripts/behaviors/quick_submit.js
View file @
645593e5
...
...
@@ -43,8 +43,8 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
const
$submitButton
=
$form
.
find
(
'
input[type=submit], button[type=submit]
'
);
if
(
!
$submitButton
.
attr
(
'
disabled
'
))
{
$submitButton
.
trigger
(
'
click
'
,
[
e
]);
$submitButton
.
disable
();
$form
.
submit
();
}
});
...
...
app/assets/javascripts/lib/utils/common_utils.js
View file @
645593e5
...
...
@@ -35,6 +35,14 @@
});
};
w
.
gl
.
utils
.
ajaxPost
=
function
(
url
,
data
)
{
return
$
.
ajax
({
type
:
'
POST
'
,
url
:
url
,
data
:
data
,
});
};
w
.
gl
.
utils
.
extractLast
=
function
(
term
)
{
return
this
.
split
(
term
).
pop
();
};
...
...
app/assets/javascripts/notes.js
View file @
645593e5
...
...
@@ -26,12 +26,13 @@ const normalizeNewlines = function(str) {
this
.
Notes
=
(
function
()
{
const
MAX_VISIBLE_COMMIT_LIST_COUNT
=
3
;
const
REGEX_SLASH_COMMANDS
=
/
\/\w
+/g
;
Notes
.
interval
=
null
;
function
Notes
(
notes_url
,
note_ids
,
last_fetched_at
,
view
)
{
this
.
updateTargetButtons
=
bind
(
this
.
updateTargetButtons
,
this
);
this
.
updateC
loseButton
=
bind
(
this
.
updateCloseButton
,
this
);
this
.
updateC
omment
=
bind
(
this
.
updateComment
,
this
);
this
.
visibilityChange
=
bind
(
this
.
visibilityChange
,
this
);
this
.
cancelDiscussionForm
=
bind
(
this
.
cancelDiscussionForm
,
this
);
this
.
addDiffNote
=
bind
(
this
.
addDiffNote
,
this
);
...
...
@@ -47,6 +48,7 @@ const normalizeNewlines = function(str) {
this
.
refresh
=
bind
(
this
.
refresh
,
this
);
this
.
keydownNoteText
=
bind
(
this
.
keydownNoteText
,
this
);
this
.
toggleCommitList
=
bind
(
this
.
toggleCommitList
,
this
);
this
.
postComment
=
bind
(
this
.
postComment
,
this
);
this
.
notes_url
=
notes_url
;
this
.
note_ids
=
note_ids
;
...
...
@@ -82,28 +84,19 @@ const normalizeNewlines = function(str) {
};
Notes
.
prototype
.
addBinding
=
function
()
{
// add note to UI after creation
$
(
document
).
on
(
"
ajax:success
"
,
"
.js-main-target-form
"
,
this
.
addNote
);
$
(
document
).
on
(
"
ajax:success
"
,
"
.js-discussion-note-form
"
,
this
.
addDiscussionNote
);
// catch note ajax errors
$
(
document
).
on
(
"
ajax:error
"
,
"
.js-main-target-form
"
,
this
.
addNoteError
);
// change note in UI after update
$
(
document
).
on
(
"
ajax:success
"
,
"
form.edit-note
"
,
this
.
updateNote
);
// Edit note link
$
(
document
).
on
(
"
click
"
,
"
.js-note-edit
"
,
this
.
showEditForm
.
bind
(
this
));
$
(
document
).
on
(
"
click
"
,
"
.note-edit-cancel
"
,
this
.
cancelEdit
);
// Reopen and close actions for Issue/MR combined with note form submit
$
(
document
).
on
(
"
click
"
,
"
.js-comment-button
"
,
this
.
updateCloseButton
);
$
(
document
).
on
(
"
click
"
,
"
.js-comment-submit-button
"
,
this
.
postComment
);
$
(
document
).
on
(
"
click
"
,
"
.js-comment-save-button
"
,
this
.
updateComment
);
$
(
document
).
on
(
"
keyup input
"
,
"
.js-note-text
"
,
this
.
updateTargetButtons
);
// resolve a discussion
$
(
document
).
on
(
'
click
'
,
'
.js-comment-resolve-button
'
,
this
.
resolveDiscussion
);
$
(
document
).
on
(
'
click
'
,
'
.js-comment-resolve-button
'
,
this
.
postComment
);
// remove a note (in general)
$
(
document
).
on
(
"
click
"
,
"
.js-note-delete
"
,
this
.
removeNote
);
// delete note attachment
$
(
document
).
on
(
"
click
"
,
"
.js-note-attachment-delete
"
,
this
.
removeAttachment
);
// reset main target form after submit
$
(
document
).
on
(
"
ajax:complete
"
,
"
.js-main-target-form
"
,
this
.
reenableTargetFormSubmitButton
);
$
(
document
).
on
(
"
ajax:success
"
,
"
.js-main-target-form
"
,
this
.
resetMainTargetForm
);
// reset main target form when clicking discard
$
(
document
).
on
(
"
click
"
,
"
.js-note-discard
"
,
this
.
resetMainTargetForm
);
// update the file name when an attachment is selected
...
...
@@ -120,20 +113,20 @@ const normalizeNewlines = function(str) {
$
(
document
).
on
(
"
visibilitychange
"
,
this
.
visibilityChange
);
// when issue status changes, we need to refresh data
$
(
document
).
on
(
"
issuable:change
"
,
this
.
refresh
);
// ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs.
$
(
document
).
on
(
"
ajax:success
"
,
"
.js-main-target-form
"
,
this
.
addNote
);
$
(
document
).
on
(
"
ajax:success
"
,
"
.js-discussion-note-form
"
,
this
.
addDiscussionNote
);
$
(
document
).
on
(
"
ajax:success
"
,
"
.js-main-target-form
"
,
this
.
resetMainTargetForm
);
$
(
document
).
on
(
"
ajax:complete
"
,
"
.js-main-target-form
"
,
this
.
reenableTargetFormSubmitButton
);
// when a key is clicked on the notes
return
$
(
document
).
on
(
"
keydown
"
,
"
.js-note-text
"
,
this
.
keydownNoteText
);
};
Notes
.
prototype
.
cleanBinding
=
function
()
{
$
(
document
).
off
(
"
ajax:success
"
,
"
.js-main-target-form
"
);
$
(
document
).
off
(
"
ajax:success
"
,
"
.js-discussion-note-form
"
);
$
(
document
).
off
(
"
ajax:success
"
,
"
form.edit-note
"
);
$
(
document
).
off
(
"
click
"
,
"
.js-note-edit
"
);
$
(
document
).
off
(
"
click
"
,
"
.note-edit-cancel
"
);
$
(
document
).
off
(
"
click
"
,
"
.js-note-delete
"
);
$
(
document
).
off
(
"
click
"
,
"
.js-note-attachment-delete
"
);
$
(
document
).
off
(
"
ajax:complete
"
,
"
.js-main-target-form
"
);
$
(
document
).
off
(
"
ajax:success
"
,
"
.js-main-target-form
"
);
$
(
document
).
off
(
"
click
"
,
"
.js-discussion-reply-button
"
);
$
(
document
).
off
(
"
click
"
,
"
.js-add-diff-note-button
"
);
$
(
document
).
off
(
"
visibilitychange
"
);
...
...
@@ -144,6 +137,9 @@ const normalizeNewlines = function(str) {
$
(
document
).
off
(
"
keydown
"
,
"
.js-note-text
"
);
$
(
document
).
off
(
'
click
'
,
'
.js-comment-resolve-button
'
);
$
(
document
).
off
(
"
click
"
,
'
.system-note-commit-list-toggler
'
);
$
(
document
).
off
(
"
ajax:success
"
,
"
.js-main-target-form
"
);
$
(
document
).
off
(
"
ajax:success
"
,
"
.js-discussion-note-form
"
);
$
(
document
).
off
(
"
ajax:complete
"
,
"
.js-main-target-form
"
);
};
Notes
.
initCommentTypeToggle
=
function
(
form
)
{
...
...
@@ -276,12 +272,8 @@ const normalizeNewlines = function(str) {
return
this
.
initRefresh
();
};
Notes
.
prototype
.
handle
CreateChange
s
=
function
(
noteEntity
)
{
Notes
.
prototype
.
handle
SlashCommand
s
=
function
(
noteEntity
)
{
var
votesBlock
;
if
(
typeof
noteEntity
===
'
undefined
'
)
{
return
;
}
if
(
noteEntity
.
commands_changes
)
{
if
(
'
merge
'
in
noteEntity
.
commands_changes
)
{
$
.
get
(
mrRefreshWidgetUrl
);
...
...
@@ -556,24 +548,29 @@ const normalizeNewlines = function(str) {
Adds new note to list.
*/
Notes
.
prototype
.
addNote
=
function
(
xhr
,
note
,
status
)
{
this
.
handleCreateChanges
(
note
);
Notes
.
prototype
.
addNote
=
function
(
$form
,
note
)
{
return
this
.
renderNote
(
note
);
};
Notes
.
prototype
.
addNoteError
=
function
(
xhr
,
note
,
status
)
{
return
new
Flash
(
'
Your comment could not be submitted! Please check your network connection and try again.
'
,
'
alert
'
,
this
.
parentTimeline
);
Notes
.
prototype
.
addNoteError
=
(
$form
)
=>
{
let
formParentTimeline
;
if
(
$form
.
hasClass
(
'
js-main-target-form
'
))
{
formParentTimeline
=
$form
.
parents
(
'
.timeline
'
);
}
else
if
(
$form
.
hasClass
(
'
js-discussion-note-form
'
))
{
formParentTimeline
=
$form
.
closest
(
'
.discussion-notes
'
).
find
(
'
.notes
'
);
}
return
new
Flash
(
'
Your comment could not be submitted! Please check your network connection and try again.
'
,
'
alert
'
,
formParentTimeline
);
};
Notes
.
prototype
.
updateNoteError
=
$parentTimeline
=>
new
Flash
(
'
Your comment could not be updated! Please check your network connection and try again.
'
);
/*
Called in response to the new note form being submitted
Adds new note to list.
*/
Notes
.
prototype
.
addDiscussionNote
=
function
(
xhr
,
note
,
status
)
{
var
$form
=
$
(
xhr
.
target
);
Notes
.
prototype
.
addDiscussionNote
=
function
(
$form
,
note
,
isNewDiffComment
)
{
if
(
$form
.
attr
(
'
data-resolve-all
'
)
!=
null
)
{
var
projectPath
=
$form
.
data
(
'
project-path
'
);
var
discussionId
=
$form
.
data
(
'
discussion-id
'
);
...
...
@@ -586,7 +583,9 @@ const normalizeNewlines = function(str) {
this
.
renderNote
(
note
,
$form
);
// cleanup after successfully creating a diff/discussion note
this
.
removeDiscussionNoteForm
(
$form
);
if
(
isNewDiffComment
)
{
this
.
removeDiscussionNoteForm
(
$form
);
}
};
/*
...
...
@@ -596,17 +595,18 @@ const normalizeNewlines = function(str) {
*/
Notes
.
prototype
.
updateNote
=
function
(
_xhr
,
noteEntity
,
_status
)
{
var
$
htm
l
,
$note_li
;
var
$
noteEntityE
l
,
$note_li
;
// Convert returned HTML to a jQuery object so we can modify it further
$html
=
$
(
noteEntity
.
html
);
$noteEntityEl
=
$
(
noteEntity
.
html
);
$noteEntityEl
.
addClass
(
'
fade-in-full
'
);
this
.
revertNoteEditForm
();
gl
.
utils
.
localTimeAgo
(
$
(
'
.js-timeago
'
,
$
htm
l
));
$
htm
l
.
renderGFM
();
$
htm
l
.
find
(
'
.js-task-list-container
'
).
taskList
(
'
enable
'
);
gl
.
utils
.
localTimeAgo
(
$
(
'
.js-timeago
'
,
$
noteEntityE
l
));
$
noteEntityE
l
.
renderGFM
();
$
noteEntityE
l
.
find
(
'
.js-task-list-container
'
).
taskList
(
'
enable
'
);
// Find the note's `li` element by ID and replace it with the updated HTML
$note_li
=
$
(
'
.note-row-
'
+
noteEntity
.
id
);
$note_li
.
replaceWith
(
$
htm
l
);
$note_li
.
replaceWith
(
$
noteEntityE
l
);
if
(
typeof
gl
.
diffNotesCompileComponents
!==
'
undefined
'
)
{
gl
.
diffNotesCompileComponents
();
...
...
@@ -698,7 +698,7 @@ const normalizeNewlines = function(str) {
var
$editForm
=
$
(
selector
);
$editForm
.
insertBefore
(
'
.notes-form
'
);
$editForm
.
find
(
'
.js-comment-button
'
).
enable
();
$editForm
.
find
(
'
.js-comment-
save-
button
'
).
enable
();
$editForm
.
find
(
'
.js-finish-edit-warning
'
).
hide
();
};
...
...
@@ -982,14 +982,6 @@ const normalizeNewlines = function(str) {
return
this
.
refresh
();
};
Notes
.
prototype
.
updateCloseButton
=
function
(
e
)
{
var
closebtn
,
form
,
textarea
;
textarea
=
$
(
e
.
target
);
form
=
textarea
.
parents
(
'
form
'
);
closebtn
=
form
.
find
(
'
.js-note-target-close
'
);
return
closebtn
.
text
(
closebtn
.
data
(
'
original-text
'
));
};
Notes
.
prototype
.
updateTargetButtons
=
function
(
e
)
{
var
closebtn
,
closetext
,
discardbtn
,
form
,
reopenbtn
,
reopentext
,
textarea
;
textarea
=
$
(
e
.
target
);
...
...
@@ -1078,17 +1070,6 @@ const normalizeNewlines = function(str) {
return
this
.
notesCountBadge
.
text
(
parseInt
(
this
.
notesCountBadge
.
text
(),
10
)
+
updateCount
);
};
Notes
.
prototype
.
resolveDiscussion
=
function
()
{
var
$this
=
$
(
this
);
var
discussionId
=
$this
.
attr
(
'
data-discussion-id
'
);
$this
.
closest
(
'
form
'
)
.
attr
(
'
data-discussion-id
'
,
discussionId
)
.
attr
(
'
data-resolve-all
'
,
'
true
'
)
.
attr
(
'
data-project-path
'
,
$this
.
attr
(
'
data-project-path
'
));
};
Notes
.
prototype
.
toggleCommitList
=
function
(
e
)
{
const
$element
=
$
(
e
.
currentTarget
);
const
$closestSystemCommitList
=
$element
.
siblings
(
'
.system-note-commit-list
'
);
...
...
@@ -1137,7 +1118,7 @@ const normalizeNewlines = function(str) {
Notes
.
animateAppendNote
=
function
(
noteHtml
,
$notesList
)
{
const
$note
=
$
(
noteHtml
);
$note
.
addClass
(
'
fade-in
'
).
renderGFM
();
$note
.
addClass
(
'
fade-in
-full
'
).
renderGFM
();
$notesList
.
append
(
$note
);
return
$note
;
};
...
...
@@ -1150,6 +1131,254 @@ const normalizeNewlines = function(str) {
return
$updatedNote
;
};
/**
* Get data from Form attributes to use for saving/submitting comment.
*/
Notes
.
prototype
.
getFormData
=
function
(
$form
)
{
return
{
formData
:
$form
.
serialize
(),
formContent
:
$form
.
find
(
'
.js-note-text
'
).
val
(),
formAction
:
$form
.
attr
(
'
action
'
),
};
};
/**
* Identify if comment has any slash commands
*/
Notes
.
prototype
.
hasSlashCommands
=
function
(
formContent
)
{
return
REGEX_SLASH_COMMANDS
.
test
(
formContent
);
};
/**
* Remove slash commands and leave comment with pure message
*/
Notes
.
prototype
.
stripSlashCommands
=
function
(
formContent
)
{
return
formContent
.
replace
(
REGEX_SLASH_COMMANDS
,
''
).
trim
();
};
/**
* Create placeholder note DOM element populated with comment body
* that we will show while comment is being posted.
* Once comment is _actually_ posted on server, we will have final element
* in response that we will show in place of this temporary element.
*/
Notes
.
prototype
.
createPlaceholderNote
=
function
({
formContent
,
uniqueId
,
isDiscussionNote
,
currentUsername
,
currentUserFullname
})
{
const
discussionClass
=
isDiscussionNote
?
'
discussion
'
:
''
;
const
$tempNote
=
$
(
`<li id="
${
uniqueId
}
" class="note being-posted fade-in-half timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<a href="/
${
currentUsername
}
"><span class="dummy-avatar"></span></a>
</div>
<div class="timeline-content
${
discussionClass
}
">
<div class="note-header">
<div class="note-header-info">
<a href="/
${
currentUsername
}
">
<span class="hidden-xs">
${
currentUserFullname
}
</span>
<span class="note-headline-light">@
${
currentUsername
}
</span>
</a>
<span class="note-headline-light">
<i class="fa fa-spinner fa-spin" aria-label="Comment is being posted" aria-hidden="true"></i>
</span>
</div>
</div>
<div class="note-body">
<div class="note-text">
<p>
${
formContent
}
</p>
</div>
</div>
</div>
</div>
</li>`
);
return
$tempNote
;
};
/**
* This method does following tasks step-by-step whenever a new comment
* is submitted by user (both main thread comments as well as discussion comments).
*
* 1) Get Form metadata
* 2) Identify comment type; a) Main thread b) Discussion thread c) Discussion resolve
* 3) Build temporary placeholder element (using `createPlaceholderNote`)
* 4) Show placeholder note on UI
* 5) Perform network request to submit the note using `gl.utils.ajaxPost`
* a) If request is successfully completed
* 1. Remove placeholder element
* 2. Show submitted Note element
* 3. Perform post-submit errands
* a. Mark discussion as resolved if comment submission was for resolve.
* b. Reset comment form to original state.
* b) If request failed
* 1. Remove placeholder element
* 2. Show error Flash message about failure
*/
Notes
.
prototype
.
postComment
=
function
(
e
)
{
e
.
preventDefault
();
// Get Form metadata
const
$submitBtn
=
$
(
e
.
target
);
let
$form
=
$submitBtn
.
parents
(
'
form
'
);
const
$closeBtn
=
$form
.
find
(
'
.js-note-target-close
'
);
const
isDiscussionNote
=
$submitBtn
.
parent
().
find
(
'
li.droplab-item-selected
'
).
attr
(
'
id
'
)
===
'
discussion
'
;
const
isMainForm
=
$form
.
hasClass
(
'
js-main-target-form
'
);
const
isDiscussionForm
=
$form
.
hasClass
(
'
js-discussion-note-form
'
);
const
isDiscussionResolve
=
$submitBtn
.
hasClass
(
'
js-comment-resolve-button
'
);
const
{
formData
,
formContent
,
formAction
}
=
this
.
getFormData
(
$form
);
const
uniqueId
=
_
.
uniqueId
(
'
tempNote_
'
);
let
$notesContainer
;
let
tempFormContent
;
// Get reference to notes container based on type of comment
if
(
isDiscussionForm
)
{
$notesContainer
=
$form
.
parent
(
'
.discussion-notes
'
).
find
(
'
.notes
'
);
}
else
if
(
isMainForm
)
{
$notesContainer
=
$
(
'
ul.main-notes-list
'
);
}
// If comment is to resolve discussion, disable submit buttons while
// comment posting is finished.
if
(
isDiscussionResolve
)
{
$submitBtn
.
disable
();
$form
.
find
(
'
.js-comment-submit-button
'
).
disable
();
}
tempFormContent
=
formContent
;
if
(
this
.
hasSlashCommands
(
formContent
))
{
tempFormContent
=
this
.
stripSlashCommands
(
formContent
);
}
if
(
tempFormContent
)
{
// Show placeholder note
$notesContainer
.
append
(
this
.
createPlaceholderNote
({
formContent
:
tempFormContent
,
uniqueId
,
isDiscussionNote
,
currentUsername
:
gon
.
current_username
,
currentUserFullname
:
gon
.
current_user_fullname
,
}));
}
// Clear the form textarea
if
(
$notesContainer
.
length
)
{
if
(
isMainForm
)
{
this
.
resetMainTargetForm
(
e
);
}
else
if
(
isDiscussionForm
)
{
this
.
removeDiscussionNoteForm
(
$form
);
}
}
/* eslint-disable promise/catch-or-return */
// Make request to submit comment on server
gl
.
utils
.
ajaxPost
(
formAction
,
formData
)
.
then
((
note
)
=>
{
// Submission successful! remove placeholder
$notesContainer
.
find
(
`#
${
uniqueId
}
`
).
remove
();
// Check if this was discussion comment
if
(
isDiscussionForm
)
{
// Remove flash-container
$notesContainer
.
find
(
'
.flash-container
'
).
remove
();
// If comment intends to resolve discussion, do the same.
if
(
isDiscussionResolve
)
{
$form
.
attr
(
'
data-discussion-id
'
,
$submitBtn
.
data
(
'
discussion-id
'
))
.
attr
(
'
data-resolve-all
'
,
'
true
'
)
.
attr
(
'
data-project-path
'
,
$submitBtn
.
data
(
'
project-path
'
));
}
// Show final note element on UI
this
.
addDiscussionNote
(
$form
,
note
,
$notesContainer
.
length
===
0
);
// append flash-container to the Notes list
if
(
$notesContainer
.
length
)
{
$notesContainer
.
append
(
'
<div class="flash-container" style="display: none;"></div>
'
);
}
}
else
if
(
isMainForm
)
{
// Check if this was main thread comment
// Show final note element on UI and perform form and action buttons cleanup
this
.
addNote
(
$form
,
note
);
this
.
reenableTargetFormSubmitButton
(
e
);
}
if
(
note
.
commands_changes
)
{
this
.
handleSlashCommands
(
note
);
}
$form
.
trigger
(
'
ajax:success
'
,
[
note
]);
}).
fail
(()
=>
{
// Submission failed, remove placeholder note and show Flash error message
$notesContainer
.
find
(
`#
${
uniqueId
}
`
).
remove
();
// Show form again on UI on failure
if
(
isDiscussionForm
&&
$notesContainer
.
length
)
{
const
replyButton
=
$notesContainer
.
parent
().
find
(
'
.js-discussion-reply-button
'
);
$
.
proxy
(
this
.
replyToDiscussionNote
,
replyButton
[
0
],
{
target
:
replyButton
[
0
]
}).
call
();
$form
=
$notesContainer
.
parent
().
find
(
'
form
'
);
}
$form
.
find
(
'
.js-note-text
'
).
val
(
formContent
);
this
.
reenableTargetFormSubmitButton
(
e
);
this
.
addNoteError
(
$form
);
});
return
$closeBtn
.
text
(
$closeBtn
.
data
(
'
original-text
'
));
};
/**
* This method does following tasks step-by-step whenever an existing comment
* is updated by user (both main thread comments as well as discussion comments).
*
* 1) Get Form metadata
* 2) Update note element with new content
* 3) Perform network request to submit the updated note using `gl.utils.ajaxPost`
* a) If request is successfully completed
* 1. Show submitted Note element
* b) If request failed
* 1. Revert Note element to original content
* 2. Show error Flash message about failure
*/
Notes
.
prototype
.
updateComment
=
function
(
e
)
{
e
.
preventDefault
();
// Get Form metadata
const
$submitBtn
=
$
(
e
.
target
);
const
$form
=
$submitBtn
.
parents
(
'
form
'
);
const
$closeBtn
=
$form
.
find
(
'
.js-note-target-close
'
);
const
$editingNote
=
$form
.
parents
(
'
.note.is-editing
'
);
const
$noteBody
=
$editingNote
.
find
(
'
.js-task-list-container
'
);
const
$noteBodyText
=
$noteBody
.
find
(
'
.note-text
'
);
const
{
formData
,
formContent
,
formAction
}
=
this
.
getFormData
(
$form
);
// Cache original comment content
const
cachedNoteBodyText
=
$noteBodyText
.
html
();
// Show updated comment content temporarily
$noteBodyText
.
html
(
formContent
);
$editingNote
.
removeClass
(
'
is-editing
'
).
addClass
(
'
being-posted fade-in-half
'
);
$editingNote
.
find
(
'
.note-headline-meta a
'
).
html
(
'
<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>
'
);
/* eslint-disable promise/catch-or-return */
// Make request to update comment on server
gl
.
utils
.
ajaxPost
(
formAction
,
formData
)
.
then
((
note
)
=>
{
// Submission successful! render final note element
this
.
updateNote
(
null
,
note
,
null
);
})
.
fail
(()
=>
{
// Submission failed, revert back to original note
$noteBodyText
.
html
(
cachedNoteBodyText
);
$editingNote
.
removeClass
(
'
being-posted fade-in
'
);
$editingNote
.
find
(
'
.fa.fa-spinner
'
).
remove
();
// Show Flash message about failure
this
.
updateNoteError
();
});
return
$closeBtn
.
text
(
$closeBtn
.
data
(
'
original-text
'
));
};
return
Notes
;
})();
}).
call
(
window
);
app/assets/stylesheets/framework/animations.scss
View file @
645593e5
...
...
@@ -159,3 +159,31 @@ a {
.fade-in
{
animation
:
fadeIn
$fade-in-duration
1
;
}
@keyframes
fadeInHalf
{
0
%
{
opacity
:
0
;
}
100
%
{
opacity
:
0
.5
;
}
}
.fade-in-half
{
animation
:
fadeInHalf
$fade-in-duration
1
;
}
@keyframes
fadeInFull
{
0
%
{
opacity
:
0
.5
;
}
100
%
{
opacity
:
1
;
}
}
.fade-in-full
{
animation
:
fadeInFull
$fade-in-duration
1
;
}
app/assets/stylesheets/pages/notes.scss
View file @
645593e5
...
...
@@ -57,6 +57,25 @@ ul.notes {
position
:
relative
;
border-bottom
:
1px
solid
$white-normal
;
&
.being-posted
{
pointer-events
:
none
;
opacity
:
0
.5
;
.dummy-avatar
{
display
:
inline-block
;
height
:
40px
;
width
:
40px
;
border-radius
:
50%
;
background-color
:
$kdb-border
;
border
:
1px
solid
darken
(
$kdb-border
,
25%
);
}
.note-headline-light
,
.fa-spinner
{
margin-left
:
3px
;
}
}
&
.note-discussion
{
&
.timeline-entry
{
padding
:
14px
10px
;
...
...
@@ -687,6 +706,10 @@ ul.notes {
}
}
.discussion-notes
.flash-container
{
margin-bottom
:
0
;
}
// Merge request notes in diffs
.diff-file
{
// Diff is side by side
...
...
app/views/discussions/_notes.html.haml
View file @
645593e5
.discussion-notes
%ul
.notes
{
data:
{
discussion_id:
discussion
.
id
}
}
=
render
partial:
"shared/notes/note"
,
collection:
discussion
.
notes
,
as: :note
.flash-container
-
if
current_user
.discussion-reply-holder
...
...
app/views/projects/notes/_edit_form.html.haml
View file @
645593e5
...
...
@@ -9,6 +9,6 @@
.note-form-actions.clearfix
.settings-message.note-edit-warning.js-finish-edit-warning
Finish editing this message first!
=
submit_tag
'Save comment'
,
class:
'btn btn-nr btn-save js-comment-button'
=
submit_tag
'Save comment'
,
class:
'btn btn-nr btn-save js-comment-
save-
button'
%button
.btn.btn-nr.btn-cancel.note-edit-cancel
{
type:
'button'
}
Cancel
changelogs/unreleased/27614-instant-comments.yml
0 → 100644
View file @
645593e5
---
title
:
Add support for instantly updating comments
merge_request
:
10760
author
:
features/steps/project/merge_requests.rb
View file @
645593e5
...
...
@@ -458,6 +458,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
click_button
"Comment"
end
wait_for_ajax
page
.
within
".files>div:nth-child(2) .note-body > .note-text"
do
expect
(
page
).
to
have_content
"Line is correct"
end
...
...
@@ -470,6 +472,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
fill_in
"note_note"
,
with:
"Line is wrong on here"
click_button
"Comment"
end
wait_for_ajax
end
step
'I should still see a comment like "Line is correct" in the second file'
do
...
...
@@ -574,6 +578,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
fill_in
"note_note"
,
with:
message
click_button
"Comment"
end
wait_for_ajax
page
.
within
(
".notes_holder"
,
visible:
true
)
do
expect
(
page
).
to
have_content
message
end
...
...
features/steps/shared/note.rb
View file @
645593e5
...
...
@@ -24,6 +24,8 @@ module SharedNote
fill_in
"note[note]"
,
with:
"XML attached"
click_button
"Comment"
end
wait_for_ajax
end
step
'I preview a comment text like "Bug fixed :smile:"'
do
...
...
@@ -37,6 +39,8 @@ module SharedNote
page
.
within
(
".js-main-target-form"
)
do
click_button
"Comment"
end
wait_for_ajax
end
step
'I write a comment like ":+1: Nice"'
do
...
...
spec/features/merge_requests/user_posts_notes_spec.rb
View file @
645593e5
...
...
@@ -98,6 +98,7 @@ describe 'Merge requests > User posts notes', :js do
find
(
'.btn-save'
).
click
end
wait_for_ajax
find
(
'.note'
).
hover
find
(
'.js-note-edit'
).
click
...
...
spec/features/merge_requests/user_uses_slash_commands_spec.rb
View file @
645593e5
...
...
@@ -160,6 +160,7 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do
it
'changes target branch from a note'
do
write_note
(
"message start
\n
/target_branch merge-test
\n
message end."
)
wait_for_ajax
expect
(
page
).
not_to
have_content
(
'/target_branch'
)
expect
(
page
).
to
have_content
(
'message start'
)
expect
(
page
).
to
have_content
(
'message end.'
)
...
...
spec/javascripts/lib/utils/common_utils_spec.js
View file @
645593e5
...
...
@@ -362,5 +362,16 @@ require('~/lib/utils/common_utils');
gl
.
utils
.
setCiStatusFavicon
(
BUILD_URL
);
});
});
describe
(
'
gl.utils.ajaxPost
'
,
()
=>
{
it
(
'
should perform `$.ajax` call and do `POST` request
'
,
()
=>
{
const
requestURL
=
'
/some/random/api
'
;
const
data
=
{
keyname
:
'
value
'
};
const
ajaxSpy
=
spyOn
(
$
,
'
ajax
'
).
and
.
callFake
(()
=>
{});
gl
.
utils
.
ajaxPost
(
requestURL
,
data
);
expect
(
ajaxSpy
.
calls
.
allArgs
()[
0
][
0
].
type
).
toEqual
(
'
POST
'
);
});
});
});
})();
spec/javascripts/notes_spec.js
View file @
645593e5
...
...
@@ -26,7 +26,7 @@ import '~/notes';
describe
(
'
task lists
'
,
function
()
{
beforeEach
(
function
()
{
$
(
'
form
'
).
on
(
'
submit
'
,
function
(
e
)
{
$
(
'
.js-comment-button
'
).
on
(
'
click
'
,
function
(
e
)
{
e
.
preventDefault
();
});
this
.
notes
=
new
Notes
();
...
...
@@ -60,9 +60,12 @@ import '~/notes';
reset
:
function
()
{}
});
$
(
'
form
'
).
on
(
'
submit
'
,
function
(
e
)
{
$
(
'
.js-comment-button
'
).
on
(
'
click
'
,
(
e
)
=>
{
const
$form
=
$
(
this
);
e
.
preventDefault
();
$
(
'
.js-main-target-form
'
).
trigger
(
'
ajax:success
'
);
this
.
notes
.
addNote
(
$form
);
this
.
notes
.
reenableTargetFormSubmitButton
(
e
);
this
.
notes
.
resetMainTargetForm
(
e
);
});
});
...
...
@@ -238,8 +241,8 @@ import '~/notes';
$resultantNote
=
Notes
.
animateAppendNote
(
noteHTML
,
$notesList
);
});
it
(
'
should have `fade-in` class
'
,
()
=>
{
expect
(
$resultantNote
.
hasClass
(
'
fade-in
'
)).
toEqual
(
true
);
it
(
'
should have `fade-in
-full
` class
'
,
()
=>
{
expect
(
$resultantNote
.
hasClass
(
'
fade-in
-full
'
)).
toEqual
(
true
);
});
it
(
'
should append note to the notes list
'
,
()
=>
{
...
...
@@ -269,5 +272,221 @@ import '~/notes';
expect
(
$note
.
replaceWith
).
toHaveBeenCalledWith
(
$updatedNote
);
});
});
describe
(
'
getFormData
'
,
()
=>
{
it
(
'
should return form metadata object from form reference
'
,
()
=>
{
this
.
notes
=
new
Notes
();
const
$form
=
$
(
'
form
'
);
const
sampleComment
=
'
foobar
'
;
$form
.
find
(
'
textarea.js-note-text
'
).
val
(
sampleComment
);
const
{
formData
,
formContent
,
formAction
}
=
this
.
notes
.
getFormData
(
$form
);
expect
(
formData
.
indexOf
(
sampleComment
)
>
-
1
).
toBe
(
true
);
expect
(
formContent
).
toEqual
(
sampleComment
);
expect
(
formAction
).
toEqual
(
$form
.
attr
(
'
action
'
));
});
});
describe
(
'
hasSlashCommands
'
,
()
=>
{
beforeEach
(()
=>
{
this
.
notes
=
new
Notes
();
});
it
(
'
should return true when comment has slash commands
'
,
()
=>
{
const
sampleComment
=
'
/wip /milestone %1.0 /merge /unassign Merging this
'
;
const
hasSlashCommands
=
this
.
notes
.
hasSlashCommands
(
sampleComment
);
expect
(
hasSlashCommands
).
toBeTruthy
();
});
it
(
'
should return false when comment does NOT have any slash commands
'
,
()
=>
{
const
sampleComment
=
'
Looking good, Awesome!
'
;
const
hasSlashCommands
=
this
.
notes
.
hasSlashCommands
(
sampleComment
);
expect
(
hasSlashCommands
).
toBeFalsy
();
});
});
describe
(
'
stripSlashCommands
'
,
()
=>
{
const
REGEX_SLASH_COMMANDS
=
/
\/\w
+/g
;
it
(
'
should strip slash commands from the comment
'
,
()
=>
{
this
.
notes
=
new
Notes
();
const
sampleComment
=
'
/wip /milestone %1.0 /merge /unassign Merging this
'
;
const
stripedComment
=
this
.
notes
.
stripSlashCommands
(
sampleComment
);
expect
(
REGEX_SLASH_COMMANDS
.
test
(
stripedComment
)).
toBeFalsy
();
});
});
describe
(
'
createPlaceholderNote
'
,
()
=>
{
const
sampleComment
=
'
foobar
'
;
const
uniqueId
=
'
b1234-a4567
'
;
const
currentUsername
=
'
root
'
;
const
currentUserFullname
=
'
Administrator
'
;
beforeEach
(()
=>
{
this
.
notes
=
new
Notes
();
});
it
(
'
should return constructed placeholder element for regular note based on form contents
'
,
()
=>
{
const
$tempNote
=
this
.
notes
.
createPlaceholderNote
({
formContent
:
sampleComment
,
uniqueId
,
isDiscussionNote
:
false
,
currentUsername
,
currentUserFullname
});
const
$tempNoteHeader
=
$tempNote
.
find
(
'
.note-header
'
);
expect
(
$tempNote
.
prop
(
'
nodeName
'
)).
toEqual
(
'
LI
'
);
expect
(
$tempNote
.
attr
(
'
id
'
)).
toEqual
(
uniqueId
);
$tempNote
.
find
(
'
.timeline-icon > a, .note-header-info > a
'
).
each
(
function
()
{
expect
(
$
(
this
).
attr
(
'
href
'
)).
toEqual
(
`/
${
currentUsername
}
`
);
});
expect
(
$tempNote
.
find
(
'
.timeline-content
'
).
hasClass
(
'
discussion
'
)).
toBeFalsy
();
expect
(
$tempNoteHeader
.
find
(
'
.hidden-xs
'
).
text
().
trim
()).
toEqual
(
currentUserFullname
);
expect
(
$tempNoteHeader
.
find
(
'
.note-headline-light
'
).
text
().
trim
()).
toEqual
(
`@
${
currentUsername
}
`
);
expect
(
$tempNote
.
find
(
'
.note-body .note-text
'
).
text
().
trim
()).
toEqual
(
sampleComment
);
});
it
(
'
should return constructed placeholder element for discussion note based on form contents
'
,
()
=>
{
const
$tempNote
=
this
.
notes
.
createPlaceholderNote
({
formContent
:
sampleComment
,
uniqueId
,
isDiscussionNote
:
true
,
currentUsername
,
currentUserFullname
});
expect
(
$tempNote
.
prop
(
'
nodeName
'
)).
toEqual
(
'
LI
'
);
expect
(
$tempNote
.
find
(
'
.timeline-content
'
).
hasClass
(
'
discussion
'
)).
toBeTruthy
();
});
});
describe
(
'
postComment & updateComment
'
,
()
=>
{
const
sampleComment
=
'
foo
'
;
const
updatedComment
=
'
bar
'
;
const
note
=
{
id
:
1234
,
html
:
`<li class="note note-row-1234 timeline-entry" id="note_1234">
<div class="note-text">
${
sampleComment
}
</div>
</li>`
,
note
:
sampleComment
,
valid
:
true
};
let
$form
;
let
$notesContainer
;
beforeEach
(()
=>
{
this
.
notes
=
new
Notes
();
window
.
gon
.
current_username
=
'
root
'
;
window
.
gon
.
current_user_fullname
=
'
Administrator
'
;
$form
=
$
(
'
form
'
);
$notesContainer
=
$
(
'
ul.main-notes-list
'
);
$form
.
find
(
'
textarea.js-note-text
'
).
val
(
sampleComment
);
$
(
'
.js-comment-button
'
).
click
();
});
it
(
'
should show placeholder note while new comment is being posted
'
,
()
=>
{
expect
(
$notesContainer
.
find
(
'
.note.being-posted
'
).
length
>
0
).
toEqual
(
true
);
});
it
(
'
should remove placeholder note when new comment is done posting
'
,
()
=>
{
spyOn
(
$
,
'
ajax
'
).
and
.
callFake
((
options
)
=>
{
options
.
success
(
note
);
expect
(
$notesContainer
.
find
(
'
.note.being-posted
'
).
length
).
toEqual
(
0
);
});
});
it
(
'
should show actual note element when new comment is done posting
'
,
()
=>
{
spyOn
(
$
,
'
ajax
'
).
and
.
callFake
((
options
)
=>
{
options
.
success
(
note
);
expect
(
$notesContainer
.
find
(
`#
${
note
.
id
}
`
).
length
>
0
).
toEqual
(
true
);
});
});
it
(
'
should reset Form when new comment is done posting
'
,
()
=>
{
spyOn
(
$
,
'
ajax
'
).
and
.
callFake
((
options
)
=>
{
options
.
success
(
note
);
expect
(
$form
.
find
(
'
textarea.js-note-text
'
)).
toEqual
(
''
);
});
});
it
(
'
should trigger ajax:success event on Form when new comment is done posting
'
,
()
=>
{
spyOn
(
$
,
'
ajax
'
).
and
.
callFake
((
options
)
=>
{
options
.
success
(
note
);
spyOn
(
$form
,
'
trigger
'
);
expect
(
$form
.
trigger
).
toHaveBeenCalledWith
(
'
ajax:success
'
,
[
note
]);
});
});
it
(
'
should show flash error message when new comment failed to be posted
'
,
()
=>
{
spyOn
(
$
,
'
ajax
'
).
and
.
callFake
((
options
)
=>
{
options
.
error
();
expect
(
$notesContainer
.
parent
().
find
(
'
.flash-container .flash-text
'
).
is
(
'
:visible
'
)).
toEqual
(
true
);
});
});
it
(
'
should refill form textarea with original comment content when new comment failed to be posted
'
,
()
=>
{
spyOn
(
$
,
'
ajax
'
).
and
.
callFake
((
options
)
=>
{
options
.
error
();
expect
(
$form
.
find
(
'
textarea.js-note-text
'
)).
toEqual
(
sampleComment
);
});
});
it
(
'
should show updated comment as _actively being posted_ while comment being updated
'
,
()
=>
{
spyOn
(
$
,
'
ajax
'
).
and
.
callFake
((
options
)
=>
{
options
.
success
(
note
);
const
$noteEl
=
$notesContainer
.
find
(
`#note_
${
note
.
id
}
`
);
$noteEl
.
find
(
'
.js-note-edit
'
).
click
();
$noteEl
.
find
(
'
textarea.js-note-text
'
).
val
(
updatedComment
);
$noteEl
.
find
(
'
.js-comment-save-button
'
).
click
();
expect
(
$noteEl
.
hasClass
(
'
.being-posted
'
)).
toEqual
(
true
);
expect
(
$noteEl
.
find
(
'
.note-text
'
).
text
()).
toEqual
(
updatedComment
);
});
});
it
(
'
should show updated comment when comment update is done posting
'
,
()
=>
{
spyOn
(
$
,
'
ajax
'
).
and
.
callFake
((
options
)
=>
{
options
.
success
(
note
);
const
$noteEl
=
$notesContainer
.
find
(
`#note_
${
note
.
id
}
`
);
$noteEl
.
find
(
'
.js-note-edit
'
).
click
();
$noteEl
.
find
(
'
textarea.js-note-text
'
).
val
(
updatedComment
);
$noteEl
.
find
(
'
.js-comment-save-button
'
).
click
();
spyOn
(
$
,
'
ajax
'
).
and
.
callFake
((
updateOptions
)
=>
{
const
updatedNote
=
Object
.
assign
({},
note
);
updatedNote
.
note
=
updatedComment
;
updatedNote
.
html
=
`<li class="note note-row-1234 timeline-entry" id="note_1234">
<div class="note-text">
${
updatedComment
}
</div>
</li>`
;
updateOptions
.
success
(
updatedNote
);
const
$updatedNoteEl
=
$notesContainer
.
find
(
`#note_
${
updatedNote
.
id
}
`
);
expect
(
$updatedNoteEl
.
hasClass
(
'
.being-posted
'
)).
toEqual
(
false
);
// Remove being-posted visuals
expect
(
$updatedNoteEl
.
find
(
'
note-text
'
).
text
().
trim
()).
toEqual
(
updatedComment
);
// Verify if comment text updated
});
});
});
it
(
'
should show flash error message when comment failed to be updated
'
,
()
=>
{
spyOn
(
$
,
'
ajax
'
).
and
.
callFake
((
options
)
=>
{
options
.
success
(
note
);
const
$noteEl
=
$notesContainer
.
find
(
`#note_
${
note
.
id
}
`
);
$noteEl
.
find
(
'
.js-note-edit
'
).
click
();
$noteEl
.
find
(
'
textarea.js-note-text
'
).
val
(
updatedComment
);
$noteEl
.
find
(
'
.js-comment-save-button
'
).
click
();
spyOn
(
$
,
'
ajax
'
).
and
.
callFake
((
updateOptions
)
=>
{
updateOptions
.
error
();
const
$updatedNoteEl
=
$notesContainer
.
find
(
`#note_
${
note
.
id
}
`
);
expect
(
$updatedNoteEl
.
hasClass
(
'
.being-posted
'
)).
toEqual
(
false
);
// Remove being-posted visuals
expect
(
$updatedNoteEl
.
find
(
'
note-text
'
).
text
().
trim
()).
toEqual
(
sampleComment
);
// See if comment reverted back to original
expect
(
$notesContainer
.
parent
().
find
(
'
.flash-container .flash-text
'
).
is
(
'
:visible
'
)).
toEqual
(
true
);
// Flash error message shown
});
});
});
});
});
}).
call
(
window
);
spec/support/features/issuable_slash_commands_shared_examples.rb
View file @
645593e5
...
...
@@ -58,6 +58,7 @@ shared_examples 'issuable record that supports slash commands in its description
expect
(
page
).
not_to
have_content
'/label ~bug'
expect
(
page
).
not_to
have_content
'/milestone %"ASAP"'
wait_for_ajax
issuable
.
reload
note
=
issuable
.
notes
.
user
.
first
...
...
spec/support/time_tracking_shared_examples.rb
View file @
645593e5
...
...
@@ -8,6 +8,7 @@ shared_examples 'issuable time tracker' do
it
'updates the sidebar component when estimate is added'
do
submit_time
(
'/estimate 3w 1d 1h'
)
wait_for_ajax
page
.
within
'.time-tracking-estimate-only-pane'
do
expect
(
page
).
to
have_content
'3w 1d 1h'
end
...
...
@@ -16,6 +17,7 @@ shared_examples 'issuable time tracker' do
it
'updates the sidebar component when spent is added'
do
submit_time
(
'/spend 3w 1d 1h'
)
wait_for_ajax
page
.
within
'.time-tracking-spend-only-pane'
do
expect
(
page
).
to
have_content
'3w 1d 1h'
end
...
...
@@ -25,6 +27,7 @@ shared_examples 'issuable time tracker' do
submit_time
(
'/estimate 3w 1d 1h'
)
submit_time
(
'/spend 3w 1d 1h'
)
wait_for_ajax
page
.
within
'.time-tracking-comparison-pane'
do
expect
(
page
).
to
have_content
'3w 1d 1h'
end
...
...
@@ -34,6 +37,7 @@ shared_examples 'issuable time tracker' do
submit_time
(
'/estimate 3w 1d 1h'
)
submit_time
(
'/remove_estimate'
)
wait_for_ajax
page
.
within
'#issuable-time-tracker'
do
expect
(
page
).
to
have_content
'No estimate or time spent'
end
...
...
@@ -43,6 +47,7 @@ shared_examples 'issuable time tracker' do
submit_time
(
'/spend 3w 1d 1h'
)
submit_time
(
'/remove_time_spent'
)
wait_for_ajax
page
.
within
'#issuable-time-tracker'
do
expect
(
page
).
to
have_content
'No estimate or time spent'
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