Add instant comments support
This commit is contained in:
parent
a5347fe58f
commit
645593e5af
16 changed files with 607 additions and 65 deletions
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
4
changelogs/unreleased/27614-instant-comments.yml
Normal file
4
changelogs/unreleased/27614-instant-comments.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Add support for instantly updating comments
|
||||
merge_request: 10760
|
||||
author:
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.')
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue