diff options
51 files changed, 1803 insertions, 1695 deletions
@@ -122,6 +122,7 @@ gem 'asciidoctor', '~> 1.5.2' gem 'asciidoctor-plantuml', '0.0.7' gem 'rouge', '~> 2.0' gem 'truncato', '~> 0.7.8' +gem 'bootstrap_form', '~> 2.7.0' # See https://groups.google.com/forum/#!topic/ruby-security-ann/aSbgDiwb24s # and https://groups.google.com/forum/#!topic/ruby-security-ann/Dy7YiKb_pMM diff --git a/Gemfile.lock b/Gemfile.lock index 6226c6bebdf..4715363c5b8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,6 +86,7 @@ GEM bootstrap-sass (3.3.6) autoprefixer-rails (>= 5.2.1) sass (>= 3.3.4) + bootstrap_form (2.7.0) brakeman (3.6.1) browser (2.2.0) builder (3.2.3) @@ -926,6 +927,7 @@ DEPENDENCIES better_errors (~> 2.1.0) binding_of_caller (~> 0.7.2) bootstrap-sass (~> 3.3.0) + bootstrap_form (~> 2.7.0) brakeman (~> 3.6.0) browser (~> 2.2) bullet (~> 5.5.0) diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js index b1c47b09c35..4af8b0c7713 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.js +++ b/app/assets/javascripts/boards/components/board_new_issue.js @@ -17,7 +17,7 @@ export default { methods: { submit(e) { e.preventDefault(); - if (this.title.trim() === '') return; + if (this.title.trim() === '') return Promise.resolve(); this.error = false; @@ -29,7 +29,10 @@ export default { assignees: [], }); - this.list.newIssue(issue) + eventHub.$emit(`scroll-board-list-${this.list.id}`); + this.cancel(); + + return this.list.newIssue(issue) .then(() => { // Need this because our jQuery very kindly disables buttons on ALL form submissions $(this.$refs.submitButton).enable(); @@ -47,9 +50,6 @@ export default { // Show error message this.error = true; }); - - eventHub.$emit(`scroll-board-list-${this.list.id}`); - this.cancel(); }, cancel() { this.title = ''; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 548de1a4c52..b4b09b3876e 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -112,8 +112,7 @@ class List { .then((resp) => { const data = resp.json(); issue.id = data.iid; - }) - .then(() => { + if (this.issuesSize > 1) { const moveBeforeIid = this.issues[1].id; gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index c7c8d42e677..1425769d2de 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -40,6 +40,10 @@ class FilteredSearchManager { return []; }) .then((searches) => { + if (!searches) { + return; + } + // Put any searches that may have come in before // we fetched the saved searches ahead of the already saved ones const resultantSearches = this.recentSearchesStore.setRecentSearches( diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 2aca86189fd..122ec138c59 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -86,18 +86,25 @@ // This is required to handle non-unicode characters in hash hash = decodeURIComponent(hash); + var fixedTabs = document.querySelector('.js-tabs-affix'); + var fixedNav = document.querySelector('.navbar-gitlab'); + + var adjustment = 0; + if (fixedNav) adjustment -= fixedNav.offsetHeight; + // scroll to user-generated markdown anchor if we cannot find a match if (document.getElementById(hash) === null) { var target = document.getElementById('user-content-' + hash); if (target && target.scrollIntoView) { target.scrollIntoView(true); + window.scrollBy(0, adjustment); } } else { // only adjust for fixedTabs when not targeting user-generated content - var fixedTabs = document.querySelector('.js-tabs-affix'); if (fixedTabs) { - window.scrollBy(0, -fixedTabs.offsetHeight); + adjustment -= fixedTabs.offsetHeight; } + window.scrollBy(0, adjustment); } }; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index d2c64182248..786b6014dc6 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -155,7 +155,10 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; scrollToElement(container) { if (location.hash) { - const offset = -$('.js-tabs-affix').outerHeight(); + const offset = 0 - ( + $('.navbar-gitlab').outerHeight() + + $('.js-tabs-affix').outerHeight() + ); const $el = $(`${container} ${location.hash}:not(.match)`); if ($el.length) { $.scrollTo($el[0], { offset }); @@ -301,6 +304,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; forceShow: true, }); anchor[0].scrollIntoView(); + window.gl.utils.handleLocationHash(); // We have multiple elements on the page with `#note_xxx` // (discussion and diff tabs) and `:target` only applies to the first anchor.addClass('target'); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 624dd336786..b21d7774920 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -4,7 +4,7 @@ no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line, default-case, prefer-template, consistent-return, no-alert, no-return-assign, no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new, brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow, -newline-per-chained-call, no-useless-escape */ +newline-per-chained-call, no-useless-escape, class-methods-use-this */ /* global Flash */ /* global Autosave */ /* global ResolveService */ @@ -25,1507 +25,1489 @@ import './task_list'; window.autosize = autosize; window.Dropzone = Dropzone; -const normalizeNewlines = function(str) { +function normalizeNewlines(str) { return str.replace(/\r\n/g, '\n'); -}; - -(function() { - this.Notes = (function() { - const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; - const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; - - Notes.interval = null; - - function Notes(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { - this.updateTargetButtons = this.updateTargetButtons.bind(this); - this.updateComment = this.updateComment.bind(this); - this.visibilityChange = this.visibilityChange.bind(this); - this.cancelDiscussionForm = this.cancelDiscussionForm.bind(this); - this.onAddDiffNote = this.onAddDiffNote.bind(this); - this.setupDiscussionNoteForm = this.setupDiscussionNoteForm.bind(this); - this.onReplyToDiscussionNote = this.onReplyToDiscussionNote.bind(this); - this.removeNote = this.removeNote.bind(this); - this.cancelEdit = this.cancelEdit.bind(this); - this.updateNote = this.updateNote.bind(this); - this.addDiscussionNote = this.addDiscussionNote.bind(this); - this.addNoteError = this.addNoteError.bind(this); - this.addNote = this.addNote.bind(this); - this.resetMainTargetForm = this.resetMainTargetForm.bind(this); - this.refresh = this.refresh.bind(this); - this.keydownNoteText = this.keydownNoteText.bind(this); - this.toggleCommitList = this.toggleCommitList.bind(this); - this.postComment = this.postComment.bind(this); - this.clearFlashWrapper = this.clearFlash.bind(this); - this.onHashChange = this.onHashChange.bind(this); - - this.notes_url = notes_url; - this.note_ids = note_ids; - this.enableGFM = enableGFM; - // 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(); - this.setupMainTargetNoteForm(); - this.taskList = new gl.TaskList({ - dataType: 'note', - fieldName: 'note', - selector: '.notes' - }); - this.collapseLongCommitList(); - this.setViewType(view); - - // We are in the Merge Requests page so we need another edit form for Changes tab - if (gl.utils.getPagePath(1) === 'merge_requests') { - $('.note-edit-form').clone() - .addClass('mr-note-edit-form').insertAfter('.note-edit-form'); - } +} + +const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; +const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; + +export default class Notes { + constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { + this.updateTargetButtons = this.updateTargetButtons.bind(this); + this.updateComment = this.updateComment.bind(this); + this.visibilityChange = this.visibilityChange.bind(this); + this.cancelDiscussionForm = this.cancelDiscussionForm.bind(this); + this.onAddDiffNote = this.onAddDiffNote.bind(this); + this.setupDiscussionNoteForm = this.setupDiscussionNoteForm.bind(this); + this.onReplyToDiscussionNote = this.onReplyToDiscussionNote.bind(this); + this.removeNote = this.removeNote.bind(this); + this.cancelEdit = this.cancelEdit.bind(this); + this.updateNote = this.updateNote.bind(this); + this.addDiscussionNote = this.addDiscussionNote.bind(this); + this.addNoteError = this.addNoteError.bind(this); + this.addNote = this.addNote.bind(this); + this.resetMainTargetForm = this.resetMainTargetForm.bind(this); + this.refresh = this.refresh.bind(this); + this.keydownNoteText = this.keydownNoteText.bind(this); + this.toggleCommitList = this.toggleCommitList.bind(this); + this.postComment = this.postComment.bind(this); + this.clearFlashWrapper = this.clearFlash.bind(this); + this.onHashChange = this.onHashChange.bind(this); + + this.notes_url = notes_url; + this.note_ids = note_ids; + this.enableGFM = enableGFM; + // 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(); + this.setupMainTargetNoteForm(); + this.taskList = new gl.TaskList({ + dataType: 'note', + fieldName: 'note', + selector: '.notes' + }); + this.collapseLongCommitList(); + this.setViewType(view); + + // We are in the Merge Requests page so we need another edit form for Changes tab + if (gl.utils.getPagePath(1) === 'merge_requests') { + $('.note-edit-form').clone() + .addClass('mr-note-edit-form').insertAfter('.note-edit-form'); + } + } + + setViewType(view) { + this.view = Cookies.get('diff_view') || view; + } + + addBinding() { + // 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-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.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 when clicking discard + $(document).on('click', '.js-note-discard', this.resetMainTargetForm); + // update the file name when an attachment is selected + $(document).on('change', '.js-note-attachment-input', this.updateFormAttachment); + // reply to diff/discussion notes + $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); + // add diff note + $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote); + // hide diff note form + $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); + // toggle commit list + $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList); + // fetch notes when tab becomes visible + $(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 + $(document).on('keydown', '.js-note-text', this.keydownNoteText); + // When the URL fragment/hash has changed, `#note_xxx` + return $(window).on('hashchange', this.onHashChange); + } + + cleanBinding() { + $(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('click', '.js-discussion-reply-button'); + $(document).off('click', '.js-add-diff-note-button'); + $(document).off('visibilitychange'); + $(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'); + $(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'); + $(window).off('hashchange', this.onHashChange); + } + + static initCommentTypeToggle(form) { + const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle'); + const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu'); + const noteTypeInput = form.querySelector('#note_type'); + const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button'); + const closeButton = form.querySelector('.js-note-target-close'); + const reopenButton = form.querySelector('.js-note-target-reopen'); + + const commentTypeToggle = new CommentTypeToggle({ + dropdownTrigger, + dropdownList, + noteTypeInput, + submitButton, + closeButton, + reopenButton, + }); + + commentTypeToggle.initDroplab(); + } + + keydownNoteText(e) { + var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText; + if (gl.utils.isMetaKey(e)) { + return; } - Notes.prototype.setViewType = function(view) { - this.view = Cookies.get('diff_view') || view; - }; - - Notes.prototype.addBinding = function() { - // 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-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.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 when clicking discard - $(document).on('click', '.js-note-discard', this.resetMainTargetForm); - // update the file name when an attachment is selected - $(document).on('change', '.js-note-attachment-input', this.updateFormAttachment); - // reply to diff/discussion notes - $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); - // add diff note - $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote); - // hide diff note form - $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); - // toggle commit list - $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList); - // fetch notes when tab becomes visible - $(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 - $(document).on('keydown', '.js-note-text', this.keydownNoteText); - // When the URL fragment/hash has changed, `#note_xxx` - return $(window).on('hashchange', this.onHashChange); - }; - - Notes.prototype.cleanBinding = function() { - $(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('click', '.js-discussion-reply-button'); - $(document).off('click', '.js-add-diff-note-button'); - $(document).off('visibilitychange'); - $(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'); - $(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'); - $(window).off('hashchange', this.onHashChange); - }; - - Notes.initCommentTypeToggle = function (form) { - const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle'); - const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu'); - const noteTypeInput = form.querySelector('#note_type'); - const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button'); - const closeButton = form.querySelector('.js-note-target-close'); - const reopenButton = form.querySelector('.js-note-target-reopen'); - - const commentTypeToggle = new CommentTypeToggle({ - dropdownTrigger, - dropdownList, - noteTypeInput, - submitButton, - closeButton, - reopenButton, - }); - - commentTypeToggle.initDroplab(); - }; - - Notes.prototype.keydownNoteText = function(e) { - var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText; - if (gl.utils.isMetaKey(e)) { - return; - } - - $textarea = $(e.target); - // Edit previous note when UP arrow is hit - switch (e.which) { - case 38: + $textarea = $(e.target); + // Edit previous note when UP arrow is hit + switch (e.which) { + case 38: + if ($textarea.val() !== '') { + return; + } + myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, .notes_holder, #notes')); + if (myLastNote.length) { + myLastNoteEditBtn = myLastNote.find('.js-note-edit'); + return myLastNoteEditBtn.trigger('click', [true, myLastNote]); + } + break; + // Cancel creating diff note or editing any note when ESCAPE is hit + case 27: + discussionNoteForm = $textarea.closest('.js-discussion-note-form'); + if (discussionNoteForm.length) { if ($textarea.val() !== '') { - return; - } - myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, .notes_holder, #notes')); - if (myLastNote.length) { - myLastNoteEditBtn = myLastNote.find('.js-note-edit'); - return myLastNoteEditBtn.trigger('click', [true, myLastNote]); - } - break; - // Cancel creating diff note or editing any note when ESCAPE is hit - case 27: - discussionNoteForm = $textarea.closest('.js-discussion-note-form'); - if (discussionNoteForm.length) { - if ($textarea.val() !== '') { - if (!confirm('Are you sure you want to cancel creating this comment?')) { - return; - } + if (!confirm('Are you sure you want to cancel creating this comment?')) { + return; } - this.removeDiscussionNoteForm(discussionNoteForm); - return; } - editNote = $textarea.closest('.note'); - if (editNote.length) { - originalText = $textarea.closest('form').data('original-note'); - newText = $textarea.val(); - if (originalText !== newText) { - if (!confirm('Are you sure you want to cancel editing this comment?')) { - return; - } + this.removeDiscussionNoteForm(discussionNoteForm); + return; + } + editNote = $textarea.closest('.note'); + if (editNote.length) { + originalText = $textarea.closest('form').data('original-note'); + newText = $textarea.val(); + if (originalText !== newText) { + if (!confirm('Are you sure you want to cancel editing this comment?')) { + return; } - return this.removeNoteEditForm(editNote); } - } - }; + return this.removeNoteEditForm(editNote); + } + } + } - Notes.prototype.initRefresh = function() { + initRefresh() { + if (Notes.interval) { clearInterval(Notes.interval); - return Notes.interval = setInterval((function(_this) { - return function() { - return _this.refresh(); - }; - })(this), this.pollingInterval); - }; + } + return Notes.interval = setInterval((function(_this) { + return function() { + return _this.refresh(); + }; + })(this), this.pollingInterval); + } - Notes.prototype.refresh = function() { - if (!document.hidden) { - return this.getContent(); - } - }; + refresh() { + if (!document.hidden) { + return this.getContent(); + } + } - Notes.prototype.getContent = function() { - if (this.refreshing) { - return; - } - this.refreshing = true; - return $.ajax({ - url: this.notes_url, - headers: { 'X-Last-Fetched-At': this.last_fetched_at }, - dataType: 'json', - success: (function(_this) { - return function(data) { - var notes; - notes = data.notes; - _this.last_fetched_at = data.last_fetched_at; - _this.setPollingInterval(data.notes.length); - return $.each(notes, function(i, note) { - _this.renderNote(note); - }); - }; - })(this) - }).always((function(_this) { - return function() { - return _this.refreshing = false; + getContent() { + if (this.refreshing) { + return; + } + this.refreshing = true; + return $.ajax({ + url: this.notes_url, + headers: { 'X-Last-Fetched-At': this.last_fetched_at }, + dataType: 'json', + success: (function(_this) { + return function(data) { + var notes; + notes = data.notes; + _this.last_fetched_at = data.last_fetched_at; + _this.setPollingInterval(data.notes.length); + return $.each(notes, function(i, note) { + _this.renderNote(note); + }); }; - })(this)); - }; - - /* - Increase @pollingInterval up to 120 seconds on every function call, - if `shouldReset` has a truthy value, 'null' or 'undefined' the variable - will reset to @basePollingInterval. - - Note: this function is used to gradually increase the polling interval - if there aren't new notes coming from the server - */ - - Notes.prototype.setPollingInterval = function(shouldReset) { - var nthInterval; - if (shouldReset == null) { - shouldReset = true; - } - nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); - if (shouldReset) { - this.pollingInterval = this.basePollingInterval; - } else if (this.pollingInterval < nthInterval) { - this.pollingInterval *= 2; + })(this) + }).always((function(_this) { + return function() { + return _this.refreshing = false; + }; + })(this)); + } + + /** + * Increase @pollingInterval up to 120 seconds on every function call, + * if `shouldReset` has a truthy value, 'null' or 'undefined' the variable + * will reset to @basePollingInterval. + * + * Note: this function is used to gradually increase the polling interval + * if there aren't new notes coming from the server + */ + setPollingInterval(shouldReset) { + var nthInterval; + if (shouldReset == null) { + shouldReset = true; + } + nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); + if (shouldReset) { + this.pollingInterval = this.basePollingInterval; + } else if (this.pollingInterval < nthInterval) { + this.pollingInterval *= 2; + } + return this.initRefresh(); + } + + handleQuickActions(noteEntity) { + var votesBlock; + if (noteEntity.commands_changes) { + if ('merge' in noteEntity.commands_changes) { + Notes.checkMergeRequestStatus(); } - return this.initRefresh(); - }; - - Notes.prototype.handleQuickActions = function(noteEntity) { - var votesBlock; - if (noteEntity.commands_changes) { - if ('merge' in noteEntity.commands_changes) { - Notes.checkMergeRequestStatus(); - } - if ('emoji_award' in noteEntity.commands_changes) { - votesBlock = $('.js-awards-block').eq(0); - gl.awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award); - return gl.awardsHandler.scrollToAwards(); - } + if ('emoji_award' in noteEntity.commands_changes) { + votesBlock = $('.js-awards-block').eq(0); + gl.awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award); + return gl.awardsHandler.scrollToAwards(); } - }; - - Notes.prototype.setupNewNote = function($note) { - // Update datetime format on the recent note - gl.utils.localTimeAgo($note.find('.js-timeago'), false); - - this.collapseLongCommitList(); - this.taskList.init(); - - // This stops the note highlight, #note_xxx`, from being removed after real time update - // The `:target` selector does not re-evaluate after we replace element in the DOM - Notes.updateNoteTargetSelector($note); - this.$noteToCleanHighlight = $note; - }; - - Notes.prototype.onHashChange = function() { - if (this.$noteToCleanHighlight) { - Notes.updateNoteTargetSelector(this.$noteToCleanHighlight); - } - - this.$noteToCleanHighlight = null; - }; - - Notes.updateNoteTargetSelector = function($note) { - const hash = gl.utils.getLocationHash(); - // Needs to be an explicit true/false for the jQuery `toggleClass(force)` - const addTargetClass = Boolean(hash && $note.filter(`#${hash}`).length > 0); - $note.toggleClass('target', addTargetClass); - }; - - /* - Render note in main comments area. + } + } - Note: for rendering inline notes use renderDiscussionNote - */ + setupNewNote($note) { + // Update datetime format on the recent note + gl.utils.localTimeAgo($note.find('.js-timeago'), false); - Notes.prototype.renderNote = function(noteEntity, $form, $notesList = $('.main-notes-list')) { - if (noteEntity.discussion_html) { - return this.renderDiscussionNote(noteEntity, $form); - } + this.collapseLongCommitList(); + this.taskList.init(); - if (!noteEntity.valid) { - if (noteEntity.errors.commands_only) { - this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline); - this.refresh(); - } - return; - } + // This stops the note highlight, #note_xxx`, from being removed after real time update + // The `:target` selector does not re-evaluate after we replace element in the DOM + Notes.updateNoteTargetSelector($note); + this.$noteToCleanHighlight = $note; + } - const $note = $notesList.find(`#note_${noteEntity.id}`); - if (Notes.isNewNote(noteEntity, this.note_ids)) { - this.note_ids.push(noteEntity.id); + onHashChange() { + if (this.$noteToCleanHighlight) { + Notes.updateNoteTargetSelector(this.$noteToCleanHighlight); + } - if ($notesList.length) { - $notesList.find('.system-note.being-posted').remove(); - } - const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList); + this.$noteToCleanHighlight = null; + } + + static updateNoteTargetSelector($note) { + const hash = gl.utils.getLocationHash(); + // Needs to be an explicit true/false for the jQuery `toggleClass(force)` + const addTargetClass = Boolean(hash && $note.filter(`#${hash}`).length > 0); + $note.toggleClass('target', addTargetClass); + } + + /** + * Render note in main comments area. + * + * Note: for rendering inline notes use renderDiscussionNote + */ + renderNote(noteEntity, $form, $notesList = $('.main-notes-list')) { + if (noteEntity.discussion_html) { + return this.renderDiscussionNote(noteEntity, $form); + } - this.setupNewNote($newNote); + if (!noteEntity.valid) { + if (noteEntity.errors.commands_only) { + this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline); 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 (Notes.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); - this.setupNewNote($updatedNote); - } - } - }; - - Notes.prototype.isParallelView = function() { - return Cookies.get('diff_view') === 'parallel'; - }; - - /* - Render note in discussion area. - - Note: for rendering inline notes use renderDiscussionNote - */ + return; + } - Notes.prototype.renderDiscussionNote = function(noteEntity, $form) { - var discussionContainer, form, row, lineType, diffAvatarContainer; - if (!Notes.isNewNote(noteEntity, this.note_ids)) { - return; - } + const $note = $notesList.find(`#note_${noteEntity.id}`); + if (Notes.isNewNote(noteEntity, this.note_ids)) { 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 = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); - if (!discussionContainer.length) { - discussionContainer = form.closest('.discussion').find('.notes'); - } - if (discussionContainer.length === 0) { - 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="${noteEntity.discussion_id}"]`); - var contentContainerClass = '.' + $notes.closest('.notes_content') - .attr('class') - .split(' ') - .join('.'); - - row.find(contentContainerClass + ' .content').append($notes.closest('.content').children()); - } - } - // Init discussion on 'Discussion' page if it is merge request page - const page = $('body').attr('data-page'); - if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) { - Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); - } - } else { - // append new note to all matching discussions - Notes.animateAppendNote(noteEntity.html, discussionContainer); - } - if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { - gl.diffNotesCompileComponents(); - this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); + if ($notesList.length) { + $notesList.find('.system-note.being-posted').remove(); } + const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList); - gl.utils.localTimeAgo($('.js-timeago'), false); - Notes.checkMergeRequestStatus(); + this.setupNewNote($newNote); + this.refresh(); return this.updateNotesCount(1); - }; - - Notes.prototype.getLineHolder = function(changesDiscussionContainer) { - return $(changesDiscussionContainer).closest('.notes_holder') - .prevAll('.line_holder') - .first() - .get(0); - }; - - 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', noteEntity.discussion_id); - - diffAvatarContainer.append(avatarHolder); + } + // The server can send the same update multiple times so we need to make sure to only update once per actual update. + else if (Notes.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; - gl.diffNotesCompileComponents(); + if (isEditing && isTextareaUntouched) { + $textarea.val(noteEntity.note); + this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; } - - if (commentButton.length) { - commentButton.remove(); + else if (isEditing && !isTextareaUntouched) { + this.putConflictEditWarningInPlace(noteEntity, $note); + this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; } - }; - - /* - Called in response the main target form has been successfully submitted. - - Removes any errors. - Resets text and preview. - Resets buttons. - */ - - Notes.prototype.resetMainTargetForm = function(e) { - var form; - form = $('.js-main-target-form'); - // remove validation errors - form.find('.js-errors').remove(); - // reset text and preview - form.find('.js-md-write-button').click(); - form.find('.js-note-text').val('').trigger('input'); - form.find('.js-note-text').data('autosave').reset(); - - var event = document.createEvent('Event'); - event.initEvent('autosize:update', true, false); - form.find('.js-autosize')[0].dispatchEvent(event); - - this.updateTargetButtons(e); - }; - - Notes.prototype.reenableTargetFormSubmitButton = function() { - var form; - form = $('.js-main-target-form'); - return form.find('.js-note-text').trigger('input'); - }; - - /* - Shows the main form and does some setup on it. - - Sets some hidden fields in the form. - */ - - Notes.prototype.setupMainTargetNoteForm = function() { - var form; - // find the form - form = $('.js-new-note-form'); - // Set a global clone of the form for later cloning - this.formClone = form.clone(); - // show the form - this.setupNoteForm(form); - // fix classes - form.removeClass('js-new-note-form'); - form.addClass('js-main-target-form'); - form.find('#note_line_code').remove(); - form.find('#note_position').remove(); - form.find('#note_type').val(''); - form.find('#in_reply_to_discussion_id').remove(); - form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove(); - this.parentTimeline = form.parents('.timeline'); - - if (form.length) { - Notes.initCommentTypeToggle(form.get(0)); - } - }; - - /* - General note form setup. - - deactivates the submit button when text is empty - hides the preview button when text is empty - setup GFM auto complete - show the form - */ - - Notes.prototype.setupNoteForm = function(form) { - var textarea, key; - new gl.GLForm(form, this.enableGFM); - textarea = form.find('.js-note-text'); - key = [ - 'Note', - form.find('#note_noteable_type').val(), - form.find('#note_noteable_id').val(), - form.find('#note_commit_id').val(), - form.find('#note_type').val(), - form.find('#in_reply_to_discussion_id').val(), - - // LegacyDiffNote - form.find('#note_line_code').val(), - - // DiffNote - form.find('#note_position').val() - ]; - return new Autosave(textarea, key); - }; - - /* - Called in response to the new note form being submitted - - Adds new note to list. - */ - - Notes.prototype.addNote = function($form, note) { - return this.renderNote(note); - }; - - Notes.prototype.addNoteError = function($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'); + else { + const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note); + this.setupNewNote($updatedNote); } - return this.addFlash('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($form, note, isNewDiffComment) { - if ($form.attr('data-resolve-all') != null) { - var projectPath = $form.data('project-path'); - var discussionId = $form.data('discussion-id'); - var mergeRequestId = $form.data('noteable-iid'); + } + } + + isParallelView() { + return Cookies.get('diff_view') === 'parallel'; + } + + /** + * Render note in discussion area. + * + * Note: for rendering inline notes use renderDiscussionNote + */ + renderDiscussionNote(noteEntity, $form) { + var discussionContainer, form, row, lineType, diffAvatarContainer; + if (!Notes.isNewNote(noteEntity, this.note_ids)) { + return; + } + 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 = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); + if (!discussionContainer.length) { + discussionContainer = form.closest('.discussion').find('.notes'); + } + if (discussionContainer.length === 0) { + if (noteEntity.diff_discussion_html) { + var $discussion = $(noteEntity.diff_discussion_html).renderGFM(); - if (ResolveService != null) { - ResolveService.toggleResolveForDiscussion(mergeRequestId, discussionId); + 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="${noteEntity.discussion_id}"]`); + var contentContainerClass = '.' + $notes.closest('.notes_content') + .attr('class') + .split(' ') + .join('.'); + + row.find(contentContainerClass + ' .content').append($notes.closest('.content').children()); } } - - this.renderNote(note, $form); - // cleanup after successfully creating a diff/discussion note - if (isNewDiffComment) { - this.removeDiscussionNoteForm($form); + // Init discussion on 'Discussion' page if it is merge request page + const page = $('body').attr('data-page'); + if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) { + Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); } - }; + } else { + // append new note to all matching discussions + Notes.animateAppendNote(noteEntity.html, discussionContainer); + } - /* - Called in response to the edit note form being submitted + if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { + gl.diffNotesCompileComponents(); + this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); + } - Updates the current note field. - */ + gl.utils.localTimeAgo($('.js-timeago'), false); + Notes.checkMergeRequestStatus(); + return this.updateNotesCount(1); + } - Notes.prototype.updateNote = function(noteEntity, $targetNote) { - var $noteEntityEl, $note_li; - // Convert returned HTML to a jQuery object so we can modify it further - $noteEntityEl = $(noteEntity.html); - $noteEntityEl.addClass('fade-in-full'); - this.revertNoteEditForm($targetNote); - $noteEntityEl.renderGFM(); - // Find the note's `li` element by ID and replace it with the updated HTML - $note_li = $('.note-row-' + noteEntity.id); + getLineHolder(changesDiscussionContainer) { + return $(changesDiscussionContainer).closest('.notes_holder') + .prevAll('.line_holder') + .first() + .get(0); + } - $note_li.replaceWith($noteEntityEl); - this.setupNewNote($noteEntityEl); + renderDiscussionAvatar(diffAvatarContainer, noteEntity) { + var commentButton = diffAvatarContainer.find('.js-add-diff-note-button'); + var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders'); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - gl.diffNotesCompileComponents(); - } - }; + if (!avatarHolder.length) { + avatarHolder = document.createElement('diff-note-avatars'); + avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id); - Notes.prototype.checkContentToAllowEditing = function($el) { - var initialContent = $el.find('.original-note-content').text().trim(); - var currentContent = $el.find('.js-note-text').val(); - var isAllowed = true; + diffAvatarContainer.append(avatarHolder); - if (currentContent === initialContent) { - this.removeNoteEditForm($el); - } - else { - var $buttons = $el.find('.note-form-actions'); - var isWidgetVisible = gl.utils.isInViewport($el.get(0)); - - if (!isWidgetVisible) { - gl.utils.scrollToElement($el); - } + gl.diffNotesCompileComponents(); + } - $el.find('.js-finish-edit-warning').show(); - isAllowed = false; - } + if (commentButton.length) { + commentButton.remove(); + } + } + + /** + * Called in response the main target form has been successfully submitted. + * + * Removes any errors. + * Resets text and preview. + * Resets buttons. + */ + resetMainTargetForm(e) { + var form; + form = $('.js-main-target-form'); + // remove validation errors + form.find('.js-errors').remove(); + // reset text and preview + form.find('.js-md-write-button').click(); + form.find('.js-note-text').val('').trigger('input'); + form.find('.js-note-text').data('autosave').reset(); + + var event = document.createEvent('Event'); + event.initEvent('autosize:update', true, false); + form.find('.js-autosize')[0].dispatchEvent(event); + + this.updateTargetButtons(e); + } + + reenableTargetFormSubmitButton() { + var form; + form = $('.js-main-target-form'); + return form.find('.js-note-text').trigger('input'); + } + + /** + * Shows the main form and does some setup on it. + * + * Sets some hidden fields in the form. + */ + setupMainTargetNoteForm() { + var form; + // find the form + form = $('.js-new-note-form'); + // Set a global clone of the form for later cloning + this.formClone = form.clone(); + // show the form + this.setupNoteForm(form); + // fix classes + form.removeClass('js-new-note-form'); + form.addClass('js-main-target-form'); + form.find('#note_line_code').remove(); + form.find('#note_position').remove(); + form.find('#note_type').val(''); + form.find('#in_reply_to_discussion_id').remove(); + form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove(); + this.parentTimeline = form.parents('.timeline'); + + if (form.length) { + Notes.initCommentTypeToggle(form.get(0)); + } + } + + /** + * General note form setup. + * + * deactivates the submit button when text is empty + * hides the preview button when text is empty + * setup GFM auto complete + * show the form + */ + setupNoteForm(form) { + var textarea, key; + new gl.GLForm(form, this.enableGFM); + textarea = form.find('.js-note-text'); + key = [ + 'Note', + form.find('#note_noteable_type').val(), + form.find('#note_noteable_id').val(), + form.find('#note_commit_id').val(), + form.find('#note_type').val(), + form.find('#in_reply_to_discussion_id').val(), - return isAllowed; - }; + // LegacyDiffNote + form.find('#note_line_code').val(), - /* - Called in response to clicking the edit note link + // DiffNote + form.find('#note_position').val() + ]; + return new Autosave(textarea, key); + } + + /** + * Called in response to the new note form being submitted + * + * Adds new note to list. + */ + addNote($form, note) { + return this.renderNote(note); + } + + 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 this.addFlash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline); + } + + 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. + */ + addDiscussionNote($form, note, isNewDiffComment) { + if ($form.attr('data-resolve-all') != null) { + var projectPath = $form.data('project-path'); + var discussionId = $form.data('discussion-id'); + var mergeRequestId = $form.data('noteable-iid'); + + if (ResolveService != null) { + ResolveService.toggleResolveForDiscussion(mergeRequestId, discussionId); + } + } - Replaces the note text with the note edit form - Adds a data attribute to the form with the original content of the note for cancellations - */ - Notes.prototype.showEditForm = function(e, scrollTo, myLastNote) { - e.preventDefault(); + this.renderNote(note, $form); + // cleanup after successfully creating a diff/discussion note + if (isNewDiffComment) { + this.removeDiscussionNoteForm($form); + } + } + + /** + * Called in response to the edit note form being submitted + * + * Updates the current note field. + */ + updateNote(noteEntity, $targetNote) { + var $noteEntityEl, $note_li; + // Convert returned HTML to a jQuery object so we can modify it further + $noteEntityEl = $(noteEntity.html); + $noteEntityEl.addClass('fade-in-full'); + this.revertNoteEditForm($targetNote); + $noteEntityEl.renderGFM(); + // Find the note's `li` element by ID and replace it with the updated HTML + $note_li = $('.note-row-' + noteEntity.id); + + $note_li.replaceWith($noteEntityEl); + this.setupNewNote($noteEntityEl); + + if (typeof gl.diffNotesCompileComponents !== 'undefined') { + gl.diffNotesCompileComponents(); + } + } - var $target = $(e.target); - var $editForm = $(this.getEditFormSelector($target)); - var $note = $target.closest('.note'); - var $currentlyEditing = $('.note.is-editing:visible'); + checkContentToAllowEditing($el) { + var initialContent = $el.find('.original-note-content').text().trim(); + var currentContent = $el.find('.js-note-text').val(); + var isAllowed = true; - if ($currentlyEditing.length) { - var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing); + if (currentContent === initialContent) { + this.removeNoteEditForm($el); + } + else { + var $buttons = $el.find('.note-form-actions'); + var isWidgetVisible = gl.utils.isInViewport($el.get(0)); - if (!isEditAllowed) { - return; - } + if (!isWidgetVisible) { + gl.utils.scrollToElement($el); } - $note.find('.js-note-attachment-delete').show(); - $editForm.addClass('current-note-edit-form'); - $note.addClass('is-editing'); - this.putEditFormInPlace($target); - }; + $el.find('.js-finish-edit-warning').show(); + isAllowed = false; + } - /* - Called in response to clicking the edit note link + return isAllowed; + } - Hides edit form and restores the original note text to the editor textarea. - */ + /** + * Called in response to clicking the edit note link + * + * Replaces the note text with the note edit form + * Adds a data attribute to the form with the original content of the note for cancellations + */ + showEditForm(e, scrollTo, myLastNote) { + e.preventDefault(); - Notes.prototype.cancelEdit = function(e) { - e.preventDefault(); - const $target = $(e.target); - const $note = $target.closest('.note'); - const noteId = $note.attr('data-note-id'); + var $target = $(e.target); + var $editForm = $(this.getEditFormSelector($target)); + var $note = $target.closest('.note'); + var $currentlyEditing = $('.note.is-editing:visible'); - this.revertNoteEditForm($target); + if ($currentlyEditing.length) { + var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing); - if (this.updatedNotesTrackingMap[noteId]) { - const $newNote = $(this.updatedNotesTrackingMap[noteId].html); - $note.replaceWith($newNote); - this.setupNewNote($newNote); - // Now that we have taken care of the update, clear it out - delete this.updatedNotesTrackingMap[noteId]; - } - else { - $note.find('.js-finish-edit-warning').hide(); - this.removeNoteEditForm($note); + if (!isEditAllowed) { + return; } - }; - - Notes.prototype.revertNoteEditForm = function($target) { - $target = $target || $('.note.is-editing:visible'); - var selector = this.getEditFormSelector($target); - var $editForm = $(selector); + } - $editForm.insertBefore('.notes-form'); - $editForm.find('.js-comment-save-button').enable(); - $editForm.find('.js-finish-edit-warning').hide(); - }; + $note.find('.js-note-attachment-delete').show(); + $editForm.addClass('current-note-edit-form'); + $note.addClass('is-editing'); + this.putEditFormInPlace($target); + } + + /** + * Called in response to clicking the edit note link + * + * Hides edit form and restores the original note text to the editor textarea. + */ + cancelEdit(e) { + e.preventDefault(); + const $target = $(e.target); + const $note = $target.closest('.note'); + const noteId = $note.attr('data-note-id'); + + this.revertNoteEditForm($target); + + if (this.updatedNotesTrackingMap[noteId]) { + const $newNote = $(this.updatedNotesTrackingMap[noteId].html); + $note.replaceWith($newNote); + this.setupNewNote($newNote); + // Now that we have taken care of the update, clear it out + delete this.updatedNotesTrackingMap[noteId]; + } + else { + $note.find('.js-finish-edit-warning').hide(); + this.removeNoteEditForm($note); + } + } - Notes.prototype.getEditFormSelector = function($el) { - var selector = '.note-edit-form:not(.mr-note-edit-form)'; + revertNoteEditForm($target) { + $target = $target || $('.note.is-editing:visible'); + var selector = this.getEditFormSelector($target); + var $editForm = $(selector); - if ($el.parents('#diffs').length) { - selector = '.note-edit-form.mr-note-edit-form'; - } + $editForm.insertBefore('.notes-form'); + $editForm.find('.js-comment-save-button').enable(); + $editForm.find('.js-finish-edit-warning').hide(); + } - return selector; - }; + getEditFormSelector($el) { + var selector = '.note-edit-form:not(.mr-note-edit-form)'; - 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-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')); - }; + if ($el.parents('#diffs').length) { + selector = '.note-edit-form.mr-note-edit-form'; + } - /* - Called in response to deleting a note of any kind. - - Removes the actual note from view. - Removes the whole discussion if the last note is being removed. - */ - - Notes.prototype.removeNote = function(e) { - var noteElId, noteId, dataNoteId, $note, lineHolder; - $note = $(e.currentTarget).closest('.note'); - noteElId = $note.attr('id'); - noteId = $note.attr('data-note-id'); - lineHolder = $(e.currentTarget).closest('.notes[data-discussion-id]') - .closest('.notes_holder') - .prev('.line_holder'); - $(`.note[id="${noteElId}"]`).each((function(_this) { - // A same note appears in the "Discussion" and in the "Changes" tab, we have - // 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'); - - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - if (gl.diffNoteApps[noteElId]) { - gl.diffNoteApps[noteElId].$destroy(); - } + return selector; + } + + removeNoteEditForm($note) { + var form = $note.find('.current-note-edit-form'); + $note.removeClass('is-editing'); + form.removeClass('current-note-edit-form'); + 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')); + } + + /** + * Called in response to deleting a note of any kind. + * + * Removes the actual note from view. + * Removes the whole discussion if the last note is being removed. + */ + removeNote(e) { + var noteElId, noteId, dataNoteId, $note, lineHolder; + $note = $(e.currentTarget).closest('.note'); + noteElId = $note.attr('id'); + noteId = $note.attr('data-note-id'); + lineHolder = $(e.currentTarget).closest('.notes[data-discussion-id]') + .closest('.notes_holder') + .prev('.line_holder'); + $(`.note[id="${noteElId}"]`).each((function(_this) { + // A same note appears in the "Discussion" and in the "Changes" tab, we have + // 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'); + + if (typeof gl.diffNotesCompileComponents !== 'undefined') { + if (gl.diffNoteApps[noteElId]) { + gl.diffNoteApps[noteElId].$destroy(); } + } - $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'); + // check if this is the last note for this line + if ($notes.find('.note').length === 0) { + var notesTr = $notes.closest('tr'); - // "Discussions" tab - $notes.closest('.timeline-entry').remove(); + // "Discussions" tab + $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(); - } else { - notesTr.remove(); - } + // The notes tr can contain multiple lists of notes, like on the parallel diff + if (notesTr.find('.discussion-notes').length > 1) { + $notes.remove(); + } else { + notesTr.remove(); } - }; - })(this)); - - Notes.checkMergeRequestStatus(); - return this.updateNotesCount(-1); - }; - - /* - Called in response to clicking the delete attachment link - - Removes the attachment wrapper view, including image tag if it exists - Resets the note editing form - */ + } + }; + })(this)); + + Notes.checkMergeRequestStatus(); + return this.updateNotesCount(-1); + } + + /** + * Called in response to clicking the delete attachment link + * + * Removes the attachment wrapper view, including image tag if it exists + * Resets the note editing form + */ + removeAttachment() { + 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(); + } + + /** + * Called when clicking on the "reply" button for a diff line. + * + * Shows the note form below the notes. + */ + onReplyToDiscussionNote(e) { + this.replyToDiscussionNote(e.target); + } + + replyToDiscussionNote(target) { + var form, replyLink; + form = this.cleanForm(this.formClone.clone()); + replyLink = $(target).closest('.js-discussion-reply-button'); + // insert the form after the button + replyLink + .closest('.discussion-reply-holder') + .hide() + .after(form); + // show the form + return this.setupDiscussionNoteForm(replyLink, form); + } + + /** + * Shows the diff or discussion form and does some setup on it. + * + * Sets some hidden fields in the form. + * + * Note: dataHolder must have the "discussionId" and "lineCode" data attributes set. + */ + setupDiscussionNoteForm(dataHolder, form) { + // setup note target + var discussionID = dataHolder.data('discussionId'); + + if (discussionID) { + form.attr('data-discussion-id', discussionID); + form.find('#in_reply_to_discussion_id').val(discussionID); + } - Notes.prototype.removeAttachment = function() { - 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(); - }; + form.attr('data-line-code', dataHolder.data('lineCode')); + form.find('#line_type').val(dataHolder.data('lineType')); - /* - Called when clicking on the "reply" button for a diff line. + form.find('#note_noteable_type').val(dataHolder.data('noteableType')); + form.find('#note_noteable_id').val(dataHolder.data('noteableId')); + form.find('#note_commit_id').val(dataHolder.data('commitId')); + form.find('#note_type').val(dataHolder.data('noteType')); - Shows the note form below the notes. - */ + // LegacyDiffNote + form.find('#note_line_code').val(dataHolder.data('lineCode')); - Notes.prototype.onReplyToDiscussionNote = function(e) { - this.replyToDiscussionNote(e.target); - }; + // DiffNote + form.find('#note_position').val(dataHolder.attr('data-position')); - Notes.prototype.replyToDiscussionNote = function(target) { - var form, replyLink; - form = this.cleanForm(this.formClone.clone()); - replyLink = $(target).closest('.js-discussion-reply-button'); - // insert the form after the button - replyLink - .closest('.discussion-reply-holder') - .hide() - .after(form); - // show the form - return this.setupDiscussionNoteForm(replyLink, form); - }; + form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text')); + form.find('.js-note-target-close').remove(); + form.find('.js-note-new-discussion').remove(); + this.setupNoteForm(form); - /* - Shows the diff or discussion form and does some setup on it. + form + .removeClass('js-main-target-form') + .addClass('discussion-form js-discussion-note-form'); - Sets some hidden fields in the form. + if (typeof gl.diffNotesCompileComponents !== 'undefined') { + var $commentBtn = form.find('comment-and-resolve-btn'); + $commentBtn.attr(':discussion-id', `'${discussionID}'`); - Note: dataHolder must have the "discussionId" and "lineCode" data attributes set. - */ + gl.diffNotesCompileComponents(); + } - Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) { - // setup note target - var discussionID = dataHolder.data('discussionId'); + form.find('.js-note-text').focus(); + form + .find('.js-comment-resolve-button') + .attr('data-discussion-id', discussionID); + } + + /** + * Called when clicking on the "add a comment" button on the side of a diff line. + * + * Inserts a temporary row for the form below the line. + * Sets up the form and shows it. + */ + onAddDiffNote(e) { + e.preventDefault(); + const link = e.currentTarget || e.target; + const $link = $(link); + const showReplyInput = !$link.hasClass('js-diff-comment-avatar'); + this.toggleDiffNote({ + target: $link, + lineType: link.dataset.lineType, + showReplyInput + }); + } + + toggleDiffNote({ + target, + lineType, + forceShow, + showReplyInput = false, + }) { + var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar; + $link = $(target); + row = $link.closest('tr'); + const nextRow = row.next(); + let targetRow = row; + if (nextRow.is('.notes_holder')) { + targetRow = nextRow; + } - if (discussionID) { - form.attr('data-discussion-id', discussionID); - form.find('#in_reply_to_discussion_id').val(discussionID); + hasNotes = nextRow.is('.notes_holder'); + addForm = false; + let lineTypeSelector = ''; + rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content"></div></td></tr>'; + // In parallel view, look inside the correct left/right pane + if (this.isParallelView()) { + lineTypeSelector = `.${lineType}`; + rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>'; + } + const notesContentSelector = `.notes_content${lineTypeSelector} .content`; + let notesContent = targetRow.find(notesContentSelector); + + if (hasNotes && showReplyInput) { + targetRow.show(); + notesContent = targetRow.find(notesContentSelector); + if (notesContent.length) { + notesContent.show(); + replyButton = notesContent.find('.js-discussion-reply-button:visible'); + if (replyButton.length) { + this.replyToDiscussionNote(replyButton[0]); + } else { + // In parallel view, the form may not be present in one of the panes + noteForm = notesContent.find('.js-discussion-note-form'); + if (noteForm.length === 0) { + addForm = true; + } + } } + } else if (showReplyInput) { + // add a notes row and insert the form + row.after(rowCssToAdd); + targetRow = row.next(); + notesContent = targetRow.find(notesContentSelector); + addForm = true; + } else { + const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible'); + const isForced = forceShow === true || forceShow === false; + const showNow = forceShow === true || (!isCurrentlyShown && !isForced); + + targetRow.toggle(showNow); + notesContent.toggle(showNow); + } - form.attr('data-line-code', dataHolder.data('lineCode')); - form.find('#line_type').val(dataHolder.data('lineType')); - - form.find('#note_noteable_type').val(dataHolder.data('noteableType')); - form.find('#note_noteable_id').val(dataHolder.data('noteableId')); - form.find('#note_commit_id').val(dataHolder.data('commitId')); - form.find('#note_type').val(dataHolder.data('noteType')); - - // LegacyDiffNote - form.find('#note_line_code').val(dataHolder.data('lineCode')); - - // DiffNote - form.find('#note_position').val(dataHolder.attr('data-position')); - - form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text')); - form.find('.js-note-target-close').remove(); - form.find('.js-note-new-discussion').remove(); - this.setupNoteForm(form); - - form - .removeClass('js-main-target-form') - .addClass('discussion-form js-discussion-note-form'); - - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - var $commentBtn = form.find('comment-and-resolve-btn'); - $commentBtn.attr(':discussion-id', `'${discussionID}'`); - - gl.diffNotesCompileComponents(); + if (addForm) { + newForm = this.cleanForm(this.formClone.clone()); + newForm.appendTo(notesContent); + // show the form + return this.setupDiscussionNoteForm($link, newForm); + } + } + + /** + * Called in response to "cancel" on a diff note form. + * + * Shows the reply button again. + * Removes the form and if necessary it's temporary row. + */ + removeDiscussionNoteForm(form) { + var glForm, row; + row = form.closest('tr'); + glForm = form.data('gl-form'); + glForm.destroy(); + form.find('.js-note-text').data('autosave').reset(); + // show the reply button (will only work for replies) + form + .prev('.discussion-reply-holder') + .show(); + if (row.is('.js-temp-notes-holder')) { + // remove temporary row for diff lines + return row.remove(); + } else { + // only remove the form + return form.remove(); + } + } + + cancelDiscussionForm(e) { + var form; + e.preventDefault(); + form = $(e.target).closest('.js-discussion-note-form'); + return this.removeDiscussionNoteForm(form); + } + + /** + * Called after an attachment file has been selected. + * + * Updates the file name for the selected attachment. + */ + updateFormAttachment() { + var filename, form; + form = $(this).closest('form'); + // get only the basename + filename = $(this).val().replace(/^.*[\\\/]/, ''); + return form.find('.js-attachment-filename').text(filename); + } + + /** + * Called when the tab visibility changes + */ + visibilityChange() { + return this.refresh(); + } + + updateTargetButtons(e) { + var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea; + textarea = $(e.target); + form = textarea.parents('form'); + reopenbtn = form.find('.js-note-target-reopen'); + closebtn = form.find('.js-note-target-close'); + discardbtn = form.find('.js-note-discard'); + + if (textarea.val().trim().length > 0) { + reopentext = reopenbtn.attr('data-alternative-text'); + closetext = closebtn.attr('data-alternative-text'); + if (reopenbtn.text() !== reopentext) { + reopenbtn.text(reopentext); } - - form.find('.js-note-text').focus(); - form - .find('.js-comment-resolve-button') - .attr('data-discussion-id', discussionID); - }; - - /* - Called when clicking on the "add a comment" button on the side of a diff line. - - Inserts a temporary row for the form below the line. - Sets up the form and shows it. - */ - - Notes.prototype.onAddDiffNote = function(e) { - e.preventDefault(); - const link = e.currentTarget || e.target; - const $link = $(link); - const showReplyInput = !$link.hasClass('js-diff-comment-avatar'); - this.toggleDiffNote({ - target: $link, - lineType: link.dataset.lineType, - showReplyInput - }); - }; - - Notes.prototype.toggleDiffNote = function({ - target, - lineType, - forceShow, - showReplyInput = false, - }) { - var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar; - $link = $(target); - row = $link.closest('tr'); - const nextRow = row.next(); - let targetRow = row; - if (nextRow.is('.notes_holder')) { - targetRow = nextRow; + if (closebtn.text() !== closetext) { + closebtn.text(closetext); } - - hasNotes = nextRow.is('.notes_holder'); - addForm = false; - let lineTypeSelector = ''; - rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content"></div></td></tr>'; - // In parallel view, look inside the correct left/right pane - if (this.isParallelView()) { - lineTypeSelector = `.${lineType}`; - rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>'; + if (reopenbtn.is(':not(.btn-comment-and-reopen)')) { + reopenbtn.addClass('btn-comment-and-reopen'); } - const notesContentSelector = `.notes_content${lineTypeSelector} .content`; - let notesContent = targetRow.find(notesContentSelector); - - if (hasNotes && showReplyInput) { - targetRow.show(); - notesContent = targetRow.find(notesContentSelector); - if (notesContent.length) { - notesContent.show(); - replyButton = notesContent.find('.js-discussion-reply-button:visible'); - if (replyButton.length) { - this.replyToDiscussionNote(replyButton[0]); - } else { - // In parallel view, the form may not be present in one of the panes - noteForm = notesContent.find('.js-discussion-note-form'); - if (noteForm.length === 0) { - addForm = true; - } - } - } - } else if (showReplyInput) { - // add a notes row and insert the form - row.after(rowCssToAdd); - targetRow = row.next(); - notesContent = targetRow.find(notesContentSelector); - addForm = true; - } else { - const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible'); - const isForced = forceShow === true || forceShow === false; - const showNow = forceShow === true || (!isCurrentlyShown && !isForced); - - targetRow.toggle(showNow); - notesContent.toggle(showNow); + if (closebtn.is(':not(.btn-comment-and-close)')) { + closebtn.addClass('btn-comment-and-close'); } - - if (addForm) { - newForm = this.cleanForm(this.formClone.clone()); - newForm.appendTo(notesContent); - // show the form - return this.setupDiscussionNoteForm($link, newForm); + if (discardbtn.is(':hidden')) { + return discardbtn.show(); } - }; - - /* - Called in response to "cancel" on a diff note form. - - Shows the reply button again. - Removes the form and if necessary it's temporary row. - */ - - Notes.prototype.removeDiscussionNoteForm = function(form) { - var glForm, row; - row = form.closest('tr'); - glForm = form.data('gl-form'); - glForm.destroy(); - form.find('.js-note-text').data('autosave').reset(); - // show the reply button (will only work for replies) - form - .prev('.discussion-reply-holder') - .show(); - if (row.is('.js-temp-notes-holder')) { - // remove temporary row for diff lines - return row.remove(); - } else { - // only remove the form - return form.remove(); + } else { + reopentext = reopenbtn.data('original-text'); + closetext = closebtn.data('original-text'); + if (reopenbtn.text() !== reopentext) { + reopenbtn.text(reopentext); } - }; - - Notes.prototype.cancelDiscussionForm = function(e) { - var form; - e.preventDefault(); - form = $(e.target).closest('.js-discussion-note-form'); - return this.removeDiscussionNoteForm(form); - }; - - /* - Called after an attachment file has been selected. - - Updates the file name for the selected attachment. - */ - - Notes.prototype.updateFormAttachment = function() { - var filename, form; - form = $(this).closest('form'); - // get only the basename - filename = $(this).val().replace(/^.*[\\\/]/, ''); - return form.find('.js-attachment-filename').text(filename); - }; - - /* - Called when the tab visibility changes - */ - - Notes.prototype.visibilityChange = function() { - return this.refresh(); - }; - - Notes.prototype.updateTargetButtons = function(e) { - var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea; - textarea = $(e.target); - form = textarea.parents('form'); - reopenbtn = form.find('.js-note-target-reopen'); - closebtn = form.find('.js-note-target-close'); - discardbtn = form.find('.js-note-discard'); - - if (textarea.val().trim().length > 0) { - reopentext = reopenbtn.attr('data-alternative-text'); - closetext = closebtn.attr('data-alternative-text'); - if (reopenbtn.text() !== reopentext) { - reopenbtn.text(reopentext); - } - if (closebtn.text() !== closetext) { - closebtn.text(closetext); - } - if (reopenbtn.is(':not(.btn-comment-and-reopen)')) { - reopenbtn.addClass('btn-comment-and-reopen'); - } - if (closebtn.is(':not(.btn-comment-and-close)')) { - closebtn.addClass('btn-comment-and-close'); - } - if (discardbtn.is(':hidden')) { - return discardbtn.show(); - } - } else { - reopentext = reopenbtn.data('original-text'); - closetext = closebtn.data('original-text'); - if (reopenbtn.text() !== reopentext) { - reopenbtn.text(reopentext); - } - if (closebtn.text() !== closetext) { - closebtn.text(closetext); - } - if (reopenbtn.is('.btn-comment-and-reopen')) { - reopenbtn.removeClass('btn-comment-and-reopen'); - } - if (closebtn.is('.btn-comment-and-close')) { - closebtn.removeClass('btn-comment-and-close'); - } - if (discardbtn.is(':visible')) { - return discardbtn.hide(); - } + if (closebtn.text() !== closetext) { + closebtn.text(closetext); } - }; - - Notes.prototype.putEditFormInPlace = function($el) { - var $editForm = $(this.getEditFormSelector($el)); - var $note = $el.closest('.note'); - - $editForm.insertAfter($note.find('.note-text')); - - var $originalContentEl = $note.find('.original-note-content'); - var originalContent = $originalContentEl.text().trim(); - var postUrl = $originalContentEl.data('post-url'); - var targetId = $originalContentEl.data('target-id'); - var targetType = $originalContentEl.data('target-type'); - - new gl.GLForm($editForm.find('form'), this.enableGFM); - - $editForm.find('form') - .attr('action', postUrl) - .attr('data-remote', 'true'); - $editForm.find('.js-form-target-id').val(targetId); - $editForm.find('.js-form-target-type').val(targetType); - $editForm.find('.js-note-text').focus().val(originalContent); - $editForm.find('.js-md-write-button').trigger('click'); - $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')); + if (reopenbtn.is('.btn-comment-and-reopen')) { + reopenbtn.removeClass('btn-comment-and-reopen'); } - }; - - Notes.prototype.updateNotesCount = function(updateCount) { - return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); - }; - - Notes.prototype.toggleCommitList = function(e) { - const $element = $(e.currentTarget); - const $closestSystemCommitList = $element.siblings('.system-note-commit-list'); - - $element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up'); - $closestSystemCommitList.toggleClass('hide-shade'); - }; - - /** - Scans system notes with `ul` elements in system note body - then collapse long commit list pushed by user to make it less - intrusive. - */ - Notes.prototype.collapseLongCommitList = function() { - const systemNotes = $('#notes-list').find('li.system-note').has('ul'); - - $.each(systemNotes, function(index, systemNote) { - const $systemNote = $(systemNote); - const headerMessage = $systemNote.find('.note-text').find('p:first').text().replace(':', ''); - - $systemNote.find('.note-header .system-note-message').html(headerMessage); - - if ($systemNote.find('li').length > MAX_VISIBLE_COMMIT_LIST_COUNT) { - $systemNote.find('.note-text').addClass('system-note-commit-list'); - $systemNote.find('.system-note-commit-list-toggler').show(); - } else { - $systemNote.find('.note-text').addClass('system-note-commit-list hide-shade'); - } - }); - }; - - Notes.prototype.addFlash = function(...flashParams) { - this.flashInstance = new Flash(...flashParams); - }; - - Notes.prototype.clearFlash = function() { - if (this.flashInstance && this.flashInstance.flashContainer) { - this.flashInstance.flashContainer.hide(); - this.flashInstance = null; + if (closebtn.is('.btn-comment-and-close')) { + closebtn.removeClass('btn-comment-and-close'); } - }; - - Notes.prototype.cleanForm = function($form) { - // Remove JS classes that are not needed here - $form - .find('.js-comment-type-dropdown') - .removeClass('btn-group'); - - // Remove dropdown - $form - .find('.dropdown-menu') - .remove(); - - return $form; - }; - - /** - * Check if note does not exists on page - */ - Notes.isNewNote = function(noteEntity, noteIds) { - return $.inArray(noteEntity.id, noteIds) === -1; - }; - - /** - * Check if $note already contains the `noteEntity` content - */ - Notes.isUpdatedNote = function(noteEntity, $note) { - // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way - const sanitizedNoteEntityText = normalizeNewlines(noteEntity.note.trim()); - const currentNoteText = normalizeNewlines( - $note.find('.original-note-content').first().text().trim() - ); - return sanitizedNoteEntityText !== currentNoteText; - }; - - Notes.checkMergeRequestStatus = function() { - if (gl.utils.getPagePath(1) === 'merge_requests') { - gl.mrWidget.checkStatus(); + if (discardbtn.is(':visible')) { + return discardbtn.hide(); } - }; + } + } + + putEditFormInPlace($el) { + var $editForm = $(this.getEditFormSelector($el)); + var $note = $el.closest('.note'); + + $editForm.insertAfter($note.find('.note-text')); + + var $originalContentEl = $note.find('.original-note-content'); + var originalContent = $originalContentEl.text().trim(); + var postUrl = $originalContentEl.data('post-url'); + var targetId = $originalContentEl.data('target-id'); + var targetType = $originalContentEl.data('target-type'); + + new gl.GLForm($editForm.find('form'), this.enableGFM); + + $editForm.find('form') + .attr('action', postUrl) + .attr('data-remote', 'true'); + $editForm.find('.js-form-target-id').val(targetId); + $editForm.find('.js-form-target-type').val(targetType); + $editForm.find('.js-note-text').focus().val(originalContent); + $editForm.find('.js-md-write-button').trigger('click'); + $editForm.find('.referenced-users').hide(); + } + + putConflictEditWarningInPlace(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.animateAppendNote = function(noteHtml, $notesList) { - const $note = $(noteHtml); + updateNotesCount(updateCount) { + return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); + } - $note.addClass('fade-in-full').renderGFM(); - $notesList.append($note); - return $note; - }; + toggleCommitList(e) { + const $element = $(e.currentTarget); + const $closestSystemCommitList = $element.siblings('.system-note-commit-list'); - Notes.animateUpdateNote = function(noteHtml, $note) { - const $updatedNote = $(noteHtml); + $element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up'); + $closestSystemCommitList.toggleClass('hide-shade'); + } - $updatedNote.addClass('fade-in').renderGFM(); - $note.replaceWith($updatedNote); - return $updatedNote; - }; + /** + * Scans system notes with `ul` elements in system note body + * then collapse long commit list pushed by user to make it less + * intrusive. + */ + collapseLongCommitList() { + const systemNotes = $('#notes-list').find('li.system-note').has('ul'); - /** - * Get data from Form attributes to use for saving/submitting comment. - */ - Notes.prototype.getFormData = function($form) { - return { - formData: $form.serialize(), - formContent: _.escape($form.find('.js-note-text').val()), - formAction: $form.attr('action'), - }; - }; + $.each(systemNotes, function(index, systemNote) { + const $systemNote = $(systemNote); + const headerMessage = $systemNote.find('.note-text').find('p:first').text().replace(':', ''); - /** - * Identify if comment has any quick actions - */ - Notes.prototype.hasQuickActions = function(formContent) { - return REGEX_QUICK_ACTIONS.test(formContent); - }; - - /** - * Remove quick actions and leave comment with pure message - */ - Notes.prototype.stripQuickActions = function(formContent) { - return formContent.replace(REGEX_QUICK_ACTIONS, '').trim(); - }; + $systemNote.find('.note-header .system-note-message').html(headerMessage); - /** - * Gets appropriate description from quick actions found in provided `formContent` - */ - Notes.prototype.getQuickActionDescription = function (formContent, availableQuickActions = []) { - let tempFormContent; + if ($systemNote.find('li').length > MAX_VISIBLE_COMMIT_LIST_COUNT) { + $systemNote.find('.note-text').addClass('system-note-commit-list'); + $systemNote.find('.system-note-commit-list-toggler').show(); + } else { + $systemNote.find('.note-text').addClass('system-note-commit-list hide-shade'); + } + }); + } - // Identify executed quick actions from `formContent` - const executedCommands = availableQuickActions.filter((command, index) => { - const commandRegex = new RegExp(`/${command.name}`); - return commandRegex.test(formContent); - }); + addFlash(...flashParams) { + this.flashInstance = new Flash(...flashParams); + } - if (executedCommands && executedCommands.length) { - if (executedCommands.length > 1) { - tempFormContent = 'Applying multiple commands'; - } else { - const commandDescription = executedCommands[0].description.toLowerCase(); - tempFormContent = `Applying command to ${commandDescription}`; - } + clearFlash() { + if (this.flashInstance && this.flashInstance.flashContainer) { + this.flashInstance.flashContainer.hide(); + this.flashInstance = null; + } + } + + cleanForm($form) { + // Remove JS classes that are not needed here + $form + .find('.js-comment-type-dropdown') + .removeClass('btn-group'); + + // Remove dropdown + $form + .find('.dropdown-menu') + .remove(); + + return $form; + } + + /** + * Check if note does not exists on page + */ + static isNewNote(noteEntity, noteIds) { + return $.inArray(noteEntity.id, noteIds) === -1; + } + + /** + * Check if $note already contains the `noteEntity` content + */ + static isUpdatedNote(noteEntity, $note) { + // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way + const sanitizedNoteEntityText = normalizeNewlines(noteEntity.note.trim()); + const currentNoteText = normalizeNewlines( + $note.find('.original-note-content').first().text().trim() + ); + return sanitizedNoteEntityText !== currentNoteText; + } + + static checkMergeRequestStatus() { + if (gl.utils.getPagePath(1) === 'merge_requests') { + gl.mrWidget.checkStatus(); + } + } + + static animateAppendNote(noteHtml, $notesList) { + const $note = $(noteHtml); + + $note.addClass('fade-in-full').renderGFM(); + $notesList.append($note); + return $note; + } + + static animateUpdateNote(noteHtml, $note) { + const $updatedNote = $(noteHtml); + + $updatedNote.addClass('fade-in').renderGFM(); + $note.replaceWith($updatedNote); + return $updatedNote; + } + + /** + * Get data from Form attributes to use for saving/submitting comment. + */ + getFormData($form) { + return { + formData: $form.serialize(), + formContent: _.escape($form.find('.js-note-text').val()), + formAction: $form.attr('action'), + }; + } + + /** + * Identify if comment has any quick actions + */ + hasQuickActions(formContent) { + return REGEX_QUICK_ACTIONS.test(formContent); + } + + /** + * Remove quick actions and leave comment with pure message + */ + stripQuickActions(formContent) { + return formContent.replace(REGEX_QUICK_ACTIONS, '').trim(); + } + + /** + * Gets appropriate description from quick actions found in provided `formContent` + */ + getQuickActionDescription(formContent, availableQuickActions = []) { + let tempFormContent; + + // Identify executed quick actions from `formContent` + const executedCommands = availableQuickActions.filter((command, index) => { + const commandRegex = new RegExp(`/${command.name}`); + return commandRegex.test(formContent); + }); + + if (executedCommands && executedCommands.length) { + if (executedCommands.length > 1) { + tempFormContent = 'Applying multiple commands'; } else { - tempFormContent = 'Applying command'; + const commandDescription = executedCommands[0].description.toLowerCase(); + tempFormContent = `Applying command to ${commandDescription}`; } + } else { + tempFormContent = 'Applying command'; + } - return tempFormContent; - }; - - /** - * 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, currentUserAvatar }) { - 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}"> - <img class="avatar s40" src="${currentUserAvatar}"> - </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> - </div> + return tempFormContent; + } + + /** + * 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. + */ + createPlaceholderNote({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) { + 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}"> + <img class="avatar s40" src="${currentUserAvatar}"> + </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> + </div> + </div> + <div class="note-body"> + <div class="note-text"> + <p>${formContent}</p> </div> - <div class="note-body"> - <div class="note-text"> - <p>${formContent}</p> - </div> - </div> - </div> + </div> + </div> + </div> + </li>` + ); + + return $tempNote; + } + + /** + * Create Placeholder System Note DOM element populated with quick action description + */ + createPlaceholderSystemNote({ formContent, uniqueId }) { + const $tempNote = $( + `<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half"> + <div class="timeline-entry-inner"> + <div class="timeline-content"> + <i>${formContent}</i> </div> - </li>` - ); - - return $tempNote; - }; - - /** - * Create Placeholder System Note DOM element populated with quick action description - */ - Notes.prototype.createPlaceholderSystemNote = function ({ formContent, uniqueId }) { - const $tempNote = $( - `<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half"> - <div class="timeline-entry-inner"> - <div class="timeline-content"> - <i>${formContent}</i> - </div> - </div> - </li>` - ); + </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 + */ + postComment(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); + let noteUniqueId; + let systemNoteUniqueId; + let hasQuickActions = false; + 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'); + } - return $tempNote; - }; + // 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(); + } - /** - * 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); - let noteUniqueId; - let systemNoteUniqueId; - let hasQuickActions = false; - 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'); - } + tempFormContent = formContent; + if (this.hasQuickActions(formContent)) { + tempFormContent = this.stripQuickActions(formContent); + hasQuickActions = true; + } - // 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(); - } + // Show placeholder note + if (tempFormContent) { + noteUniqueId = _.uniqueId('tempNote_'); + $notesContainer.append(this.createPlaceholderNote({ + formContent: tempFormContent, + uniqueId: noteUniqueId, + isDiscussionNote, + currentUsername: gon.current_username, + currentUserFullname: gon.current_user_fullname, + currentUserAvatar: gon.current_user_avatar_url, + })); + } - tempFormContent = formContent; - if (this.hasQuickActions(formContent)) { - tempFormContent = this.stripQuickActions(formContent); - hasQuickActions = true; - } + // Show placeholder system note + if (hasQuickActions) { + systemNoteUniqueId = _.uniqueId('tempSystemNote_'); + $notesContainer.append(this.createPlaceholderSystemNote({ + formContent: this.getQuickActionDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)), + uniqueId: systemNoteUniqueId, + })); + } - // Show placeholder note - if (tempFormContent) { - noteUniqueId = _.uniqueId('tempNote_'); - $notesContainer.append(this.createPlaceholderNote({ - formContent: tempFormContent, - uniqueId: noteUniqueId, - isDiscussionNote, - currentUsername: gon.current_username, - currentUserFullname: gon.current_user_fullname, - currentUserAvatar: gon.current_user_avatar_url, - })); + // Clear the form textarea + if ($notesContainer.length) { + if (isMainForm) { + this.resetMainTargetForm(e); + } else if (isDiscussionForm) { + this.removeDiscussionNoteForm($form); } + } - // Show placeholder system note - if (hasQuickActions) { - systemNoteUniqueId = _.uniqueId('tempSystemNote_'); - $notesContainer.append(this.createPlaceholderSystemNote({ - formContent: this.getQuickActionDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)), - uniqueId: systemNoteUniqueId, - })); - } + /* 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(`#${noteUniqueId}`).remove(); - // Clear the form textarea - if ($notesContainer.length) { - if (isMainForm) { - this.resetMainTargetForm(e); - } else if (isDiscussionForm) { - this.removeDiscussionNoteForm($form); + // Reset cached commands list when command is applied + if (hasQuickActions) { + $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho'); } - } - - /* 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(`#${noteUniqueId}`).remove(); - // Reset cached commands list when command is applied - if (hasQuickActions) { - $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho'); - } - - // Clear previous form errors - this.clearFlashWrapper(); + // Clear previous form errors + this.clearFlashWrapper(); - // Check if this was discussion comment - if (isDiscussionForm) { - // Remove flash-container - $notesContainer.find('.flash-container').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')); - } + // 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); + // 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); + // 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.handleQuickActions(note); - } + if (note.commands_changes) { + this.handleQuickActions(note); + } - $form.trigger('ajax:success', [note]); - }).fail(() => { - // Submission failed, remove placeholder note and show Flash error message - $notesContainer.find(`#${noteUniqueId}`).remove(); + $form.trigger('ajax:success', [note]); + }).fail(() => { + // Submission failed, remove placeholder note and show Flash error message + $notesContainer.find(`#${noteUniqueId}`).remove(); - if (hasQuickActions) { - $notesContainer.find(`#${systemNoteUniqueId}`).remove(); - } + if (hasQuickActions) { + $notesContainer.find(`#${systemNoteUniqueId}`).remove(); + } - // Show form again on UI on failure - if (isDiscussionForm && $notesContainer.length) { - const replyButton = $notesContainer.parent().find('.js-discussion-reply-button'); - this.replyToDiscussionNote(replyButton[0]); - $form = $notesContainer.parent().find('form'); - } + // Show form again on UI on failure + if (isDiscussionForm && $notesContainer.length) { + const replyButton = $notesContainer.parent().find('.js-discussion-reply-button'); + this.replyToDiscussionNote(replyButton[0]); + $form = $notesContainer.parent().find('form'); + } - $form.find('.js-note-text').val(formContent); - this.reenableTargetFormSubmitButton(e); - this.addNoteError($form); - }); + $form.find('.js-note-text').val(formContent); + this.reenableTargetFormSubmitButton(e); + this.addNoteError($form); + }); - return $closeBtn.text($closeBtn.data('original-text')); - }; + 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 + */ + updateComment(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(_.escape(formContent)); + $editingNote.removeClass('is-editing fade-in-full').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(note, $editingNote); + }) + .fail(() => { + // Submission failed, revert back to original note + $noteBodyText.html(_.escape(cachedNoteBodyText)); + $editingNote.removeClass('being-posted fade-in'); + $editingNote.find('.fa.fa-spinner').remove(); + + // Show Flash message about failure + this.updateNoteError(); + }); - /** - * 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(_.escape(formContent)); - $editingNote.removeClass('is-editing fade-in-full').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(note, $editingNote); - }) - .fail(() => { - // Submission failed, revert back to original note - $noteBodyText.html(_.escape(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 $closeBtn.text($closeBtn.data('original-text')); + } +} - return Notes; - })(); -}).call(window); +window.Notes = Notes; diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index da7c0c5a36c..322162afdb8 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -10,8 +10,6 @@ import Cookies from 'js-cookie'; this.$sidebarInner = this.sidebar.find('.issuable-sidebar'); this.$navGitlab = $('.navbar-gitlab'); - this.$layoutNav = $('.layout-nav'); - this.$subScroll = $('.sub-nav-scroll'); this.$rightSidebar = $('.js-right-sidebar'); this.removeListeners(); @@ -215,7 +213,7 @@ import Cookies from 'js-cookie'; }; Sidebar.prototype.setSidebarHeight = function() { - const $navHeight = this.$navGitlab.outerHeight() + this.$layoutNav.outerHeight() + (this.$subScroll ? this.$subScroll.outerHeight() : 0); + const $navHeight = this.$navGitlab.outerHeight(); const diff = $navHeight - $(window).scrollTop(); if (diff > 0) { this.$rightSidebar.outerHeight($(window).height() - diff); diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index da03e4f5b5e..31220ab438e 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -12,6 +12,12 @@ &.readme-holder { margin: $gl-padding 0; + + &.limited-width-container .file-content { + max-width: $limited-layout-width-sm; + margin-left: auto; + margin-right: auto; + } } table { diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 49bff23452d..4a9d41b4fda 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -53,7 +53,7 @@ body { } &.limit-container-width-sm { - max-width: 790px; + max-width: $limited-layout-width-sm; } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 13c9c6c9fb3..476427c1593 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -161,6 +161,7 @@ $progress-color: #c0392b; $header-height: 50px; $fixed-layout-width: 1280px; $limited-layout-width: 990px; +$limited-layout-width-sm: 790px; $gl-avatar-size: 40px; $error-exclamation-point: $red-500; $border-radius-default: 3px; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 89bd437b362..1046ebfa2e2 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -83,6 +83,7 @@ .avatar { float: none; + margin-right: 0; } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 20f2eec9af5..0d1a360d12c 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -11,7 +11,9 @@ .commit-box, .info-well, .commit-ci-menu, - .files-changed { + .files-changed, + .limited-header-width, + .limited-width-notes { @extend .fixed-width-container; } diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 4be0e133b69..f21005895e4 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -136,10 +136,6 @@ width: 250px; } - @media (min-width: $screen-md-min) { - width: 350px; - } - &.input-short { @media (min-width: $screen-md-min) { width: 170px; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 2dc7f73a295..59e0624d94e 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -419,7 +419,7 @@ .commit { margin: 0; - padding: 10px 0; + padding: 10px; list-style: none; &:hover { diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index a85ba3a5955..9637d26e56d 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -133,7 +133,7 @@ overflow: hidden; display: inline-block; white-space: nowrap; - vertical-align: top; + vertical-align: middle; text-overflow: ellipsis; } diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 9c623c9ba7c..b5f54d3e154 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -4,4 +4,14 @@ module UsersHelper title: user.email, class: 'has-tooltip commit-committer-link') end + + def user_email_help_text(user) + return 'We also use email for avatar detection if no avatar is uploaded.' unless user.unconfirmed_email.present? + + confirmation_link = link_to 'Resend confirmation e-mail', user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post + + h('Please click the link in the confirmation email before continuing. It was sent to ') + + content_tag(:strong) { user.unconfirmed_email } + h('.') + + content_tag(:p) { confirmation_link } + end end diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index 8fe0bd149f3..45e39252e16 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -18,5 +18,4 @@ - if current_user To see all merge requests you should visit #{link_to 'dashboard', merge_requests_dashboard_path} page. - .prepend-top-default - = render 'shared/merge_requests' + = render 'shared/merge_requests' diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 15672289c65..087ae778b0f 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -1,6 +1,6 @@ = render 'profiles/head' -= form_for @user, url: profile_path, method: :put, html: { multipart: true, class: "edit-user prepend-top-default" }, authenticity_token: true do |f| += bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default' }, authenticity_token: true do |f| = form_errors(@user) .row @@ -11,11 +11,11 @@ - if @user.avatar? You can change your avatar here - if gravatar_enabled? - or remove the current avatar to revert to #{link_to Gitlab.config.gravatar.host, "http://" + Gitlab.config.gravatar.host} + or remove the current avatar to revert to #{link_to Gitlab.config.gravatar.host, 'http://' + Gitlab.config.gravatar.host} - else You can upload an avatar here - if gravatar_enabled? - or change it at #{link_to Gitlab.config.gravatar.host, "http://" + Gitlab.config.gravatar.host} + or change it at #{link_to Gitlab.config.gravatar.host, 'http://' + Gitlab.config.gravatar.host} .col-lg-9 .clearfix.avatar-image.append-bottom-default = link_to avatar_icon(@user, 400), target: '_blank', rel: 'noopener noreferrer' do @@ -26,12 +26,12 @@ %a.btn.js-choose-user-avatar-button Browse file... %span.avatar-file-name.prepend-left-default.js-avatar-filename No file chosen - = f.file_field :avatar, class: "js-user-avatar-input hidden", accept: "image/*" + = f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' .help-block The maximum file size allowed is 200KB. - if @user.avatar? %hr - = link_to 'Remove avatar', profile_avatar_path, data: { confirm: "Avatar will be removed. Are you sure?" }, method: :delete, class: "btn btn-gray" + = link_to 'Remove avatar', profile_avatar_path, data: { confirm: 'Avatar will be removed. Are you sure?' }, method: :delete, class: 'btn btn-gray' %hr .row .col-lg-3.profile-settings-sidebar @@ -43,91 +43,50 @@ Some options are unavailable for LDAP accounts .col-lg-9 .row - .form-group.col-md-9 - = f.label :name, class: "label-light" - = f.text_field :name, class: "form-control", required: true - %span.help-block Enter your name, so people you know can recognize you. + = f.text_field :name, required: true, wrapper: { class: 'col-md-9' }, + help: 'Enter your name, so people you know can recognize you.' + = f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' } - .form-group.col-md-3 - = f.label :id, class: 'label-light' do - User ID - = f.text_field :id, class: 'form-control', readonly: true - - - .form-group - = f.label :email, class: "label-light" - - if @user.external_email? - = f.text_field :email, class: "form-control", required: true, readonly: true - %span.help-block.light - Your email address was automatically set based on your #{email_provider_label} account. - - else - - if @user.temp_oauth_email? - = f.text_field :email, class: "form-control", required: true, value: nil - - else - = f.text_field :email, class: "form-control", required: true - - if @user.unconfirmed_email.present? - %span.help-block - Please click the link in the confirmation email before continuing. It was sent to - = succeed "." do - %strong= @user.unconfirmed_email - %p - = link_to "Resend confirmation e-mail", user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post - - - else - %span.help-block We also use email for avatar detection if no avatar is uploaded. - .form-group - = f.label :public_email, class: "label-light" - = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), { include_blank: 'Do not show on profile' }, class: "select2" - %span.help-block This email will be displayed on your public profile. - .form-group - = f.label :preferred_language, class: "label-light" - = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }, - {}, class: "select2" - %span.help-block This feature is experimental and translations are not complete yet. - .form-group - = f.label :skype, class: "label-light" - = f.text_field :skype, class: "form-control" - .form-group - = f.label :linkedin, class: "label-light" - = f.text_field :linkedin, class: "form-control" - .form-group - = f.label :twitter, class: "label-light" - = f.text_field :twitter, class: "form-control" - .form-group - = f.label :website_url, 'Website', class: "label-light" - = f.text_field :website_url, class: "form-control" - .form-group - = f.label :location, 'Location', class: "label-light" - = f.text_field :location, class: "form-control" - .form-group - = f.label :organization, 'Organization', class: "label-light" - = f.text_field :organization, class: "form-control" - .form-group - = f.label :bio, class: "label-light" - = f.text_area :bio, rows: 4, class: "form-control", maxlength: 250 - %span.help-block Tell us about yourself in fewer than 250 characters. + - if @user.external_email? + = f.text_field :email, required: true, readonly: true, help: 'Your email address was automatically set based on your #{email_provider_label} account.' + - else + = f.text_field :email, required: true, value: (@user.email unless @user.temp_oauth_email?), + help: user_email_help_text(@user) + = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), + { help: 'This email will be displayed on your public profile.', include_blank: 'Do not show on profile' }, + control_class: 'select2' + = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }, + { help: 'This feature is experimental and translations are not complete yet.' }, + control_class: 'select2' + = f.text_field :skype + = f.text_field :linkedin + = f.text_field :twitter + = f.text_field :website_url, label: 'Website' + = f.text_field :location + = f.text_field :organization + = f.text_area :bio, rows: 4, maxlength: 250, help: 'Tell us about yourself in fewer than 250 characters.' .prepend-top-default.append-bottom-default - = f.submit 'Update profile settings', class: "btn btn-success" - = link_to "Cancel", user_path(current_user), class: "btn btn-cancel" + = f.submit 'Update profile settings', class: 'btn btn-success' + = link_to 'Cancel', user_path(current_user), class: 'btn btn-cancel' .modal.modal-profile-crop .modal-dialog .modal-content .modal-header - %button.close{ :type => "button", :'data-dismiss' => "modal" } + %button.close{ type: 'button', 'data-dismiss': 'modal' } %span × %h4.modal-title Position and size your new avatar .modal-body .profile-crop-image-container - %img.modal-profile-crop-image{ alt: "Avatar cropper" } + %img.modal-profile-crop-image{ alt: 'Avatar cropper' } .crop-controls .btn-group - %button.btn.btn-primary{ data: { method: "zoom", option: "0.1" } } + %button.btn.btn-primary{ data: { method: 'zoom', option: '0.1' } } %span.fa.fa-search-plus - %button.btn.btn-primary{ data: { method: "zoom", option: "-0.1" } } + %button.btn.btn-primary{ data: { method: 'zoom', option: '-0.1' } } %span.fa.fa-search-minus .modal-footer - %button.btn.btn-primary.js-upload-user-avatar{ :type => "button" } + %button.btn.btn-primary.js-upload-user-avatar{ type: 'button' } Set new profile picture diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 3a1be3fa4b6..b778e8af121 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -1,6 +1,6 @@ - @no_container = true - container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : '' -- limited_container_width = fluid_layout || diff_view == :inline ? '' : 'limit-container-width' +- limited_container_width = fluid_layout ? '' : 'limit-container-width' - page_title "#{@commit.title} (#{@commit.short_id})", "Commits" - page_description @commit.description = render "projects/commits/head" @@ -13,7 +13,8 @@ .block-connector = render "projects/diffs/diffs", diffs: @diffs, environment: @environment - = render "shared/notes/notes_with_form", :autocomplete => true - - if can_collaborate_with_project? - - %w(revert cherry-pick).each do |type| - = render "projects/commit/change", type: type, commit: @commit, title: @commit.title + .limited-width-notes + = render "shared/notes/notes_with_form", :autocomplete => true + - if can_collaborate_with_project? + - %w(revert cherry-pick).each do |type| + = render "projects/commit/change", type: type, commit: @commit, title: @commit.title diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index 9b2ec9ae41c..26f3c297260 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -3,24 +3,25 @@ .table-mobile-header{ role: 'rowheader' } ID %strong.table-mobile-content ##{deployment.iid} - .table-section.section-40{ role: 'gridcell' } + .table-section.section-30{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' } Commit = render 'projects/deployments/commit', deployment: deployment - .table-section.section-15.build-column{ role: 'gridcell' } + .table-section.section-25.build-column{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' } Job - if deployment.deployable - = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link table-mobile-content' do - #{deployment.deployable.name} (##{deployment.deployable.id}) - - if deployment.user - by - = user_avatar(user: deployment.user, size: 20) + .table-mobile-content + = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link' do + #{deployment.deployable.name} (##{deployment.deployable.id}) + - if deployment.user + by + = user_avatar(user: deployment.user, size: 20) .table-section.section-15{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' } Created %span.table-mobile-content= time_ago_with_tooltip(deployment.created_at) .table-section.section-20.table-button-footer{ role: 'gridcell' } - .btn-group.table-action-button + .btn-group.table-action-buttons = render 'projects/deployments/actions', deployment: deployment = render 'projects/deployments/rollback', deployment: deployment diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 296e37e20e6..78057facde7 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -1,10 +1,12 @@ +- @content_class = "limit-container-width" unless fluid_layout + = render "projects/settings/head" .project-edit-container .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 Project settings - .col-lg-9 + .col-lg-8 .project-edit-errors = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project" }, authenticity_token: true do |f| %fieldset @@ -39,66 +41,66 @@ Sharing & Permissions .form_group.prepend-top-20.sharing-and-permissions .row.js-visibility-select - .col-md-9 + .col-md-8 .label-light = label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level = link_to icon('question-circle'), help_page_path("public_access/public_access") %span.help-block - .col-md-3.visibility-select-container + .col-md-4.visibility-select-container = render('projects/visibility_select', model_method: :visibility_level, form: f, selected_level: @project.visibility_level) = f.fields_for :project_feature do |feature_fields| %fieldset.features .row - .col-md-9.project-feature + .col-md-8.project-feature = feature_fields.label :repository_access_level, "Repository", class: 'label-light' %span.help-block View and edit files in this project - .col-md-3.js-repo-access-level + .col-md-4.js-repo-access-level = project_feature_access_select(:repository_access_level) .row - .col-md-9.project-feature.nested + .col-md-8.project-feature.nested = feature_fields.label :merge_requests_access_level, "Merge requests", class: 'label-light' %span.help-block Submit changes to be merged upstream - .col-md-3 + .col-md-4 = project_feature_access_select(:merge_requests_access_level) .row - .col-md-9.project-feature.nested + .col-md-8.project-feature.nested = feature_fields.label :builds_access_level, "Pipelines", class: 'label-light' %span.help-block Build, test, and deploy your changes - .col-md-3 + .col-md-4 = project_feature_access_select(:builds_access_level) .row - .col-md-9.project-feature + .col-md-8.project-feature = feature_fields.label :snippets_access_level, "Snippets", class: 'label-light' %span.help-block Share code pastes with others out of Git repository - .col-md-3 + .col-md-4 = project_feature_access_select(:snippets_access_level) .row - .col-md-9.project-feature + .col-md-8.project-feature = feature_fields.label :issues_access_level, "Issues", class: 'label-light' %span.help-block Lightweight issue tracking system for this project - .col-md-3 + .col-md-4 = project_feature_access_select(:issues_access_level) .row - .col-md-9.project-feature + .col-md-8.project-feature = feature_fields.label :wiki_access_level, "Wiki", class: 'label-light' %span.help-block Pages for project documentation - .col-md-3 + .col-md-4 = project_feature_access_select(:wiki_access_level) .form-group = render 'shared/allow_request_access', form: f - if Gitlab.config.lfs.enabled && current_user.admin? .row.js-lfs-enabled - .col-md-9 + .col-md-8 = f.label :lfs_enabled, 'LFS', class: 'label-light' %span.help-block Git Large File Storage = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') - .col-md-3 + .col-md-4 .select-wrapper = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control project-repo-select select-control', data: { field: 'lfs_enabled' } = icon('chevron-down') @@ -138,19 +140,19 @@ .row.prepend-top-default %hr .row.prepend-top-default - .col-lg-3 + .col-lg-4 %h4.prepend-top-0 Housekeeping %p.append-bottom-0 %p Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects. - .col-lg-9 + .col-lg-8 = link_to 'Housekeeping', housekeeping_namespace_project_path(@project.namespace, @project), method: :post, class: "btn btn-default" %hr .row.prepend-top-default - .col-lg-3 + .col-lg-4 %h4.prepend-top-0 Export project %p.append-bottom-0 @@ -159,7 +161,7 @@ %p Once the exported file is ready, you will receive a notification email with a download link. - .col-lg-9 + .col-lg-8 - if @project.export_project_path = link_to 'Download export', download_export_namespace_project_path(@project.namespace, @project), @@ -190,7 +192,7 @@ - if can? current_user, :archive_project, @project %hr .row.prepend-top-default - .col-lg-3 + .col-lg-4 %h4.warning-title.prepend-top-0 - if @project.archived? Unarchive project @@ -201,7 +203,7 @@ Unarchiving the project will mark its repository as active. The project can be committed to. - else Archiving the project will mark its repository as read-only. It is hidden from the dashboard and doesn't show up in searches. - .col-lg-9 + .col-lg-8 - if @project.archived? %p %strong Once active this project shows up in the search and on the dashboard. @@ -216,10 +218,10 @@ method: :post, class: "btn btn-warning" %hr .row.prepend-top-default - .col-lg-3 + .col-lg-4 %h4.prepend-top-0.warning-title Rename repository - .col-lg-9 + .col-lg-8 = render 'projects/errors' = form_for([@project.namespace.becomes(Namespace), @project]) do |f| .form-group.project_name_holder @@ -244,12 +246,12 @@ - if can?(current_user, :change_namespace, @project) %hr .row.prepend-top-default - .col-lg-3 + .col-lg-4 %h4.prepend-top-0.danger-title Transfer project to new group %p.append-bottom-0 Please select the group you want to transfer this project to in the dropdown to the right. - .col-lg-9 + .col-lg-8 = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_namespace_project_path(@project.namespace, @project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f| .form-group = label_tag :new_namespace_id, nil, class: 'label-light' do @@ -265,7 +267,7 @@ - if @project.forked? && can?(current_user, :remove_fork_project, @project) %hr .row.prepend-top-default.append-bottom-default - .col-lg-3 + .col-lg-4 %h4.prepend-top-0.danger-title Remove fork relationship %p.append-bottom-0 @@ -273,7 +275,7 @@ This will remove the fork relationship to source project = succeed "." do = link_to @project.forked_from_project.name_with_namespace, project_path(@project.forked_from_project) - .col-lg-9 + .col-lg-8 = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_namespace_project_path(@project.namespace, @project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f| %p %strong Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source. @@ -281,12 +283,12 @@ - if can?(current_user, :remove_project, @project) %hr .row.prepend-top-default.append-bottom-default - .col-lg-3 + .col-lg-4 %h4.prepend-top-0.danger-title Remove project %p.append-bottom-0 Removing the project will delete its repository and all related resources including issues, merge requests etc. - .col-lg-9 + .col-lg-8 = form_tag(namespace_project_path(@project.namespace, @project), method: :delete) do %p %strong Removed projects cannot be restored! diff --git a/app/views/projects/hooks/_index.html.haml b/app/views/projects/hooks/_index.html.haml index 676b7c345bc..776681ea09a 100644 --- a/app/views/projects/hooks/_index.html.haml +++ b/app/views/projects/hooks/_index.html.haml @@ -1,12 +1,12 @@ .row.prepend-top-default - .col-lg-3 + .col-lg-4 %h4.prepend-top-0 = page_title %p #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be used for binding events when something is happening within the project. - .col-lg-9.append-bottom-default + .col-lg-8.append-bottom-default = form_for @hook, as: :hook, url: polymorphic_path([@project.namespace.becomes(Namespace), @project, :hooks]) do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } = f.submit 'Add webhook', class: 'btn btn-create' diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/pipelines_settings/_badge.html.haml index 43bbd735059..3de518c8b9a 100644 --- a/app/views/projects/pipelines_settings/_badge.html.haml +++ b/app/views/projects/pipelines_settings/_badge.html.haml @@ -1,8 +1,8 @@ %div{ class: badge.title.gsub(' ', '-') } - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 = badge.title.capitalize - .col-lg-9 + .col-lg-8 .prepend-top-10 .panel.panel-default .panel-heading diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml index 3b17daeb6da..580129ca809 100644 --- a/app/views/projects/pipelines_settings/_show.html.haml +++ b/app/views/projects/pipelines_settings/_show.html.haml @@ -1,8 +1,8 @@ .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 Pipelines - .col-lg-9 + .col-lg-8 = form_for @project, url: namespace_project_pipelines_settings_path(@project.namespace.becomes(Namespace), @project) do |f| %fieldset.builds-feature - unless @repository.gitlab_ci_yml diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml index cfae371e169..fa99610c0be 100644 --- a/app/views/projects/project_members/_index.html.haml +++ b/app/views/projects/project_members/_index.html.haml @@ -1,5 +1,5 @@ .row.prepend-top-default - .col-lg-3.settings-sidebar + .col-lg-4.settings-sidebar %h4.prepend-top-0 Project members - if can?(current_user, :admin_project_member, @project) @@ -13,7 +13,7 @@ %i Masters or %i Owners - .col-lg-9 + .col-lg-8 .light - if can?(current_user, :admin_project_member, @project) %ul.nav-links.project-member-tabs{ role: 'tablist' } diff --git a/app/views/projects/services/_index.html.haml b/app/views/projects/services/_index.html.haml index 86d5a0ec7b8..997b702da33 100644 --- a/app/views/projects/services/_index.html.haml +++ b/app/views/projects/services/_index.html.haml @@ -1,9 +1,9 @@ .row.prepend-top-default.append-bottom-default - .col-lg-3 + .col-lg-4 %h4.prepend-top-0 Project services %p Project services allow you to integrate GitLab with other applications - .col-lg-9 + .col-lg-8 %table.table %colgroup %col diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index e8d2e91bd76..00ccc3ec41e 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -1,3 +1,4 @@ +- @content_class = "limit-container-width" unless fluid_layout - page_title "Pipelines" = render "projects/settings/head" diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml index f69992566b5..1d1d0849289 100644 --- a/app/views/projects/settings/integrations/show.html.haml +++ b/app/views/projects/settings/integrations/show.html.haml @@ -1,3 +1,4 @@ +- @content_class = "limit-container-width" unless fluid_layout - page_title 'Integrations' = render "projects/settings/head" = render 'projects/hooks/index' diff --git a/app/views/projects/settings/members/show.html.haml b/app/views/projects/settings/members/show.html.haml index 343807b87cd..1e7695ac397 100644 --- a/app/views/projects/settings/members/show.html.haml +++ b/app/views/projects/settings/members/show.html.haml @@ -1,3 +1,5 @@ +- @content_class = "limit-container-width" unless fluid_layout + - page_title "Members" = render "projects/settings/head" diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index 847f3c2f348..d8e448dd2af 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -1,3 +1,4 @@ +- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout - page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets" = render 'shared/snippets/header' @@ -9,4 +10,4 @@ .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes= render "shared/notes/notes_with_form", :autocomplete => true + #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml index de57cd4ba00..f9147815427 100644 --- a/app/views/projects/tree/_readme.html.haml +++ b/app/views/projects/tree/_readme.html.haml @@ -1,5 +1,5 @@ - if readme.rich_viewer - %article.file-holder.readme-holder + %article.file-holder.readme-holder{ class: ("limited-width-container" unless fluid_layout) } .js-file-title.file-title = blob_icon readme.mode, readme.name = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path)) do diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index cc74e50a5e3..e9a2f803edd 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -1,7 +1,7 @@ .row.prepend-top-default.append-bottom-default.triggers-container - .col-lg-3 + .col-lg-4 = render "projects/triggers/content" - .col-lg-9 + .col-lg-8 .panel.panel-default .panel-heading %h4.panel-title diff --git a/app/views/projects/variables/_index.html.haml b/app/views/projects/variables/_index.html.haml index 1b852a9c5b3..5e6786f6698 100644 --- a/app/views/projects/variables/_index.html.haml +++ b/app/views/projects/variables/_index.html.haml @@ -1,7 +1,7 @@ .row.prepend-top-default.append-bottom-default - .col-lg-3 + .col-lg-4 = render "projects/variables/content" - .col-lg-9 + .col-lg-8 %h5.prepend-top-0 Add a variable = render "projects/variables/form", btn_text: "Add new variable" diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index 813d8d69d8d..17b34c5eeb3 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -16,7 +16,7 @@ - else = render "snippets/actions" -.snippet-header +.snippet-header.limited-header-width %h2.snippet-title.prepend-top-0.append-bottom-0 = markdown_field(@snippet, :title) diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 216184eb839..8818590362d 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -1,3 +1,4 @@ +- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout - page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets" = render 'shared/snippets/header' @@ -9,4 +10,4 @@ .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes= render "shared/notes/notes_with_form", :autocomplete => false + #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => false diff --git a/changelogs/unreleased/33538-update-ci-dockerfile-now-that-chrome-headless-no-longer-in-beta.yml b/changelogs/unreleased/33538-update-ci-dockerfile-now-that-chrome-headless-no-longer-in-beta.yml new file mode 100644 index 00000000000..590472c0990 --- /dev/null +++ b/changelogs/unreleased/33538-update-ci-dockerfile-now-that-chrome-headless-no-longer-in-beta.yml @@ -0,0 +1,4 @@ +--- +title: Update QA Dockerfile to lock Chrome browser version +merge_request: 12071 +author: diff --git a/changelogs/unreleased/commit-comments-limited-width.yml b/changelogs/unreleased/commit-comments-limited-width.yml new file mode 100644 index 00000000000..97f50105495 --- /dev/null +++ b/changelogs/unreleased/commit-comments-limited-width.yml @@ -0,0 +1,4 @@ +--- +title: Limit commit & snippets comments width +merge_request: +author: diff --git a/changelogs/unreleased/dm-unnecessary-top-padding.yml b/changelogs/unreleased/dm-unnecessary-top-padding.yml new file mode 100644 index 00000000000..4557c06f8e7 --- /dev/null +++ b/changelogs/unreleased/dm-unnecessary-top-padding.yml @@ -0,0 +1,4 @@ +--- +title: Remove unnecessary top padding on group MR index +merge_request: +author: diff --git a/changelogs/unreleased/project-readme-limited-width.yml b/changelogs/unreleased/project-readme-limited-width.yml new file mode 100644 index 00000000000..17d87a5691e --- /dev/null +++ b/changelogs/unreleased/project-readme-limited-width.yml @@ -0,0 +1,4 @@ +--- +title: Limit the width of the projects README text +merge_request: +author: diff --git a/config/initializers/bootstrap_form.rb b/config/initializers/bootstrap_form.rb new file mode 100644 index 00000000000..11171b38a85 --- /dev/null +++ b/config/initializers/bootstrap_form.rb @@ -0,0 +1,7 @@ +module BootstrapFormBuilderCustomization + def label_class + "label-light" + end +end + +BootstrapForm::FormBuilder.prepend(BootstrapFormBuilderCustomization) diff --git a/config/webpack.config.js b/config/webpack.config.js index f27b7cae310..2e8c94655c1 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -241,6 +241,7 @@ if (IS_DEV_SERVER) { port: DEV_SERVER_PORT, headers: { 'Access-Control-Allow-Origin': '*' }, stats: 'errors-only', + hot: DEV_SERVER_LIVERELOAD, inline: DEV_SERVER_LIVERELOAD }; config.output.publicPath = '//' + DEV_SERVER_HOST + ':' + DEV_SERVER_PORT + config.output.publicPath; @@ -248,6 +249,9 @@ if (IS_DEV_SERVER) { // watch node_modules for changes if we encounter a missing module compile error new WatchMissingNodeModulesPlugin(path.join(ROOT_PATH, 'node_modules')) ); + if (DEV_SERVER_LIVERELOAD) { + config.plugins.push(new webpack.HotModuleReplacementPlugin()); + } } if (WEBPACK_REPORT) { diff --git a/qa/Dockerfile b/qa/Dockerfile index 9e2a74ef991..97ae1961e34 100644 --- a/qa/Dockerfile +++ b/qa/Dockerfile @@ -1,6 +1,9 @@ FROM ruby:2.3 LABEL maintainer "Grzegorz Bizon <grzegorz@gitlab.com>" +ENV CHROME_VERSION 59.0.3071.109-1 +ENV CHROME_DRIVER_VERSION 2.30 + ## # Update APT sources and install some dependencies # @@ -8,15 +11,16 @@ RUN sed -i "s/httpredir.debian.org/ftp.us.debian.org/" /etc/apt/sources.list RUN apt-get update && apt-get install -y wget git unzip xvfb ## -# At this point Google Chrome Beta is 59 - first version with headless support +# Install Google Chrome version with headless support # -RUN wget -q https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb -RUN dpkg -i google-chrome-beta_current_amd64.deb; apt-get -fy install +RUN curl -sS -L https://dl.google.com/linux/linux_signing_key.pub | apt-key add - +RUN echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list +RUN apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y google-chrome-stable=$CHROME_VERSION ## # Install chromedriver to make it work with Selenium # -RUN wget -q https://chromedriver.storage.googleapis.com/2.29/chromedriver_linux64.zip +RUN wget -q https://chromedriver.storage.googleapis.com/$CHROME_DRIVER_VERSION/chromedriver_linux64.zip RUN unzip chromedriver_linux64.zip -d /usr/local/bin RUN apt-get clean diff --git a/qa/qa/specs/config.rb b/qa/qa/specs/config.rb index 78a93828d36..b341aa3094a 100644 --- a/qa/qa/specs/config.rb +++ b/qa/qa/specs/config.rb @@ -55,7 +55,7 @@ module QA Capybara.register_driver :chrome do |app| capabilities = Selenium::WebDriver::Remote::Capabilities.chrome( 'chromeOptions' => { - 'binary' => '/opt/google/chrome-beta/google-chrome-beta', + 'binary' => '/usr/bin/google-chrome-stable', 'args' => %w[headless no-sandbox disable-gpu] } ) diff --git a/spec/javascripts/boards/board_new_issue_spec.js b/spec/javascripts/boards/board_new_issue_spec.js index 832877de71c..c0a7323a505 100644 --- a/spec/javascripts/boards/board_new_issue_spec.js +++ b/spec/javascripts/boards/board_new_issue_spec.js @@ -12,6 +12,7 @@ import './mock_data'; describe('Issue boards new issue form', () => { let vm; let list; + let newIssueMock; const promiseReturn = { json() { return { @@ -21,7 +22,11 @@ describe('Issue boards new issue form', () => { }; const submitIssue = () => { - vm.$el.querySelector('.btn-success').click(); + const dummySubmitEvent = { + preventDefault() {}, + }; + vm.$refs.submitButton = vm.$el.querySelector('.btn-success'); + return vm.submit(dummySubmitEvent); }; beforeEach((done) => { @@ -32,29 +37,35 @@ describe('Issue boards new issue form', () => { gl.issueBoards.BoardsStore.create(); gl.IssueBoardsApp = new Vue(); - setTimeout(() => { - list = new List(listObj); - - spyOn(gl.boardService, 'newIssue').and.callFake(() => new Promise((resolve, reject) => { - if (vm.title === 'error') { - reject(); - } else { - resolve(promiseReturn); - } - })); - - vm = new BoardNewIssueComp({ - propsData: { - list, - }, - }).$mount(); - - done(); - }, 0); + list = new List(listObj); + + newIssueMock = Promise.resolve(promiseReturn); + spyOn(list, 'newIssue').and.callFake(() => newIssueMock); + + vm = new BoardNewIssueComp({ + propsData: { + list, + }, + }).$mount(); + + Vue.nextTick() + .then(done) + .catch(done.fail); }); - afterEach(() => { - Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor); + it('calls submit if submit button is clicked', (done) => { + spyOn(vm, 'submit'); + vm.title = 'Testing Title'; + + Vue.nextTick() + .then(() => { + vm.$el.querySelector('.btn-success').click(); + + expect(vm.submit.calls.count()).toBe(1); + expect(vm.$refs['submit-button']).toBe(vm.$el.querySelector('.btn-success')); + }) + .then(done) + .catch(done.fail); }); it('disables submit button if title is empty', () => { @@ -64,136 +75,122 @@ describe('Issue boards new issue form', () => { it('enables submit button if title is not empty', (done) => { vm.title = 'Testing Title'; - setTimeout(() => { - expect(vm.$el.querySelector('.form-control').value).toBe('Testing Title'); - expect(vm.$el.querySelector('.btn-success').disabled).not.toBe(true); - - done(); - }, 0); + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.form-control').value).toBe('Testing Title'); + expect(vm.$el.querySelector('.btn-success').disabled).not.toBe(true); + }) + .then(done) + .catch(done.fail); }); it('clears title after clicking cancel', (done) => { vm.$el.querySelector('.btn-default').click(); - setTimeout(() => { - expect(vm.title).toBe(''); - done(); - }, 0); + Vue.nextTick() + .then(() => { + expect(vm.title).toBe(''); + }) + .then(done) + .catch(done.fail); }); it('does not create new issue if title is empty', (done) => { - submitIssue(); - - setTimeout(() => { - expect(gl.boardService.newIssue).not.toHaveBeenCalled(); - done(); - }, 0); + submitIssue() + .then(() => { + expect(list.newIssue).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); }); describe('submit success', () => { it('creates new issue', (done) => { vm.title = 'submit title'; - setTimeout(() => { - submitIssue(); - - expect(gl.boardService.newIssue).toHaveBeenCalled(); - done(); - }, 0); + Vue.nextTick() + .then(submitIssue) + .then(() => { + expect(list.newIssue).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); }); it('enables button after submit', (done) => { vm.title = 'submit issue'; - setTimeout(() => { - submitIssue(); - - expect(vm.$el.querySelector('.btn-success').disabled).toBe(false); - done(); - }, 0); + Vue.nextTick() + .then(submitIssue) + .then(() => { + expect(vm.$el.querySelector('.btn-success').disabled).toBe(false); + }) + .then(done) + .catch(done.fail); }); it('clears title after submit', (done) => { vm.title = 'submit issue'; - Vue.nextTick(() => { - submitIssue(); - - setTimeout(() => { + Vue.nextTick() + .then(submitIssue) + .then(() => { expect(vm.title).toBe(''); - done(); - }, 0); - }); - }); - - it('adds new issue to top of list after submit request', (done) => { - vm.title = 'submit issue'; - - setTimeout(() => { - submitIssue(); - - setTimeout(() => { - expect(list.issues.length).toBe(2); - expect(list.issues[0].title).toBe('submit issue'); - expect(list.issues[0].subscribed).toBe(true); - done(); - }, 0); - }, 0); + }) + .then(done) + .catch(done.fail); }); it('sets detail issue after submit', (done) => { expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe(undefined); vm.title = 'submit issue'; - setTimeout(() => { - submitIssue(); - - setTimeout(() => { + Vue.nextTick() + .then(submitIssue) + .then(() => { expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe('submit issue'); - done(); - }, 0); - }, 0); + }) + .then(done) + .catch(done.fail); }); it('sets detail list after submit', (done) => { vm.title = 'submit issue'; - setTimeout(() => { - submitIssue(); - - setTimeout(() => { + Vue.nextTick() + .then(submitIssue) + .then(() => { expect(gl.issueBoards.BoardsStore.detail.list.id).toBe(list.id); - done(); - }, 0); - }, 0); + }) + .then(done) + .catch(done.fail); }); }); describe('submit error', () => { - it('removes issue', (done) => { + beforeEach(() => { + newIssueMock = Promise.reject(new Error('My hovercraft is full of eels!')); vm.title = 'error'; + }); - setTimeout(() => { - submitIssue(); - - setTimeout(() => { + it('removes issue', (done) => { + Vue.nextTick() + .then(submitIssue) + .then(() => { expect(list.issues.length).toBe(1); - done(); - }, 0); - }, 0); + }) + .then(done) + .catch(done.fail); }); it('shows error', (done) => { - vm.title = 'error'; - - setTimeout(() => { - submitIssue(); - - setTimeout(() => { + Vue.nextTick() + .then(submitIssue) + .then(() => { expect(vm.error).toBe(true); - done(); - }, 0); - }, 0); + }) + .then(done) + .catch(done.fail); }); }); }); diff --git a/spec/javascripts/boards/list_spec.js b/spec/javascripts/boards/list_spec.js index 8e3d9fd77a0..db50829a276 100644 --- a/spec/javascripts/boards/list_spec.js +++ b/spec/javascripts/boards/list_spec.js @@ -150,4 +150,41 @@ describe('List model', () => { expect(list.getIssues).toHaveBeenCalled(); }); }); + + describe('newIssue', () => { + beforeEach(() => { + spyOn(gl.boardService, 'newIssue').and.returnValue(Promise.resolve({ + json() { + return { + iid: 42, + }; + }, + })); + }); + + it('adds new issue to top of list', (done) => { + list.issues.push(new ListIssue({ + title: 'Testing', + iid: _.random(10000), + confidential: false, + labels: [list.label], + assignees: [], + })); + const dummyIssue = new ListIssue({ + title: 'new issue', + iid: _.random(10000), + confidential: false, + labels: [list.label], + assignees: [], + }); + + list.newIssue(dummyIssue) + .then(() => { + expect(list.issues.length).toBe(2); + expect(list.issues[0]).toBe(dummyIssue); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index 8d239c9cc3f..16ae649ee60 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -48,18 +48,23 @@ describe('Filtered Search Manager', () => { </div> `); + spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {}); + }); + + const initializeManager = () => { + /* eslint-disable jasmine/no-unsafe-spy */ spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {}); spyOn(gl.FilteredSearchManager.prototype, 'tokenChange').and.callFake(() => {}); - spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {}); spyOn(gl.FilteredSearchDropdownManager.prototype, 'updateDropdownOffset').and.callFake(() => {}); spyOn(gl.utils, 'getParameterByName').and.returnValue(null); spyOn(gl.FilteredSearchVisualTokens, 'unselectTokens').and.callThrough(); + /* eslint-enable jasmine/no-unsafe-spy */ input = document.querySelector('.filtered-search'); tokensContainer = document.querySelector('.tokens-container'); manager = new gl.FilteredSearchManager(); manager.setup(); - }); + }; afterEach(() => { manager.cleanup(); @@ -67,33 +72,34 @@ describe('Filtered Search Manager', () => { describe('class constructor', () => { const isLocalStorageAvailable = 'isLocalStorageAvailable'; - let filteredSearchManager; beforeEach(() => { spyOn(RecentSearchesService, 'isAvailable').and.returnValue(isLocalStorageAvailable); spyOn(recentSearchesStoreSrc, 'default'); spyOn(RecentSearchesRoot.prototype, 'render'); - - filteredSearchManager = new gl.FilteredSearchManager(); - filteredSearchManager.setup(); - - return filteredSearchManager; }); it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => { + manager = new gl.FilteredSearchManager(); + expect(RecentSearchesService.isAvailable).toHaveBeenCalled(); expect(recentSearchesStoreSrc.default).toHaveBeenCalledWith({ isLocalStorageAvailable, allowedKeys: gl.FilteredSearchTokenKeys.getKeys(), }); }); + }); + + describe('setup', () => { + beforeEach(() => { + manager = new gl.FilteredSearchManager(); + }); it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => { spyOn(RecentSearchesService.prototype, 'fetch').and.callFake(() => Promise.reject(new RecentSearchesServiceError())); spyOn(window, 'Flash'); - filteredSearchManager = new gl.FilteredSearchManager(); - filteredSearchManager.setup(); + manager.setup(); expect(window.Flash).not.toHaveBeenCalled(); }); @@ -102,6 +108,7 @@ describe('Filtered Search Manager', () => { describe('searchState', () => { beforeEach(() => { spyOn(gl.FilteredSearchManager.prototype, 'search').and.callFake(() => {}); + initializeManager(); }); it('should blur button', () => { @@ -148,6 +155,10 @@ describe('Filtered Search Manager', () => { describe('search', () => { const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened'; + beforeEach(() => { + initializeManager(); + }); + it('should search with a single word', (done) => { input.value = 'searchTerm'; @@ -197,6 +208,10 @@ describe('Filtered Search Manager', () => { }); describe('handleInputPlaceholder', () => { + beforeEach(() => { + initializeManager(); + }); + it('should render placeholder when there is no input', () => { expect(input.placeholder).toEqual(placeholder); }); @@ -223,6 +238,10 @@ describe('Filtered Search Manager', () => { }); describe('checkForBackspace', () => { + beforeEach(() => { + initializeManager(); + }); + describe('tokens and no input', () => { beforeEach(() => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( @@ -260,6 +279,10 @@ describe('Filtered Search Manager', () => { }); describe('removeToken', () => { + beforeEach(() => { + initializeManager(); + }); + it('removes token even when it is already selected', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), @@ -291,6 +314,7 @@ describe('Filtered Search Manager', () => { describe('removeSelectedTokenKeydown', () => { beforeEach(() => { + initializeManager(); tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), ); @@ -344,27 +368,39 @@ describe('Filtered Search Manager', () => { spyOn(gl.FilteredSearchVisualTokens, 'removeSelectedToken').and.callThrough(); spyOn(gl.FilteredSearchManager.prototype, 'handleInputPlaceholder').and.callThrough(); spyOn(gl.FilteredSearchManager.prototype, 'toggleClearSearchButton').and.callThrough(); - manager.removeSelectedToken(); + initializeManager(); }); it('calls FilteredSearchVisualTokens.removeSelectedToken', () => { + manager.removeSelectedToken(); + expect(gl.FilteredSearchVisualTokens.removeSelectedToken).toHaveBeenCalled(); }); it('calls handleInputPlaceholder', () => { + manager.removeSelectedToken(); + expect(manager.handleInputPlaceholder).toHaveBeenCalled(); }); it('calls toggleClearSearchButton', () => { + manager.removeSelectedToken(); + expect(manager.toggleClearSearchButton).toHaveBeenCalled(); }); it('calls update dropdown offset', () => { + manager.removeSelectedToken(); + expect(manager.dropdownManager.updateDropdownOffset).toHaveBeenCalled(); }); }); describe('toggleInputContainerFocus', () => { + beforeEach(() => { + initializeManager(); + }); + it('toggles on focus', () => { input.focus(); expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(true); diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 276e01fc82f..9df92318864 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -3,17 +3,9 @@ import '~/render_math'; import '~/render_gfm'; import issuableApp from '~/issue_show/components/app.vue'; import eventHub from '~/issue_show/event_hub'; +import Poll from '~/lib/utils/poll'; import issueShowData from '../mock_data'; -const issueShowInterceptor = data => (request, next) => { - next(request.respondWith(JSON.stringify(data), { - status: 200, - headers: { - 'POLL-INTERVAL': 1, - }, - })); -}; - function formatText(text) { return text.trim().replace(/\s\s+/g, ' '); } @@ -24,10 +16,10 @@ describe('Issuable output', () => { let vm; beforeEach(() => { - const IssuableDescriptionComponent = Vue.extend(issuableApp); - Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); - spyOn(eventHub, '$emit'); + spyOn(Poll.prototype, 'makeRequest'); + + const IssuableDescriptionComponent = Vue.extend(issuableApp); vm = new IssuableDescriptionComponent({ propsData: { @@ -54,9 +46,18 @@ describe('Issuable output', () => { }); it('should render a title/description/edited and update title/description/edited on update', (done) => { - setTimeout(() => { - const editedText = vm.$el.querySelector('.edited-text'); + vm.poll.options.successCallback({ + json() { + return issueShowData.initialRequest; + }, + }); + let editedText; + Vue.nextTick() + .then(() => { + editedText = vm.$el.querySelector('.edited-text'); + }) + .then(() => { expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>'); @@ -64,22 +65,27 @@ describe('Issuable output', () => { expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/); expect(editedText.querySelector('.author_link').href).toMatch(/\/some_user$/); expect(editedText.querySelector('time')).toBeTruthy(); - - Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); - - setTimeout(() => { - expect(document.querySelector('title').innerText).toContain('2 (#1)'); - expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); - expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); - expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42'); - expect(vm.$el.querySelector('.edited-text')).toBeTruthy(); - expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(/Edited[\s\S]+?by Other User/); - expect(editedText.querySelector('.author_link').href).toMatch(/\/other_user$/); - expect(editedText.querySelector('time')).toBeTruthy(); - - done(); + }) + .then(() => { + vm.poll.options.successCallback({ + json() { + return issueShowData.secondRequest; + }, }); - }); + }) + .then(Vue.nextTick) + .then(() => { + expect(document.querySelector('title').innerText).toContain('2 (#1)'); + expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); + expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); + expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42'); + expect(vm.$el.querySelector('.edited-text')).toBeTruthy(); + expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(/Edited[\s\S]+?by Other User/); + expect(editedText.querySelector('.author_link').href).toMatch(/\/other_user$/); + expect(editedText.querySelector('time')).toBeTruthy(); + }) + .then(done) + .catch(done.fail); }); it('shows actions if permissions are correct', (done) => { @@ -344,21 +350,23 @@ describe('Issuable output', () => { describe('open form', () => { it('shows locked warning if form is open & data is different', (done) => { - Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + vm.poll.options.successCallback({ + json() { + return issueShowData.initialRequest; + }, + }); Vue.nextTick() - .then(() => new Promise((resolve) => { - setTimeout(resolve); - })) .then(() => { vm.openForm(); - Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); - - return new Promise((resolve) => { - setTimeout(resolve); + vm.poll.options.successCallback({ + json() { + return issueShowData.secondRequest; + }, }); }) + .then(Vue.nextTick) .then(() => { expect( vm.formState.lockedWarningVisible, @@ -367,9 +375,8 @@ describe('Issuable output', () => { expect( vm.$el.querySelector('.alert'), ).not.toBeNull(); - - done(); }) + .then(done) .catch(done.fail); }); }); diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index f0d51bd0902..075b72f35b2 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -22,6 +22,19 @@ window.gl = window.gl || {}; window.gl.TEST_HOST = 'http://test.host'; window.gon = window.gon || {}; +let hasUnhandledPromiseRejections = false; + +window.addEventListener('unhandledrejection', (event) => { + hasUnhandledPromiseRejections = true; + console.error('Unhandled promise rejection:'); + console.error(event.reason.stack || event.reason); +}); + +const checkUnhandledPromiseRejections = (done) => { + expect(hasUnhandledPromiseRejections).toBe(false); + done(); +}; + // HACK: Chrome 59 disconnects if there are too many synchronous tests in a row // because it appears to lock up the thread that communicates to Karma's socket // This async beforeEach gets called on every spec and releases the JS thread long @@ -63,6 +76,10 @@ testsContext.keys().forEach(function (path) { } }); +it('has no unhandled Promise rejections', (done) => { + setTimeout(checkUnhandledPromiseRejections(done), 1000); +}); + // if we're generating coverage reports, make sure to include all files so // that we can catch files with 0% coverage // see: https://github.com/deepsweet/istanbul-instrumenter-loader/issues/15 diff --git a/spec/views/projects/commit/show.html.haml_spec.rb b/spec/views/projects/commit/show.html.haml_spec.rb index 122075cc10e..92b4aa12d49 100644 --- a/spec/views/projects/commit/show.html.haml_spec.rb +++ b/spec/views/projects/commit/show.html.haml_spec.rb @@ -21,24 +21,26 @@ describe 'projects/commit/show.html.haml', :view do context 'inline diff view' do before do allow(view).to receive(:diff_view).and_return(:inline) + allow(view).to receive(:diff_view).and_return(:inline) render end - it 'keeps container-limited' do - expect(rendered).not_to have_selector('.limit-container-width') + it 'has limited width' do + expect(rendered).to have_selector('.limit-container-width') end end context 'parallel diff view' do before do allow(view).to receive(:diff_view).and_return(:parallel) + allow(view).to receive(:fluid_layout).and_return(true) render end it 'spans full width' do - expect(rendered).to have_selector('.limit-container-width') + expect(rendered).not_to have_selector('.limit-container-width') end end end |