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