Commit 645593e5 authored by Kushal Pandya's avatar Kushal Pandya Committed by Filipa Lacerda

Add instant comments support

parent a5347fe5
...@@ -43,8 +43,8 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => { ...@@ -43,8 +43,8 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
const $submitButton = $form.find('input[type=submit], button[type=submit]'); const $submitButton = $form.find('input[type=submit], button[type=submit]');
if (!$submitButton.attr('disabled')) { if (!$submitButton.attr('disabled')) {
$submitButton.trigger('click', [e]);
$submitButton.disable(); $submitButton.disable();
$form.submit();
} }
}); });
......
...@@ -35,6 +35,14 @@ ...@@ -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) { w.gl.utils.extractLast = function(term) {
return this.split(term).pop(); return this.split(term).pop();
}; };
......
...@@ -26,12 +26,13 @@ const normalizeNewlines = function(str) { ...@@ -26,12 +26,13 @@ const normalizeNewlines = function(str) {
this.Notes = (function() { this.Notes = (function() {
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
const REGEX_SLASH_COMMANDS = /\/\w+/g;
Notes.interval = null; Notes.interval = null;
function Notes(notes_url, note_ids, last_fetched_at, view) { function Notes(notes_url, note_ids, last_fetched_at, view) {
this.updateTargetButtons = bind(this.updateTargetButtons, this); 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.visibilityChange = bind(this.visibilityChange, this);
this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this); this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this);
this.addDiffNote = bind(this.addDiffNote, this); this.addDiffNote = bind(this.addDiffNote, this);
...@@ -47,6 +48,7 @@ const normalizeNewlines = function(str) { ...@@ -47,6 +48,7 @@ const normalizeNewlines = function(str) {
this.refresh = bind(this.refresh, this); this.refresh = bind(this.refresh, this);
this.keydownNoteText = bind(this.keydownNoteText, this); this.keydownNoteText = bind(this.keydownNoteText, this);
this.toggleCommitList = bind(this.toggleCommitList, this); this.toggleCommitList = bind(this.toggleCommitList, this);
this.postComment = bind(this.postComment, this);
this.notes_url = notes_url; this.notes_url = notes_url;
this.note_ids = note_ids; this.note_ids = note_ids;
...@@ -82,28 +84,19 @@ const normalizeNewlines = function(str) { ...@@ -82,28 +84,19 @@ const normalizeNewlines = function(str) {
}; };
Notes.prototype.addBinding = function() { 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 // Edit note link
$(document).on("click", ".js-note-edit", this.showEditForm.bind(this)); $(document).on("click", ".js-note-edit", this.showEditForm.bind(this));
$(document).on("click", ".note-edit-cancel", this.cancelEdit); $(document).on("click", ".note-edit-cancel", this.cancelEdit);
// Reopen and close actions for Issue/MR combined with note form submit // 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); $(document).on("keyup input", ".js-note-text", this.updateTargetButtons);
// resolve a discussion // 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) // remove a note (in general)
$(document).on("click", ".js-note-delete", this.removeNote); $(document).on("click", ".js-note-delete", this.removeNote);
// delete note attachment // delete note attachment
$(document).on("click", ".js-note-attachment-delete", this.removeAttachment); $(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 // reset main target form when clicking discard
$(document).on("click", ".js-note-discard", this.resetMainTargetForm); $(document).on("click", ".js-note-discard", this.resetMainTargetForm);
// update the file name when an attachment is selected // update the file name when an attachment is selected
...@@ -120,20 +113,20 @@ const normalizeNewlines = function(str) { ...@@ -120,20 +113,20 @@ const normalizeNewlines = function(str) {
$(document).on("visibilitychange", this.visibilityChange); $(document).on("visibilitychange", this.visibilityChange);
// when issue status changes, we need to refresh data // when issue status changes, we need to refresh data
$(document).on("issuable:change", this.refresh); $(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 // when a key is clicked on the notes
return $(document).on("keydown", ".js-note-text", this.keydownNoteText); return $(document).on("keydown", ".js-note-text", this.keydownNoteText);
}; };
Notes.prototype.cleanBinding = function() { 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", ".js-note-edit");
$(document).off("click", ".note-edit-cancel"); $(document).off("click", ".note-edit-cancel");
$(document).off("click", ".js-note-delete"); $(document).off("click", ".js-note-delete");
$(document).off("click", ".js-note-attachment-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-discussion-reply-button");
$(document).off("click", ".js-add-diff-note-button"); $(document).off("click", ".js-add-diff-note-button");
$(document).off("visibilitychange"); $(document).off("visibilitychange");
...@@ -144,6 +137,9 @@ const normalizeNewlines = function(str) { ...@@ -144,6 +137,9 @@ const normalizeNewlines = function(str) {
$(document).off("keydown", ".js-note-text"); $(document).off("keydown", ".js-note-text");
$(document).off('click', '.js-comment-resolve-button'); $(document).off('click', '.js-comment-resolve-button');
$(document).off("click", '.system-note-commit-list-toggler'); $(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) { Notes.initCommentTypeToggle = function (form) {
...@@ -276,12 +272,8 @@ const normalizeNewlines = function(str) { ...@@ -276,12 +272,8 @@ const normalizeNewlines = function(str) {
return this.initRefresh(); return this.initRefresh();
}; };
Notes.prototype.handleCreateChanges = function(noteEntity) { Notes.prototype.handleSlashCommands = function(noteEntity) {
var votesBlock; var votesBlock;
if (typeof noteEntity === 'undefined') {
return;
}
if (noteEntity.commands_changes) { if (noteEntity.commands_changes) {
if ('merge' in noteEntity.commands_changes) { if ('merge' in noteEntity.commands_changes) {
$.get(mrRefreshWidgetUrl); $.get(mrRefreshWidgetUrl);
...@@ -556,24 +548,29 @@ const normalizeNewlines = function(str) { ...@@ -556,24 +548,29 @@ const normalizeNewlines = function(str) {
Adds new note to list. Adds new note to list.
*/ */
Notes.prototype.addNote = function(xhr, note, status) { Notes.prototype.addNote = function($form, note) {
this.handleCreateChanges(note);
return this.renderNote(note); return this.renderNote(note);
}; };
Notes.prototype.addNoteError = function(xhr, note, status) { Notes.prototype.addNoteError = ($form) => {
return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', this.parentTimeline); 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 Called in response to the new note form being submitted
Adds new note to list. Adds new note to list.
*/ */
Notes.prototype.addDiscussionNote = function(xhr, note, status) { Notes.prototype.addDiscussionNote = function($form, note, isNewDiffComment) {
var $form = $(xhr.target);
if ($form.attr('data-resolve-all') != null) { if ($form.attr('data-resolve-all') != null) {
var projectPath = $form.data('project-path'); var projectPath = $form.data('project-path');
var discussionId = $form.data('discussion-id'); var discussionId = $form.data('discussion-id');
...@@ -586,7 +583,9 @@ const normalizeNewlines = function(str) { ...@@ -586,7 +583,9 @@ const normalizeNewlines = function(str) {
this.renderNote(note, $form); this.renderNote(note, $form);
// cleanup after successfully creating a diff/discussion note // cleanup after successfully creating a diff/discussion note
this.removeDiscussionNoteForm($form); if (isNewDiffComment) {
this.removeDiscussionNoteForm($form);
}
}; };
/* /*
...@@ -596,17 +595,18 @@ const normalizeNewlines = function(str) { ...@@ -596,17 +595,18 @@ const normalizeNewlines = function(str) {
*/ */
Notes.prototype.updateNote = function(_xhr, noteEntity, _status) { 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 // 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(); this.revertNoteEditForm();
gl.utils.localTimeAgo($('.js-timeago', $html)); gl.utils.localTimeAgo($('.js-timeago', $noteEntityEl));
$html.renderGFM(); $noteEntityEl.renderGFM();
$html.find('.js-task-list-container').taskList('enable'); $noteEntityEl.find('.js-task-list-container').taskList('enable');
// Find the note's `li` element by ID and replace it with the updated HTML // Find the note's `li` element by ID and replace it with the updated HTML
$note_li = $('.note-row-' + noteEntity.id); $note_li = $('.note-row-' + noteEntity.id);
$note_li.replaceWith($html); $note_li.replaceWith($noteEntityEl);
if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents(); gl.diffNotesCompileComponents();
...@@ -698,7 +698,7 @@ const normalizeNewlines = function(str) { ...@@ -698,7 +698,7 @@ const normalizeNewlines = function(str) {
var $editForm = $(selector); var $editForm = $(selector);
$editForm.insertBefore('.notes-form'); $editForm.insertBefore('.notes-form');
$editForm.find('.js-comment-button').enable(); $editForm.find('.js-comment-save-button').enable();
$editForm.find('.js-finish-edit-warning').hide(); $editForm.find('.js-finish-edit-warning').hide();
}; };
...@@ -982,14 +982,6 @@ const normalizeNewlines = function(str) { ...@@ -982,14 +982,6 @@ const normalizeNewlines = function(str) {
return this.refresh(); 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) { Notes.prototype.updateTargetButtons = function(e) {
var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea; var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea;
textarea = $(e.target); textarea = $(e.target);
...@@ -1078,17 +1070,6 @@ const normalizeNewlines = function(str) { ...@@ -1078,17 +1070,6 @@ const normalizeNewlines = function(str) {
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); 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) { Notes.prototype.toggleCommitList = function(e) {
const $element = $(e.currentTarget); const $element = $(e.currentTarget);
const $closestSystemCommitList = $element.siblings('.system-note-commit-list'); const $closestSystemCommitList = $element.siblings('.system-note-commit-list');
...@@ -1137,7 +1118,7 @@ const normalizeNewlines = function(str) { ...@@ -1137,7 +1118,7 @@ const normalizeNewlines = function(str) {
Notes.animateAppendNote = function(noteHtml, $notesList) { Notes.animateAppendNote = function(noteHtml, $notesList) {
const $note = $(noteHtml); const $note = $(noteHtml);
$note.addClass('fade-in').renderGFM(); $note.addClass('fade-in-full').renderGFM();
$notesList.append($note); $notesList.append($note);
return $note; return $note;
}; };
...@@ -1150,6 +1131,254 @@ const normalizeNewlines = function(str) { ...@@ -1150,6 +1131,254 @@ const normalizeNewlines = function(str) {
return $updatedNote; 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; return Notes;
})(); })();
}).call(window); }).call(window);
...@@ -159,3 +159,31 @@ a { ...@@ -159,3 +159,31 @@ a {
.fade-in { .fade-in {
animation: fadeIn $fade-in-duration 1; 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;
}
...@@ -57,6 +57,25 @@ ul.notes { ...@@ -57,6 +57,25 @@ ul.notes {
position: relative; position: relative;
border-bottom: 1px solid $white-normal; 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 { &.note-discussion {
&.timeline-entry { &.timeline-entry {
padding: 14px 10px; padding: 14px 10px;
...@@ -687,6 +706,10 @@ ul.notes { ...@@ -687,6 +706,10 @@ ul.notes {
} }
} }
.discussion-notes .flash-container {
margin-bottom: 0;
}
// Merge request notes in diffs // Merge request notes in diffs
.diff-file { .diff-file {
// Diff is side by side // Diff is side by side
......
.discussion-notes .discussion-notes
%ul.notes{ data: { discussion_id: discussion.id } } %ul.notes{ data: { discussion_id: discussion.id } }
= render partial: "shared/notes/note", collection: discussion.notes, as: :note = render partial: "shared/notes/note", collection: discussion.notes, as: :note
.flash-container
- if current_user - if current_user
.discussion-reply-holder .discussion-reply-holder
......
...@@ -9,6 +9,6 @@ ...@@ -9,6 +9,6 @@
.note-form-actions.clearfix .note-form-actions.clearfix
.settings-message.note-edit-warning.js-finish-edit-warning .settings-message.note-edit-warning.js-finish-edit-warning
Finish editing this message first! 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' } %button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' }
Cancel Cancel
---
title: Add support for instantly updating comments
merge_request: 10760
author:
...@@ -458,6 +458,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps ...@@ -458,6 +458,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
click_button "Comment" click_button "Comment"
end end
wait_for_ajax
page.within ".files>div:nth-child(2) .note-body > .note-text" do page.within ".files>div:nth-child(2) .note-body > .note-text" do
expect(page).to have_content "Line is correct" expect(page).to have_content "Line is correct"
end end
...@@ -470,6 +472,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps ...@@ -470,6 +472,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
fill_in "note_note", with: "Line is wrong on here" fill_in "note_note", with: "Line is wrong on here"
click_button "Comment" click_button "Comment"
end end
wait_for_ajax
end end
step 'I should still see a comment like "Line is correct" in the second file' do 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 ...@@ -574,6 +578,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
fill_in "note_note", with: message fill_in "note_note", with: message
click_button "Comment" click_button "Comment"
end end
wait_for_ajax
page.within(".notes_holder", visible: true) do page.within(".notes_holder", visible: true) do
expect(page).to have_content message expect(page).to have_content message
end end
......
...@@ -24,6 +24,8 @@ module SharedNote ...@@ -24,6 +24,8 @@ module SharedNote
fill_in "note[note]", with: "XML attached" fill_in "note[note]", with: "XML attached"
click_button "Comment" click_button "Comment"
end end
wait_for_ajax
end end
step 'I preview a comment text like "Bug fixed :smile:"' do step 'I preview a comment text like "Bug fixed :smile:"' do
...@@ -37,6 +39,8 @@ module SharedNote ...@@ -37,6 +39,8 @@ module SharedNote
page.within(".js-main-target-form") do page.within(".js-main-target-form") do
click_button "Comment" click_button "Comment"
end end
wait_for_ajax
end end
step 'I write a comment like ":+1: Nice"' do step 'I write a comment like ":+1: Nice"' do
......
...@@ -98,6 +98,7 @@ describe 'Merge requests > User posts notes', :js do ...@@ -98,6 +98,7 @@ describe 'Merge requests > User posts notes', :js do
find('.btn-save').click find('.btn-save').click
end end
wait_for_ajax
find('.note').hover find('.note').hover
find('.js-note-edit').click find('.js-note-edit').click
......
...@@ -160,6 +160,7 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do ...@@ -160,6 +160,7 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do
it 'changes target branch from a note' do it 'changes target branch from a note' do
write_note("message start \n/target_branch merge-test\n message end.") 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).not_to have_content('/target_branch')
expect(page).to have_content('message start') expect(page).to have_content('message start')
expect(page).to have_content('message end.') expect(page).to have_content('message end.')
......
...@@ -362,5 +362,16 @@ require('~/lib/utils/common_utils'); ...@@ -362,5 +362,16 @@ require('~/lib/utils/common_utils');
gl.utils.setCiStatusFavicon(BUILD_URL); 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');
});
});
}); });
})(); })();
...@@ -26,7 +26,7 @@ import '~/notes'; ...@@ -26,7 +26,7 @@ import '~/notes';
describe('task lists', function() { describe('task lists', function() {
beforeEach(function() { beforeEach(function() {
$('form').on('submit', function(e) { $('.js-comment-button').on('click', function(e) {
e.preventDefault(); e.preventDefault();
}); });
this.notes = new Notes(); this.notes = new Notes();
...@@ -60,9 +60,12 @@ import '~/notes'; ...@@ -60,9 +60,12 @@ import '~/notes';
reset: function() {} reset: function() {}
}); });
$('form').on('submit', function(e) { $('.js-comment-button').on('click', (e) => {
const $form = $(this);
e.preventDefault(); 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'; ...@@ -238,8 +241,8 @@ import '~/notes';
$resultantNote = Notes.animateAppendNote(noteHTML, $notesList); $resultantNote = Notes.animateAppendNote(noteHTML, $notesList);
}); });
it('should have `fade-in` class', () => { it('should have `fade-in-full` class', () => {
expect($resultantNote.hasClass('fade-in')).toEqual(true); expect($resultantNote.hasClass('fade-in-full')).toEqual(true);
}); });
it('should append note to the notes list', () => { it('should append note to the notes list', () => {
...@@ -269,5 +272,221 @@ import '~/notes'; ...@@ -269,5 +272,221 @@ import '~/notes';
expect($note.replaceWith).toHaveBeenCalledWith($updatedNote); 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); }).call(window);
...@@ -58,6 +58,7 @@ shared_examples 'issuable record that supports slash commands in its description ...@@ -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 '/label ~bug'
expect(page).not_to have_content '/milestone %"ASAP"' expect(page).not_to have_content '/milestone %"ASAP"'
wait_for_ajax
issuable.reload issuable.reload
note = issuable.notes.user.first note = issuable.notes.user.first
......
...@@ -8,6 +8,7 @@ shared_examples 'issuable time tracker' do ...@@ -8,6 +8,7 @@ shared_examples 'issuable time tracker' do
it 'updates the sidebar component when estimate is added' do it 'updates the sidebar component when estimate is added' do
submit_time('/estimate 3w 1d 1h') submit_time('/estimate 3w 1d 1h')
wait_for_ajax
page.within '.time-tracking-estimate-only-pane' do page.within '.time-tracking-estimate-only-pane' do
expect(page).to have_content '3w 1d 1h' expect(page).to have_content '3w 1d 1h'
end end
...@@ -16,6 +17,7 @@ shared_examples 'issuable time tracker' do ...@@ -16,6 +17,7 @@ shared_examples 'issuable time tracker' do
it 'updates the sidebar component when spent is added' do it 'updates the sidebar component when spent is added' do
submit_time('/spend 3w 1d 1h') submit_time('/spend 3w 1d 1h')
wait_for_ajax
page.within '.time-tracking-spend-only-pane' do page.within '.time-tracking-spend-only-pane' do
expect(page).to have_content '3w 1d 1h' expect(page).to have_content '3w 1d 1h'
end end
...@@ -25,6 +27,7 @@ shared_examples 'issuable time tracker' do ...@@ -25,6 +27,7 @@ shared_examples 'issuable time tracker' do
submit_time('/estimate 3w 1d 1h') submit_time('/estimate 3w 1d 1h')
submit_time('/spend 3w 1d 1h') submit_time('/spend 3w 1d 1h')
wait_for_ajax
page.within '.time-tracking-comparison-pane' do page.within '.time-tracking-comparison-pane' do
expect(page).to have_content '3w 1d 1h' expect(page).to have_content '3w 1d 1h'
end end
...@@ -34,6 +37,7 @@ shared_examples 'issuable time tracker' do ...@@ -34,6 +37,7 @@ shared_examples 'issuable time tracker' do
submit_time('/estimate 3w 1d 1h') submit_time('/estimate 3w 1d 1h')
submit_time('/remove_estimate') submit_time('/remove_estimate')
wait_for_ajax
page.within '#issuable-time-tracker' do page.within '#issuable-time-tracker' do
expect(page).to have_content 'No estimate or time spent' expect(page).to have_content 'No estimate or time spent'
end end
...@@ -43,6 +47,7 @@ shared_examples 'issuable time tracker' do ...@@ -43,6 +47,7 @@ shared_examples 'issuable time tracker' do
submit_time('/spend 3w 1d 1h') submit_time('/spend 3w 1d 1h')
submit_time('/remove_time_spent') submit_time('/remove_time_spent')
wait_for_ajax
page.within '#issuable-time-tracker' do page.within '#issuable-time-tracker' do
expect(page).to have_content 'No estimate or time spent' expect(page).to have_content 'No estimate or time spent'
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment