summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFilipa Lacerda <filipa@gitlab.com>2017-05-05 10:57:29 +0000
committerFilipa Lacerda <filipa@gitlab.com>2017-05-05 10:57:29 +0000
commit15acb6c5a713c9e02b6cf4f228e85e0b841013e0 (patch)
tree514b8e8acbc10e915c50971c31b7fab6fdbcb18f
parenta5347fe58f6ace1ced67fa32a8469ba4e2819606 (diff)
parent645593e5af1fe9e7fa345788aeaa90d2313d6486 (diff)
downloadgitlab-ce-15acb6c5a713c9e02b6cf4f228e85e0b841013e0.tar.gz
Merge branch '27614-instant-comments' into 'master'
Add instant comments support Closes #27614 See merge request !10760
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js2
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js8
-rw-r--r--app/assets/javascripts/notes.js345
-rw-r--r--app/assets/stylesheets/framework/animations.scss28
-rw-r--r--app/assets/stylesheets/pages/notes.scss23
-rw-r--r--app/views/discussions/_notes.html.haml1
-rw-r--r--app/views/projects/notes/_edit_form.html.haml2
-rw-r--r--changelogs/unreleased/27614-instant-comments.yml4
-rw-r--r--features/steps/project/merge_requests.rb7
-rw-r--r--features/steps/shared/note.rb4
-rw-r--r--spec/features/merge_requests/user_posts_notes_spec.rb1
-rw-r--r--spec/features/merge_requests/user_uses_slash_commands_spec.rb1
-rw-r--r--spec/javascripts/lib/utils/common_utils_spec.js11
-rw-r--r--spec/javascripts/notes_spec.js229
-rw-r--r--spec/support/features/issuable_slash_commands_shared_examples.rb1
-rw-r--r--spec/support/time_tracking_shared_examples.rb5
16 files changed, 607 insertions, 65 deletions
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index 3d162b24413..1f9e0448084 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -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();
}
});
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 8058672eaa9..2f682fbd2fb 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -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();
};
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 87f03a40eba..72709f68070 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -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.updateCloseButton = bind(this.updateCloseButton, this);
+ this.updateComment = 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.handleCreateChanges = function(noteEntity) {
+ Notes.prototype.handleSlashCommands = 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 $html, $note_li;
+ var $noteEntityEl, $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', $html));
- $html.renderGFM();
- $html.find('.js-task-list-container').taskList('enable');
+ gl.utils.localTimeAgo($('.js-timeago', $noteEntityEl));
+ $noteEntityEl.renderGFM();
+ $noteEntityEl.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($html);
+ $note_li.replaceWith($noteEntityEl);
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);
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 7c50b80fd2b..3cd7f81da47 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -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;
+}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index f89150ebead..cfea52c6e57 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -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
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index 964473ee3e0..7ba3f3f6c42 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -1,6 +1,7 @@
.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
diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/projects/notes/_edit_form.html.haml
index 00230b0bdf8..3867072225f 100644
--- a/app/views/projects/notes/_edit_form.html.haml
+++ b/app/views/projects/notes/_edit_form.html.haml
@@ -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
diff --git a/changelogs/unreleased/27614-instant-comments.yml b/changelogs/unreleased/27614-instant-comments.yml
new file mode 100644
index 00000000000..7b2592f46ed
--- /dev/null
+++ b/changelogs/unreleased/27614-instant-comments.yml
@@ -0,0 +1,4 @@
+---
+title: Add support for instantly updating comments
+merge_request: 10760
+author:
diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb
index a06b2f2911f..4b7d6cd840b 100644
--- a/features/steps/project/merge_requests.rb
+++ b/features/steps/project/merge_requests.rb
@@ -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
diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb
index 7885cc7ab77..7d260025052 100644
--- a/features/steps/shared/note.rb
+++ b/features/steps/shared/note.rb
@@ -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
diff --git a/spec/features/merge_requests/user_posts_notes_spec.rb b/spec/features/merge_requests/user_posts_notes_spec.rb
index c7cc4d6bc72..7fc0e2ce6ec 100644
--- a/spec/features/merge_requests/user_posts_notes_spec.rb
+++ b/spec/features/merge_requests/user_posts_notes_spec.rb
@@ -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
diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb
index 1c0f21e5616..f0ad57eb92f 100644
--- a/spec/features/merge_requests/user_uses_slash_commands_spec.rb
+++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb
@@ -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.')
diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js
index a00efa10119..5eb147ed888 100644
--- a/spec/javascripts/lib/utils/common_utils_spec.js
+++ b/spec/javascripts/lib/utils/common_utils_spec.js
@@ -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');
+ });
+ });
});
})();
diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js
index cdc5c4510ff..7bffa90ab14 100644
--- a/spec/javascripts/notes_spec.js
+++ b/spec/javascripts/notes_spec.js
@@ -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);
diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb
index 6efcdd7a1de..610decdcddb 100644
--- a/spec/support/features/issuable_slash_commands_shared_examples.rb
+++ b/spec/support/features/issuable_slash_commands_shared_examples.rb
@@ -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
diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb
index 01bc80f957e..e94e17da7e5 100644
--- a/spec/support/time_tracking_shared_examples.rb
+++ b/spec/support/time_tracking_shared_examples.rb
@@ -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