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
cf34b07e
Commit
cf34b07e
authored
May 03, 2017
by
Eric Eastwood
Committed by
Jacob Schatz
May 03, 2017
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add real-time note edits
🐿
parent
e14ca539
Changes
9
Show whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
320 additions
and
131 deletions
+320
-131
app/assets/javascripts/gl_form.js
app/assets/javascripts/gl_form.js
+2
-2
app/assets/javascripts/notes.js
app/assets/javascripts/notes.js
+148
-69
app/assets/stylesheets/pages/notes.scss
app/assets/stylesheets/pages/notes.scss
+1
-1
app/views/projects/notes/_edit_form.html.haml
app/views/projects/notes/_edit_form.html.haml
+1
-1
app/views/shared/notes/_note.html.haml
app/views/shared/notes/_note.html.haml
+5
-1
changelogs/unreleased/30458-real-time-note-edits.yml
changelogs/unreleased/30458-real-time-note-edits.yml
+4
-0
spec/features/gitlab_flavored_markdown_spec.rb
spec/features/gitlab_flavored_markdown_spec.rb
+2
-8
spec/features/issues/note_polling_spec.rb
spec/features/issues/note_polling_spec.rb
+69
-6
spec/javascripts/notes_spec.js
spec/javascripts/notes_spec.js
+88
-43
No files found.
app/assets/javascripts/gl_form.js
View file @
cf34b07e
...
...
@@ -34,9 +34,9 @@ GLForm.prototype.setupForm = function() {
gl
.
GfmAutoComplete
.
setup
(
this
.
form
.
find
(
'
.js-gfm-input
'
));
new
DropzoneInput
(
this
.
form
);
autosize
(
this
.
textarea
);
}
// form and textarea event listeners
this
.
addEventListeners
();
}
gl
.
text
.
init
(
this
.
form
);
// hide discard button
this
.
form
.
find
(
'
.js-note-discard
'
).
hide
();
...
...
app/assets/javascripts/notes.js
View file @
cf34b07e
...
...
@@ -4,6 +4,7 @@
/* global ResolveService */
/* global mrRefreshWidgetUrl */
import
$
from
'
jquery
'
;
import
Cookies
from
'
js-cookie
'
;
import
CommentTypeToggle
from
'
./comment_type_toggle
'
;
...
...
@@ -16,6 +17,10 @@ require('vendor/jquery.caret'); // required by jquery.atwho
require
(
'
vendor/jquery.atwho
'
);
require
(
'
./task_list
'
);
const
normalizeNewlines
=
function
(
str
)
{
return
str
.
replace
(
/
\r\n
/g
,
'
\n
'
);
};
(
function
()
{
var
bind
=
function
(
fn
,
me
)
{
return
function
()
{
return
fn
.
apply
(
me
,
arguments
);
};
};
...
...
@@ -42,13 +47,17 @@ require('./task_list');
this
.
refresh
=
bind
(
this
.
refresh
,
this
);
this
.
keydownNoteText
=
bind
(
this
.
keydownNoteText
,
this
);
this
.
toggleCommitList
=
bind
(
this
.
toggleCommitList
,
this
);
this
.
notes_url
=
notes_url
;
this
.
note_ids
=
note_ids
;
// Used to keep track of updated notes while people are editing things
this
.
updatedNotesTrackingMap
=
{};
this
.
last_fetched_at
=
last_fetched_at
;
this
.
noteable_url
=
document
.
URL
;
this
.
notesCountBadge
||
(
this
.
notesCountBadge
=
$
(
"
.issuable-details
"
).
find
(
"
.notes-tab .badge
"
));
this
.
basePollingInterval
=
15000
;
this
.
maxPollingSteps
=
4
;
this
.
cleanBinding
();
this
.
addBinding
();
this
.
setPollingInterval
();
...
...
@@ -128,7 +137,7 @@ require('./task_list');
$
(
document
).
off
(
"
click
"
,
"
.js-discussion-reply-button
"
);
$
(
document
).
off
(
"
click
"
,
"
.js-add-diff-note-button
"
);
$
(
document
).
off
(
"
visibilitychange
"
);
$
(
document
).
off
(
"
keyup
"
,
"
.js-note-text
"
);
$
(
document
).
off
(
"
keyup
input
"
,
"
.js-note-text
"
);
$
(
document
).
off
(
"
click
"
,
"
.js-note-target-reopen
"
);
$
(
document
).
off
(
"
click
"
,
"
.js-note-target-close
"
);
$
(
document
).
off
(
"
click
"
,
"
.js-note-discard
"
);
...
...
@@ -267,20 +276,20 @@ require('./task_list');
return
this
.
initRefresh
();
};
Notes
.
prototype
.
handleCreateChanges
=
function
(
note
)
{
Notes
.
prototype
.
handleCreateChanges
=
function
(
note
Entity
)
{
var
votesBlock
;
if
(
typeof
note
===
'
undefined
'
)
{
if
(
typeof
note
Entity
===
'
undefined
'
)
{
return
;
}
if
(
note
.
commands_changes
)
{
if
(
'
merge
'
in
note
.
commands_changes
)
{
if
(
note
Entity
.
commands_changes
)
{
if
(
'
merge
'
in
note
Entity
.
commands_changes
)
{
$
.
get
(
mrRefreshWidgetUrl
);
}
if
(
'
emoji_award
'
in
note
.
commands_changes
)
{
if
(
'
emoji_award
'
in
note
Entity
.
commands_changes
)
{
votesBlock
=
$
(
'
.js-awards-block
'
).
eq
(
0
);
gl
.
awardsHandler
.
addAwardToEmojiBar
(
votesBlock
,
note
.
commands_changes
.
emoji_award
);
gl
.
awardsHandler
.
addAwardToEmojiBar
(
votesBlock
,
note
Entity
.
commands_changes
.
emoji_award
);
return
gl
.
awardsHandler
.
scrollToAwards
();
}
}
...
...
@@ -292,41 +301,76 @@ require('./task_list');
Note: for rendering inline notes use renderDiscussionNote
*/
Notes
.
prototype
.
renderNote
=
function
(
note
,
$form
)
{
var
$notesList
;
if
(
note
.
discussion_html
!=
null
)
{
return
this
.
renderDiscussionNote
(
note
,
$form
);
Notes
.
prototype
.
renderNote
=
function
(
noteEntity
,
$form
,
$notesList
=
$
(
'
.main-notes-list
'
))
{
if
(
noteEntity
.
discussion_html
!=
null
)
{
return
this
.
renderDiscussionNote
(
noteEntity
,
$form
);
}
if
(
!
note
.
valid
)
{
if
(
note
.
errors
.
commands_only
)
{
new
Flash
(
note
.
errors
.
commands_only
,
'
notice
'
,
this
.
parentTimeline
);
if
(
!
note
Entity
.
valid
)
{
if
(
note
Entity
.
errors
.
commands_only
)
{
new
Flash
(
note
Entity
.
errors
.
commands_only
,
'
notice
'
,
this
.
parentTimeline
);
this
.
refresh
();
}
return
;
}
if
(
this
.
isNewNote
(
note
))
{
this
.
note_ids
.
push
(
note
.
id
);
const
$note
=
$notesList
.
find
(
`#note_
${
noteEntity
.
id
}
`
);
if
(
this
.
isNewNote
(
noteEntity
))
{
this
.
note_ids
.
push
(
noteEntity
.
id
);
$notesList
=
window
.
$
(
'
ul.main-notes-list
'
);
Notes
.
animateAppendNote
(
note
.
html
,
$notesList
);
const
$newNote
=
Notes
.
animateAppendNote
(
noteEntity
.
html
,
$notesList
);
// Update datetime format on the recent note
gl
.
utils
.
localTimeAgo
(
$n
otesList
.
find
(
"
#note_
"
+
note
.
id
+
"
.js-timeago
"
),
false
);
gl
.
utils
.
localTimeAgo
(
$n
ewNote
.
find
(
'
.js-timeago
'
),
false
);
this
.
collapseLongCommitList
();
this
.
taskList
.
init
();
this
.
refresh
();
return
this
.
updateNotesCount
(
1
);
}
// The server can send the same update multiple times so we need to make sure to only update once per actual update.
else
if
(
this
.
isUpdatedNote
(
noteEntity
,
$note
))
{
const
isEditing
=
$note
.
hasClass
(
'
is-editing
'
);
const
initialContent
=
normalizeNewlines
(
$note
.
find
(
'
.original-note-content
'
).
text
().
trim
()
);
const
$textarea
=
$note
.
find
(
'
.js-note-text
'
);
const
currentContent
=
$textarea
.
val
();
// There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
const
sanitizedNoteNote
=
normalizeNewlines
(
noteEntity
.
note
);
const
isTextareaUntouched
=
currentContent
===
initialContent
||
currentContent
===
sanitizedNoteNote
;
if
(
isEditing
&&
isTextareaUntouched
)
{
$textarea
.
val
(
noteEntity
.
note
);
this
.
updatedNotesTrackingMap
[
noteEntity
.
id
]
=
noteEntity
;
}
else
if
(
isEditing
&&
!
isTextareaUntouched
)
{
this
.
putConflictEditWarningInPlace
(
noteEntity
,
$note
);
this
.
updatedNotesTrackingMap
[
noteEntity
.
id
]
=
noteEntity
;
}
else
{
const
$updatedNote
=
Notes
.
animateUpdateNote
(
noteEntity
.
html
,
$note
);
// Update datetime format on the recent note
gl
.
utils
.
localTimeAgo
(
$updatedNote
.
find
(
'
.js-timeago
'
),
false
);
}
}
};
/*
Check if note does not exists on page
*/
Notes
.
prototype
.
isNewNote
=
function
(
note
)
{
return
$
.
inArray
(
note
.
id
,
this
.
note_ids
)
===
-
1
;
Notes
.
prototype
.
isNewNote
=
function
(
noteEntity
)
{
return
$
.
inArray
(
noteEntity
.
id
,
this
.
note_ids
)
===
-
1
;
};
Notes
.
prototype
.
isUpdatedNote
=
function
(
noteEntity
,
$note
)
{
// There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
const
sanitizedNoteNote
=
normalizeNewlines
(
noteEntity
.
note
);
const
currentNoteText
=
normalizeNewlines
(
$note
.
find
(
'
.original-note-content
'
).
text
().
trim
()
);
return
sanitizedNoteNote
!==
currentNoteText
;
};
Notes
.
prototype
.
isParallelView
=
function
()
{
...
...
@@ -339,31 +383,31 @@ require('./task_list');
Note: for rendering inline notes use renderDiscussionNote
*/
Notes
.
prototype
.
renderDiscussionNote
=
function
(
note
,
$form
)
{
Notes
.
prototype
.
renderDiscussionNote
=
function
(
note
Entity
,
$form
)
{
var
discussionContainer
,
form
,
row
,
lineType
,
diffAvatarContainer
;
if
(
!
this
.
isNewNote
(
note
))
{
if
(
!
this
.
isNewNote
(
note
Entity
))
{
return
;
}
this
.
note_ids
.
push
(
note
.
id
);
form
=
$form
||
$
(
"
.js-discussion-note-form[data-discussion-id='
"
+
note
.
discussion_id
+
"
']
"
);
this
.
note_ids
.
push
(
note
Entity
.
id
);
form
=
$form
||
$
(
"
.js-discussion-note-form[data-discussion-id='
"
+
note
Entity
.
discussion_id
+
"
']
"
);
row
=
form
.
closest
(
"
tr
"
);
lineType
=
this
.
isParallelView
()
?
form
.
find
(
'
#line_type
'
).
val
()
:
'
old
'
;
diffAvatarContainer
=
row
.
prevAll
(
'
.line_holder
'
).
first
().
find
(
'
.js-avatar-container.
'
+
lineType
+
'
_line
'
);
// is this the first note of discussion?
discussionContainer
=
window
.
$
(
`.notes[data-discussion-id="
${
note
.
discussion_id
}
"]`
);
discussionContainer
=
$
(
`.notes[data-discussion-id="
${
noteEntity
.
discussion_id
}
"]`
);
if
(
!
discussionContainer
.
length
)
{
discussionContainer
=
form
.
closest
(
'
.discussion
'
).
find
(
'
.notes
'
);
}
if
(
discussionContainer
.
length
===
0
)
{
if
(
note
.
diff_discussion_html
)
{
var
$discussion
=
$
(
note
.
diff_discussion_html
).
renderGFM
();
if
(
note
Entity
.
diff_discussion_html
)
{
var
$discussion
=
$
(
note
Entity
.
diff_discussion_html
).
renderGFM
();
if
(
!
this
.
isParallelView
()
||
row
.
hasClass
(
'
js-temp-notes-holder
'
))
{
// insert the note and the reply button after the temp row
row
.
after
(
$discussion
);
}
else
{
// Merge new discussion HTML in
var
$notes
=
$discussion
.
find
(
'
.notes[data-discussion-id="
'
+
note
.
discussion_id
+
'
"]
'
);
var
$notes
=
$discussion
.
find
(
'
.notes[data-discussion-id="
'
+
note
Entity
.
discussion_id
+
'
"]
'
);
var
contentContainerClass
=
'
.
'
+
$notes
.
closest
(
'
.notes_content
'
)
.
attr
(
'
class
'
)
.
split
(
'
'
)
...
...
@@ -373,17 +417,18 @@ require('./task_list');
}
}
// Init discussion on 'Discussion' page if it is merge request page
if
(
window
.
$
(
'
body
'
).
attr
(
'
data-page
'
).
indexOf
(
'
projects:merge_request
'
)
===
0
||
!
note
.
diff_discussion_html
)
{
Notes
.
animateAppendNote
(
note
.
discussion_html
,
window
.
$
(
'
ul.main-notes-list
'
));
const
page
=
$
(
'
body
'
).
attr
(
'
data-page
'
);
if
((
page
&&
page
.
indexOf
(
'
projects:merge_request
'
)
===
0
)
||
!
noteEntity
.
diff_discussion_html
)
{
Notes
.
animateAppendNote
(
noteEntity
.
discussion_html
,
$
(
'
.main-notes-list
'
));
}
}
else
{
// append new note to all matching discussions
Notes
.
animateAppendNote
(
note
.
html
,
discussionContainer
);
Notes
.
animateAppendNote
(
note
Entity
.
html
,
discussionContainer
);
}
if
(
typeof
gl
.
diffNotesCompileComponents
!==
'
undefined
'
&&
note
.
discussion_resolvable
)
{
if
(
typeof
gl
.
diffNotesCompileComponents
!==
'
undefined
'
&&
note
Entity
.
discussion_resolvable
)
{
gl
.
diffNotesCompileComponents
();
this
.
renderDiscussionAvatar
(
diffAvatarContainer
,
note
);
this
.
renderDiscussionAvatar
(
diffAvatarContainer
,
note
Entity
);
}
gl
.
utils
.
localTimeAgo
(
$
(
'
.js-timeago
'
),
false
);
...
...
@@ -397,13 +442,13 @@ require('./task_list');
.
get
(
0
);
};
Notes
.
prototype
.
renderDiscussionAvatar
=
function
(
diffAvatarContainer
,
note
)
{
Notes
.
prototype
.
renderDiscussionAvatar
=
function
(
diffAvatarContainer
,
note
Entity
)
{
var
commentButton
=
diffAvatarContainer
.
find
(
'
.js-add-diff-note-button
'
);
var
avatarHolder
=
diffAvatarContainer
.
find
(
'
.diff-comment-avatar-holders
'
);
if
(
!
avatarHolder
.
length
)
{
avatarHolder
=
document
.
createElement
(
'
diff-note-avatars
'
);
avatarHolder
.
setAttribute
(
'
discussion-id
'
,
note
.
discussion_id
);
avatarHolder
.
setAttribute
(
'
discussion-id
'
,
note
Entity
.
discussion_id
);
diffAvatarContainer
.
append
(
avatarHolder
);
...
...
@@ -550,16 +595,16 @@ require('./task_list');
Updates the current note field.
*/
Notes
.
prototype
.
updateNote
=
function
(
_xhr
,
note
,
_status
)
{
Notes
.
prototype
.
updateNote
=
function
(
_xhr
,
note
Entity
,
_status
)
{
var
$html
,
$note_li
;
// Convert returned HTML to a jQuery object so we can modify it further
$html
=
$
(
note
.
html
);
$html
=
$
(
note
Entity
.
html
);
this
.
revertNoteEditForm
();
gl
.
utils
.
localTimeAgo
(
$
(
'
.js-timeago
'
,
$html
));
$html
.
renderGFM
();
$html
.
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-
'
+
note
.
id
);
$note_li
=
$
(
'
.note-row-
'
+
note
Entity
.
id
);
$note_li
.
replaceWith
(
$html
);
...
...
@@ -570,7 +615,7 @@ require('./task_list');
Notes
.
prototype
.
checkContentToAllowEditing
=
function
(
$el
)
{
var
initialContent
=
$el
.
find
(
'
.original-note-content
'
).
text
().
trim
();
var
currentContent
=
$el
.
find
(
'
.
note-textarea
'
).
val
();
var
currentContent
=
$el
.
find
(
'
.
js-note-text
'
).
val
();
var
isAllowed
=
true
;
if
(
currentContent
===
initialContent
)
{
...
...
@@ -584,7 +629,7 @@ require('./task_list');
gl
.
utils
.
scrollToElement
(
$el
);
}
$el
.
find
(
'
.js-edit-warning
'
).
show
();
$el
.
find
(
'
.js-
finish-
edit-warning
'
).
show
();
isAllowed
=
false
;
}
...
...
@@ -603,7 +648,7 @@ require('./task_list');
var
$target
=
$
(
e
.
target
);
var
$editForm
=
$
(
this
.
getEditFormSelector
(
$target
));
var
$note
=
$target
.
closest
(
'
.note
'
);
var
$currentlyEditing
=
$
(
'
.note.is-edit
t
ing:visible
'
);
var
$currentlyEditing
=
$
(
'
.note.is-editing:visible
'
);
if
(
$currentlyEditing
.
length
)
{
var
isEditAllowed
=
this
.
checkContentToAllowEditing
(
$currentlyEditing
);
...
...
@@ -615,7 +660,7 @@ require('./task_list');
$note
.
find
(
'
.js-note-attachment-delete
'
).
show
();
$editForm
.
addClass
(
'
current-note-edit-form
'
);
$note
.
addClass
(
'
is-edit
t
ing
'
);
$note
.
addClass
(
'
is-editing
'
);
this
.
putEditFormInPlace
(
$target
);
};
...
...
@@ -627,21 +672,34 @@ require('./task_list');
Notes
.
prototype
.
cancelEdit
=
function
(
e
)
{
e
.
preventDefault
();
var
$target
=
$
(
e
.
target
);
var
note
=
$target
.
closest
(
'
.note
'
);
note
.
find
(
'
.js-edit-warning
'
).
hide
();
const
$target
=
$
(
e
.
target
);
const
$note
=
$target
.
closest
(
'
.note
'
);
const
noteId
=
$note
.
attr
(
'
data-note-id
'
);
this
.
revertNoteEditForm
(
$target
);
return
this
.
removeNoteEditForm
(
note
);
if
(
this
.
updatedNotesTrackingMap
[
noteId
])
{
const
$newNote
=
$
(
this
.
updatedNotesTrackingMap
[
noteId
].
html
);
$note
.
replaceWith
(
$newNote
);
this
.
updatedNotesTrackingMap
[
noteId
]
=
null
;
// Update datetime format on the recent note
gl
.
utils
.
localTimeAgo
(
$newNote
.
find
(
'
.js-timeago
'
),
false
);
}
else
{
$note
.
find
(
'
.js-finish-edit-warning
'
).
hide
();
this
.
removeNoteEditForm
(
$note
);
}
};
Notes
.
prototype
.
revertNoteEditForm
=
function
(
$target
)
{
$target
=
$target
||
$
(
'
.note.is-edit
t
ing:visible
'
);
$target
=
$target
||
$
(
'
.note.is-editing:visible
'
);
var
selector
=
this
.
getEditFormSelector
(
$target
);
var
$editForm
=
$
(
selector
);
$editForm
.
insertBefore
(
'
.notes-form
'
);
$editForm
.
find
(
'
.js-comment-button
'
).
enable
();
$editForm
.
find
(
'
.js-edit-warning
'
).
hide
();
$editForm
.
find
(
'
.js-
finish-
edit-warning
'
).
hide
();
};
Notes
.
prototype
.
getEditFormSelector
=
function
(
$el
)
{
...
...
@@ -654,11 +712,11 @@ require('./task_list');
return
selector
;
};
Notes
.
prototype
.
removeNoteEditForm
=
function
(
note
)
{
var
form
=
note
.
find
(
'
.current-note-edit-form
'
);
note
.
removeClass
(
'
is-edit
ting
'
);
Notes
.
prototype
.
removeNoteEditForm
=
function
(
$
note
)
{
var
form
=
$
note
.
find
(
'
.current-note-edit-form
'
);
$note
.
removeClass
(
'
is-edi
ting
'
);
form
.
removeClass
(
'
current-note-edit-form
'
);
form
.
find
(
'
.js-edit-warning
'
).
hide
();
form
.
find
(
'
.js-
finish-
edit-warning
'
).
hide
();
// Replace markdown textarea text with original note text.
return
form
.
find
(
'
.js-note-text
'
).
val
(
form
.
find
(
'
form.edit-note
'
).
data
(
'
original-note
'
));
};
...
...
@@ -683,9 +741,9 @@ require('./task_list');
// to remove all. Using $(".note[id='noteId']") ensure we get all the notes,
// where $("#noteId") would return only one.
return
function
(
i
,
el
)
{
var
note
,
notes
;
note
=
$
(
el
);
notes
=
note
.
closest
(
"
.discussion-notes
"
);
var
$note
,
$
notes
;
$
note
=
$
(
el
);
$notes
=
$
note
.
closest
(
"
.discussion-notes
"
);
if
(
typeof
gl
.
diffNotesCompileComponents
!==
'
undefined
'
)
{
if
(
gl
.
diffNoteApps
[
noteElId
])
{
...
...
@@ -693,18 +751,18 @@ require('./task_list');
}
}
note
.
remove
();
$
note
.
remove
();
// check if this is the last note for this line
if
(
notes
.
find
(
"
.note
"
).
length
===
0
)
{
var
notesTr
=
notes
.
closest
(
"
tr
"
);
if
(
$
notes
.
find
(
"
.note
"
).
length
===
0
)
{
var
notesTr
=
$
notes
.
closest
(
"
tr
"
);
// "Discussions" tab
notes
.
closest
(
"
.timeline-entry
"
).
remove
();
$
notes
.
closest
(
"
.timeline-entry
"
).
remove
();
// The notes tr can contain multiple lists of notes, like on the parallel diff
if
(
notesTr
.
find
(
'
.discussion-notes
'
).
length
>
1
)
{
notes
.
remove
();
$
notes
.
remove
();
}
else
{
notesTr
.
remove
();
}
...
...
@@ -723,12 +781,11 @@ require('./task_list');
*/
Notes
.
prototype
.
removeAttachment
=
function
()
{
var
note
;
note
=
$
(
this
).
closest
(
"
.note
"
);
note
.
find
(
"
.note-attachment
"
).
remove
();
note
.
find
(
"
.note-body > .note-text
"
).
show
();
note
.
find
(
"
.note-header
"
).
show
();
return
note
.
find
(
"
.current-note-edit-form
"
).
remove
();
const
$note
=
$
(
this
).
closest
(
"
.note
"
);
$note
.
find
(
"
.note-attachment
"
).
remove
();
$note
.
find
(
"
.note-body > .note-text
"
).
show
();
$note
.
find
(
"
.note-header
"
).
show
();
return
$note
.
find
(
"
.current-note-edit-form
"
).
remove
();
};
/*
...
...
@@ -1004,6 +1061,19 @@ require('./task_list');
$editForm
.
find
(
'
.referenced-users
'
).
hide
();
};
Notes
.
prototype
.
putConflictEditWarningInPlace
=
function
(
noteEntity
,
$note
)
{
if
(
$note
.
find
(
'
.js-conflict-edit-warning
'
).
length
===
0
)
{
const
$alert
=
$
(
`<div class="js-conflict-edit-warning alert alert-danger">
This comment has changed since you started editing, please review the
<a href="#note_
${
noteEntity
.
id
}
" target="_blank" rel="noopener noreferrer">
updated comment
</a>
to ensure information is not lost
</div>`
);
$alert
.
insertAfter
(
$note
.
find
(
'
.note-text
'
));
}
};
Notes
.
prototype
.
updateNotesCount
=
function
(
updateCount
)
{
return
this
.
notesCountBadge
.
text
(
parseInt
(
this
.
notesCountBadge
.
text
(),
10
)
+
updateCount
);
};
...
...
@@ -1064,11 +1134,20 @@ require('./task_list');
return
$form
;
};
Notes
.
animateAppendNote
=
function
(
noteH
TML
,
$notesList
)
{
const
$note
=
window
.
$
(
noteHTML
);
Notes
.
animateAppendNote
=
function
(
noteH
tml
,
$notesList
)
{
const
$note
=
$
(
noteHtml
);
$note
.
addClass
(
'
fade-in
'
).
renderGFM
();
$notesList
.
append
(
$note
);
return
$note
;
};
Notes
.
animateUpdateNote
=
function
(
noteHtml
,
$note
)
{
const
$updatedNote
=
$
(
noteHtml
);
$updatedNote
.
addClass
(
'
fade-in
'
).
renderGFM
();
$note
.
replaceWith
(
$updatedNote
);
return
$updatedNote
;
};
return
Notes
;
...
...
app/assets/stylesheets/pages/notes.scss
View file @
cf34b07e
...
...
@@ -67,7 +67,7 @@ ul.notes {
}
}
&
.is-edit
t
ing
{
&
.is-editing
{
.note-header
,
.note-text
,
.edited-text
{
...
...
app/views/projects/notes/_edit_form.html.haml
View file @
cf34b07e
...
...
@@ -7,7 +7,7 @@
=
render
'projects/notes/hints'
.note-form-actions.clearfix
.settings-message.note-edit-warning.js-edit-warning
.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'
%button
.btn.btn-nr.btn-cancel.note-edit-cancel
{
type:
'button'
}
...
...
app/views/shared/notes/_note.html.haml
View file @
cf34b07e
...
...
@@ -2,7 +2,11 @@
-
return
if
note
.
cross_reference_not_visible_for?
(
current_user
)
-
note_editable
=
note_editable?
(
note
)
%li
.timeline-entry
{
id:
dom_id
(
note
),
class:
[
"note"
,
"note-row-#{note.id}"
,
(
'system-note'
if
note
.
system
)],
data:
{
author_id:
note
.
author
.
id
,
editable:
note_editable
,
note_id:
note
.
id
}
}
%li
.timeline-entry
{
id:
dom_id
(
note
),
class:
[
"note"
,
"note-row-#{note.id}"
,
(
'system-note'
if
note
.
system
)],
data:
{
author_id:
note
.
author
.
id
,
editable:
note_editable
,
note_id:
note
.
id
}
}
.timeline-entry-inner
.timeline-icon
-
if
note
.
system
...
...
changelogs/unreleased/30458-real-time-note-edits.yml
0 → 100644
View file @
cf34b07e
---
title
:
Update note edits in real-time
merge_request
:
author
:
spec/features/gitlab_flavored_markdown_spec.rb
View file @
cf34b07e
...
...
@@ -62,6 +62,8 @@ describe "GitLab Flavored Markdown", feature: true do
project:
project
,
title:
"fix
#{
@other_issue
.
to_reference
}
"
,
description:
"ask
#{
fred
.
to_reference
}
for details"
)
@note
=
create
(
:note_on_issue
,
noteable:
@issue
,
project:
@issue
.
project
,
note:
"Hello world"
)
end
it
"renders subject in issues#index"
do
...
...
@@ -81,14 +83,6 @@ describe "GitLab Flavored Markdown", feature: true do
expect
(
page
).
to
have_link
(
fred
.
to_reference
)
end
it
"renders updated subject once edited somewhere else in issues#show"
do
visit
namespace_project_issue_path
(
project
.
namespace
,
project
,
@issue
)
@issue
.
update
(
title:
"fix
#{
@other_issue
.
to_reference
}
and update"
)
wait_for_vue_resource
expect
(
page
).
to
have_text
(
"fix
#{
@other_issue
.
to_reference
}
and update"
)
end
end
describe
"for merge requests"
do
...
...
spec/features/issues/note_polling_spec.rb
View file @
cf34b07e
...
...
@@ -4,14 +4,77 @@ feature 'Issue notes polling', :feature, :js do
let
(
:project
)
{
create
(
:empty_project
,
:public
)
}
let
(
:issue
)
{
create
(
:issue
,
project:
project
)
}
describe
'creates'
do
before
do
visit
namespace_project_issue_path
(
project
.
namespace
,
project
,
issue
)
end
it
'should display
the new comment'
do
it
'displays
the new comment'
do
note
=
create
(
:note
,
noteable:
issue
,
project:
project
,
note:
'Looks good!'
)
page
.
execute_script
(
'notes.refresh();'
)
expect
(
page
).
to
have_selector
(
"#note_
#{
note
.
id
}
"
,
text:
'Looks good!'
)
end
end
describe
'updates'
do
let
(
:user
)
{
create
(
:user
)
}
let
(
:note_text
)
{
"Hello World"
}
let
(
:updated_text
)
{
"Bye World"
}
let!
(
:existing_note
)
{
create
(
:note
,
noteable:
issue
,
project:
project
,
author:
user
,
note:
note_text
)
}
before
do
login_as
(
user
)
visit
namespace_project_issue_path
(
project
.
namespace
,
project
,
issue
)
end
it
'displays the updated content'
do
expect
(
page
).
to
have_selector
(
"#note_
#{
existing_note
.
id
}
"
,
text:
note_text
)
update_note
(
existing_note
,
updated_text
)
expect
(
page
).
to
have_selector
(
"#note_
#{
existing_note
.
id
}
"
,
text:
updated_text
)
end
it
'when editing but have not changed anything, and an update comes in, show the updated content in the textarea'
do
find
(
"#note_
#{
existing_note
.
id
}
.js-note-edit"
).
click
expect
(
page
).
to
have_field
(
"note[note]"
,
with:
note_text
)
update_note
(
existing_note
,
updated_text
)
expect
(
page
).
to
have_field
(
"note[note]"
,
with:
updated_text
)
end
it
'when editing but you changed some things, and an update comes in, show a warning'
do
find
(
"#note_
#{
existing_note
.
id
}
.js-note-edit"
).
click
expect
(
page
).
to
have_field
(
"note[note]"
,
with:
note_text
)
find
(
"#note_
#{
existing_note
.
id
}
.js-note-text"
).
set
(
'something random'
)
update_note
(
existing_note
,
updated_text
)
expect
(
page
).
to
have_selector
(
".alert"
)
end
it
'when editing but you changed some things, an update comes in, and you press cancel, show the updated content'
do
find
(
"#note_
#{
existing_note
.
id
}
.js-note-edit"
).
click
expect
(
page
).
to
have_field
(
"note[note]"
,
with:
note_text
)
find
(
"#note_
#{
existing_note
.
id
}
.js-note-text"
).
set
(
'something random'
)
update_note
(
existing_note
,
updated_text
)
find
(
"#note_
#{
existing_note
.
id
}
.note-edit-cancel"
).
click
expect
(
page
).
to
have_selector
(
"#note_
#{
existing_note
.
id
}
"
,
text:
updated_text
)
end
end
def
update_note
(
note
,
new_text
)
note
.
update
(
note:
new_text
)
page
.
execute_script
(
'notes.refresh();'
)
end
end
spec/javascripts/notes_spec.js
View file @
cf34b07e
/* eslint-disable space-before-function-paren, no-unused-expressions, no-var, object-shorthand, comma-dangle, max-len */
/* global Notes */
require
(
'
~/notes
'
);
require
(
'
vendor/autosize
'
);
require
(
'
~/gl_form
'
);
require
(
'
~/lib/utils/text_utility
'
);
import
'
vendor/autosize
'
;
import
'
~/gl_form
'
;
import
'
~/lib/utils/text_utility
'
;
import
'
~/render_gfm
'
;
import
'
~/render_math
'
;
import
'
~/notes
'
;
(
function
()
{
window
.
gon
||
(
window
.
gon
=
{});
...
...
@@ -80,35 +82,78 @@ require('~/lib/utils/text_utility');
beforeEach
(()
=>
{
note
=
{
id
:
1
,
discussion_html
:
null
,
valid
:
true
,
html
:
'
<div></div>
'
,
note
:
'
heya
'
,
html
:
'
<div>heya</div>
'
,
};
$notesList
=
jasmine
.
createSpyObj
(
'
$notesList
'
,
[
'
find
'
]);
$notesList
=
jasmine
.
createSpyObj
(
'
$notesList
'
,
[
'
find
'
,
'
append
'
,
]);
notes
=
jasmine
.
createSpyObj
(
'
notes
'
,
[
'
refresh
'
,
'
isNewNote
'
,
'
isUpdatedNote
'
,
'
collapseLongCommitList
'
,
'
updateNotesCount
'
,
'
putConflictEditWarningInPlace
'
]);
notes
.
taskList
=
jasmine
.
createSpyObj
(
'
tasklist
'
,
[
'
init
'
]);
notes
.
note_ids
=
[];
notes
.
updatedNotesTrackingMap
=
{};
spyOn
(
window
,
'
$
'
).
and
.
returnValue
(
$notesList
);
spyOn
(
gl
.
utils
,
'
localTimeAgo
'
);
spyOn
(
Notes
,
'
animateAppendNote
'
);
spyOn
(
Notes
,
'
animateAppendNote
'
).
and
.
callThrough
();
spyOn
(
Notes
,
'
animateUpdateNote
'
).
and
.
callThrough
();
});
describe
(
'
when adding note
'
,
()
=>
{
it
(
'
should call .animateAppendNote
'
,
()
=>
{
notes
.
isNewNote
.
and
.
returnValue
(
true
);
Notes
.
prototype
.
renderNote
.
call
(
notes
,
note
,
null
,
$notesList
);
Notes
.
prototype
.
renderNote
.
call
(
notes
,
note
);
expect
(
Notes
.
animateAppendNote
).
toHaveBeenCalledWith
(
note
.
html
,
$notesList
);
});
});
it
(
'
should query for the notes list
'
,
()
=>
{
expect
(
window
.
$
).
toHaveBeenCalledWith
(
'
ul.main-notes-list
'
);
describe
(
'
when note was edited
'
,
()
=>
{
it
(
'
should call .animateUpdateNote
'
,
()
=>
{
notes
.
isUpdatedNote
.
and
.
returnValue
(
true
);
const
$note
=
$
(
'
<div>
'
);
$notesList
.
find
.
and
.
returnValue
(
$note
);
Notes
.
prototype
.
renderNote
.
call
(
notes
,
note
,
null
,
$notesList
);
expect
(
Notes
.
animateUpdateNote
).
toHaveBeenCalledWith
(
note
.
html
,
$note
);
});
it
(
'
should call .animateAppendNote
'
,
()
=>
{
expect
(
Notes
.
animateAppendNote
).
toHaveBeenCalledWith
(
note
.
html
,
$notesList
);
describe
(
'
while editing
'
,
()
=>
{
it
(
'
should update textarea if nothing has been touched
'
,
()
=>
{
notes
.
isUpdatedNote
.
and
.
returnValue
(
true
);
const
$note
=
$
(
`<div class="is-editing">
<div class="original-note-content">initial</div>
<textarea class="js-note-text">initial</textarea>
</div>`
);
$notesList
.
find
.
and
.
returnValue
(
$note
);
Notes
.
prototype
.
renderNote
.
call
(
notes
,
note
,
null
,
$notesList
);
expect
(
$note
.
find
(
'
.js-note-text
'
).
val
()).
toEqual
(
note
.
note
);
});
it
(
'
should call .putConflictEditWarningInPlace
'
,
()
=>
{
notes
.
isUpdatedNote
.
and
.
returnValue
(
true
);
const
$note
=
$
(
`<div class="is-editing">
<div class="original-note-content">initial</div>
<textarea class="js-note-text">different</textarea>
</div>`
);
$notesList
.
find
.
and
.
returnValue
(
$note
);
Notes
.
prototype
.
renderNote
.
call
(
notes
,
note
,
null
,
$notesList
);
expect
(
notes
.
putConflictEditWarningInPlace
).
toHaveBeenCalledWith
(
note
,
$note
);
});
});
});
});
...
...
@@ -147,14 +192,12 @@ require('~/lib/utils/text_utility');
});
describe
(
'
Discussion root note
'
,
()
=>
{
let
$notesList
;
let
body
;
beforeEach
(()
=>
{
body
=
jasmine
.
createSpyObj
(
'
body
'
,
[
'
attr
'
]);
discussionContainer
=
{
length
:
0
};
spyOn
(
window
,
'
$
'
).
and
.
returnValues
(
discussionContainer
,
body
,
$notesList
);
$form
.
closest
.
and
.
returnValues
(
row
,
$form
);
$form
.
find
.
and
.
returnValues
(
discussionContainer
);
body
.
attr
.
and
.
returnValue
(
''
);
...
...
@@ -162,12 +205,8 @@ require('~/lib/utils/text_utility');
Notes
.
prototype
.
renderDiscussionNote
.
call
(
notes
,
note
,
$form
);
});
it
(
'
should query for the notes list
'
,
()
=>
{
expect
(
window
.
$
.
calls
.
argsFor
(
2
)).
toEqual
([
'
ul.main-notes-list
'
]);
});
it
(
'
should call Notes.animateAppendNote
'
,
()
=>
{
expect
(
Notes
.
animateAppendNote
).
toHaveBeenCalledWith
(
note
.
discussion_html
,
$
notesList
);
expect
(
Notes
.
animateAppendNote
).
toHaveBeenCalledWith
(
note
.
discussion_html
,
$
(
'
.main-notes-list
'
)
);
});
});
...
...
@@ -175,16 +214,12 @@ require('~/lib/utils/text_utility');
beforeEach
(()
=>
{
discussionContainer
=
{
length
:
1
};
spyOn
(
window
,
'
$
'
).
and
.
returnValues
(
discussionContainer
);
$form
.
closest
.
and
.
returnValues
(
row
);
$form
.
closest
.
and
.
returnValues
(
row
,
$form
);
$form
.
find
.
and
.
returnValues
(
discussionContainer
);
Notes
.
prototype
.
renderDiscussionNote
.
call
(
notes
,
note
,
$form
);
});
it
(
'
should query foor the discussion container
'
,
()
=>
{
expect
(
window
.
$
).
toHaveBeenCalledWith
(
`.notes[data-discussion-id="
${
note
.
discussion_id
}
"]`
);
});
it
(
'
should call Notes.animateAppendNote
'
,
()
=>
{
expect
(
Notes
.
animateAppendNote
).
toHaveBeenCalledWith
(
note
.
html
,
discussionContainer
);
});
...
...
@@ -193,35 +228,45 @@ require('~/lib/utils/text_utility');
describe
(
'
animateAppendNote
'
,
()
=>
{
let
noteHTML
;
let
$note
;
let
$notesList
;
let
$resultantNote
;
beforeEach
(()
=>
{
noteHTML
=
'
<div></div>
'
;
$note
=
jasmine
.
createSpyObj
(
'
$note
'
,
[
'
addClass
'
,
'
renderGFM
'
,
'
removeClass
'
]);
$notesList
=
jasmine
.
createSpyObj
(
'
$notesList
'
,
[
'
append
'
]);
spyOn
(
window
,
'
$
'
).
and
.
returnValue
(
$note
);
spyOn
(
window
,
'
setTimeout
'
).
and
.
callThrough
();
$note
.
addClass
.
and
.
returnValue
(
$note
);
$note
.
renderGFM
.
and
.
returnValue
(
$note
);
$resultantNote
=
Notes
.
animateAppendNote
(
noteHTML
,
$notesList
);
});
Notes
.
animateAppendNote
(
noteHTML
,
$notesList
);
it
(
'
should have `fade-in` class
'
,
()
=>
{
expect
(
$resultantNote
.
hasClass
(
'
fade-in
'
)).
toEqual
(
true
);
});
it
(
'
should init the note jquery object
'
,
()
=>
{
expect
(
window
.
$
).
toHaveBeenCalledWith
(
noteHTML
);
it
(
'
should append note to the notes list
'
,
()
=>
{
expect
(
$notesList
.
append
).
toHaveBeenCalledWith
(
$resultantNote
);
});
});
it
(
'
should call addClass
'
,
()
=>
{
expect
(
$note
.
addClass
).
toHaveBeenCalledWith
(
'
fade-in
'
);
describe
(
'
animateUpdateNote
'
,
()
=>
{
let
noteHTML
;
let
$note
;
let
$updatedNote
;
beforeEach
(()
=>
{
noteHTML
=
'
<div></div>
'
;
$note
=
jasmine
.
createSpyObj
(
'
$note
'
,
[
'
replaceWith
'
]);
$updatedNote
=
Notes
.
animateUpdateNote
(
noteHTML
,
$note
);
});
it
(
'
should call renderGFM
'
,
()
=>
{
expect
(
$note
.
renderGFM
).
toHaveBeenCalledWith
();
it
(
'
should have `fade-in` class
'
,
()
=>
{
expect
(
$updatedNote
.
hasClass
(
'
fade-in
'
)).
toEqual
(
true
);
});
it
(
'
should
append note to the notes list
'
,
()
=>
{
expect
(
$note
sList
.
append
).
toHaveBeenCalledWith
(
$n
ote
);
it
(
'
should
call replaceWith on $note
'
,
()
=>
{
expect
(
$note
.
replaceWith
).
toHaveBeenCalledWith
(
$updatedN
ote
);
});
});
});
...
...
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