提交 15acb6c5 编写于 作者: F Filipa Lacerda

Merge branch '27614-instant-comments' into 'master'

Add instant comments support

Closes #27614

See merge request !10760
......@@ -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();
};
......
此差异已折叠。
......@@ -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
......
.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
---
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
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册