diff options
author | Grzegorz Bizon <grzegorz@gitlab.com> | 2018-03-17 08:52:35 +0300 |
---|---|---|
committer | Grzegorz Bizon <grzegorz@gitlab.com> | 2018-03-17 08:52:35 +0300 |
commit | 83874edb77235d2a276f114f72942c43e8be3a44 (patch) | |
tree | 890ac40129ec2fadda1e2e3ad609cdc9a7605537 /app | |
parent | 53e2987ba6a8b2fb79f5754ae13924f2939d81fd (diff) | |
parent | ea5221aeb358ef6c349cfa09b9c6993bd7bd027d (diff) |
Merge branch 'master' into 'update-kubeclient'
Conflicts:
Gemfile.lock
Diffstat (limited to 'app')
80 files changed, 2825 insertions, 2188 deletions
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 43a5325cf71..8259133c95b 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -132,9 +132,8 @@ class GfmAutoComplete { callbacks: { ...this.getDefaultCallbacks(), matcher(flag, subtext) { - const relevantText = subtext.trim().split(/\s/).pop(); const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi'); - const match = regexp.exec(relevantText); + const match = regexp.exec(subtext); return match && match.length ? match[1] : null; }, diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index f01aef45500..e77318fef46 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -73,6 +73,7 @@ export default class MergeRequestTabs { constructor({ action, setUrl, stubLocation } = {}) { const mergeRequestTabs = document.querySelector('.js-tabs-affix'); const navbar = document.querySelector('.navbar-gitlab'); + const peek = document.getElementById('peek'); const paddingTop = 16; this.diffsLoaded = false; @@ -86,6 +87,10 @@ export default class MergeRequestTabs { this.showTab = this.showTab.bind(this); this.stickyTop = navbar ? navbar.offsetHeight - paddingTop : 0; + if (peek) { + this.stickyTop += peek.offsetHeight; + } + if (mergeRequestTabs) { this.stickyTop += mergeRequestTabs.offsetHeight; } diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index 9e67a6f2146..42615d2bb8e 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -209,6 +209,7 @@ const xAxis = d3.axisBottom() .scale(axisXScale) + .ticks(this.graphWidth / 120) .tickFormat(timeScaleFormat); const yAxis = d3.axisLeft() diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index 972fdb2b791..096c4ef5f31 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -4,13 +4,15 @@ import discussionCounter from '../notes/components/discussion_counter.vue'; import store from '../notes/stores'; export default function initMrNotes() { - new Vue({ // eslint-disable-line + // eslint-disable-next-line no-new + new Vue({ el: '#js-vue-mr-discussions', components: { notesApp, }, data() { - const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; + const notesDataset = document.getElementById('js-vue-mr-discussions') + .dataset; return { noteableData: JSON.parse(notesDataset.noteableData), currentUserData: JSON.parse(notesDataset.currentUserData), @@ -28,7 +30,8 @@ export default function initMrNotes() { }, }); - new Vue({ // eslint-disable-line + // eslint-disable-next-line no-new + new Vue({ el: '#js-vue-discussion-counter', components: { discussionCounter, diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index c640003d958..659ae575219 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -16,6 +16,10 @@ import Autosize from 'autosize'; import 'vendor/jquery.caret'; // required by jquery.atwho import 'vendor/jquery.atwho'; import AjaxCache from '~/lib/utils/ajax_cache'; +import Vue from 'vue'; +import syntaxHighlight from '~/syntax_highlight'; +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import { getLocationHash } from './lib/utils/url_utility'; import Flash from './flash'; @@ -24,7 +28,13 @@ import GLForm from './gl_form'; import loadAwardsHandler from './awards_handler'; import Autosave from './autosave'; import TaskList from './task_list'; -import { isInViewport, getPagePath, scrollToElement, isMetaKey, hasVueMRDiscussionsCookie } from './lib/utils/common_utils'; +import { + isInViewport, + getPagePath, + scrollToElement, + isMetaKey, + hasVueMRDiscussionsCookie, +} from './lib/utils/common_utils'; import imageDiffHelper from './image_diff/helpers/index'; import { localTimeAgo } from './lib/utils/datetime_utility'; @@ -38,9 +48,21 @@ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; export default class Notes { - static initialize(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { + static initialize( + notes_url, + note_ids, + last_fetched_at, + view, + enableGFM = true, + ) { if (!this.instance) { - this.instance = new Notes(notes_url, note_ids, last_fetched_at, view, enableGFM); + this.instance = new Notes( + notes_url, + note_ids, + last_fetched_at, + view, + enableGFM, + ); } } @@ -78,7 +100,8 @@ export default class Notes { 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.notesCountBadge || + (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge')); this.basePollingInterval = 15000; this.maxPollingSteps = 4; @@ -89,15 +112,24 @@ export default class Notes { this.taskList = new TaskList({ dataType: 'note', fieldName: 'note', - selector: '.notes' + selector: '.notes', }); this.collapseLongCommitList(); this.setViewType(view); // We are in the Merge Requests page so we need another edit form for Changes tab if (getPagePath(1) === 'merge_requests') { - $('.note-edit-form').clone() - .addClass('mr-note-edit-form').insertAfter('.note-edit-form'); + $('.note-edit-form') + .clone() + .addClass('mr-note-edit-form') + .insertAfter('.note-edit-form'); + } + + const hash = getLocationHash(); + const $anchor = hash && document.getElementById(hash); + + if ($anchor) { + this.loadLazyDiff({ currentTarget: $anchor }); } } @@ -106,7 +138,9 @@ export default class Notes { } addBinding() { - this.$wrapperEl = hasVueMRDiscussionsCookie() ? $(document).find('.diffs') : $(document); + this.$wrapperEl = hasVueMRDiscussionsCookie() + ? $(document).find('.diffs') + : $(document); // Edit note link this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this)); @@ -114,36 +148,78 @@ export default class Notes { // Reopen and close actions for Issue/MR combined with note form submit this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment); this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment); - this.$wrapperEl.on('keyup input', '.js-note-text', this.updateTargetButtons); + this.$wrapperEl.on( + 'keyup input', + '.js-note-text', + this.updateTargetButtons, + ); // resolve a discussion this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) this.$wrapperEl.on('click', '.js-note-delete', this.removeNote); // delete note attachment - this.$wrapperEl.on('click', '.js-note-attachment-delete', this.removeAttachment); + this.$wrapperEl.on( + 'click', + '.js-note-attachment-delete', + this.removeAttachment, + ); // reset main target form when clicking discard this.$wrapperEl.on('click', '.js-note-discard', this.resetMainTargetForm); // update the file name when an attachment is selected - this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment); + this.$wrapperEl.on( + 'change', + '.js-note-attachment-input', + this.updateFormAttachment, + ); // reply to diff/discussion notes - this.$wrapperEl.on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); + this.$wrapperEl.on( + 'click', + '.js-discussion-reply-button', + this.onReplyToDiscussionNote, + ); // add diff note this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote); // add diff note for images - this.$wrapperEl.on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote); + this.$wrapperEl.on( + 'click', + '.js-add-image-diff-note-button', + this.onAddImageDiffNote, + ); // hide diff note form - this.$wrapperEl.on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); + this.$wrapperEl.on( + 'click', + '.js-close-discussion-note-form', + this.cancelDiscussionForm, + ); // toggle commit list - this.$wrapperEl.on('click', '.system-note-commit-list-toggler', this.toggleCommitList); + this.$wrapperEl.on( + 'click', + '.system-note-commit-list-toggler', + this.toggleCommitList, + ); + + this.$wrapperEl.on('click', '.js-toggle-lazy-diff', this.loadLazyDiff); // fetch notes when tab becomes visible this.$wrapperEl.on('visibilitychange', this.visibilityChange); // when issue status changes, we need to refresh data this.$wrapperEl.on('issuable:change', this.refresh); // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.addNote); - this.$wrapperEl.on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); - this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); - this.$wrapperEl.on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton); + this.$wrapperEl.on( + 'ajax:success', + '.js-discussion-note-form', + this.addDiscussionNote, + ); + this.$wrapperEl.on( + 'ajax:success', + '.js-main-target-form', + this.resetMainTargetForm, + ); + this.$wrapperEl.on( + 'ajax:complete', + '.js-main-target-form', + this.reenableTargetFormSubmitButton, + ); // when a key is clicked on the notes this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText); // When the URL fragment/hash has changed, `#note_xxx` @@ -173,6 +249,7 @@ export default class Notes { this.$wrapperEl.off('keydown', '.js-note-text'); this.$wrapperEl.off('click', '.js-comment-resolve-button'); this.$wrapperEl.off('click', '.system-note-commit-list-toggler'); + this.$wrapperEl.off('click', '.js-toggle-lazy-diff'); this.$wrapperEl.off('ajax:success', '.js-main-target-form'); this.$wrapperEl.off('ajax:success', '.js-discussion-note-form'); this.$wrapperEl.off('ajax:complete', '.js-main-target-form'); @@ -181,10 +258,16 @@ export default class Notes { } static initCommentTypeToggle(form) { - const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle'); - const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu'); + 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 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'); @@ -201,7 +284,13 @@ export default class Notes { } keydownNoteText(e) { - var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText; + var $textarea, + discussionNoteForm, + editNote, + myLastNote, + myLastNoteEditBtn, + newText, + originalText; if (isMetaKey(e)) { return; } @@ -213,7 +302,12 @@ export default class Notes { if ($textarea.val() !== '') { return; } - myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, .notes_holder, #notes')); + 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]); @@ -224,7 +318,9 @@ export default class Notes { 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?')) { + if ( + !confirm('Are you sure you want to cancel creating this comment?') + ) { return; } } @@ -236,7 +332,9 @@ export default class Notes { originalText = $textarea.closest('form').data('originalNote'); newText = $textarea.val(); if (originalText !== newText) { - if (!confirm('Are you sure you want to cancel editing this comment?')) { + if ( + !confirm('Are you sure you want to cancel editing this comment?') + ) { return; } } @@ -249,11 +347,14 @@ export default class Notes { 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, + )); } refresh() { @@ -269,20 +370,23 @@ export default class Notes { this.refreshing = true; - axios.get(`${this.notes_url}?html=true`, { - headers: { - 'X-Last-Fetched-At': this.last_fetched_at, - }, - }).then(({ data }) => { - const notes = data.notes; - this.last_fetched_at = data.last_fetched_at; - this.setPollingInterval(data.notes.length); - $.each(notes, (i, note) => this.renderNote(note)); - - this.refreshing = false; - }).catch(() => { - this.refreshing = false; - }); + axios + .get(`${this.notes_url}?html=true`, { + headers: { + 'X-Last-Fetched-At': this.last_fetched_at, + }, + }) + .then(({ data }) => { + const notes = data.notes; + this.last_fetched_at = data.last_fetched_at; + this.setPollingInterval(data.notes.length); + $.each(notes, (i, note) => this.renderNote(note)); + + this.refreshing = false; + }) + .catch(() => { + this.refreshing = false; + }); } /** @@ -298,7 +402,8 @@ export default class Notes { if (shouldReset == null) { shouldReset = true; } - nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); + nthInterval = + this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); if (shouldReset) { this.pollingInterval = this.basePollingInterval; } else if (this.pollingInterval < nthInterval) { @@ -317,12 +422,17 @@ export default class Notes { if ('emoji_award' in noteEntity.commands_changes) { votesBlock = $('.js-awards-block').eq(0); - loadAwardsHandler().then((awardsHandler) => { - awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award); - awardsHandler.scrollToAwards(); - }).catch(() => { - // ignore - }); + loadAwardsHandler() + .then(awardsHandler => { + awardsHandler.addAwardToEmojiBar( + votesBlock, + noteEntity.commands_changes.emoji_award, + ); + awardsHandler.scrollToAwards(); + }) + .catch(() => { + // ignore + }); } } } @@ -367,11 +477,17 @@ export default class Notes { if (!noteEntity.valid) { if (noteEntity.errors && noteEntity.errors.commands_only) { - if (noteEntity.commands_changes && - Object.keys(noteEntity.commands_changes).length > 0) { + if ( + noteEntity.commands_changes && + Object.keys(noteEntity.commands_changes).length > 0 + ) { $notesList.find('.system-note.being-posted').remove(); } - this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline.get(0)); + this.addFlash( + noteEntity.errors.commands_only, + 'notice', + this.parentTimeline.get(0), + ); this.refresh(); } return; @@ -393,28 +509,30 @@ export default class Notes { this.setupNewNote($newNote); 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)) { + } else if (Notes.isUpdatedNote(noteEntity, $note)) { + // The server can send the same update multiple times so we need to make sure to only update once per actual update. const isEditing = $note.hasClass('is-editing'); const initialContent = normalizeNewlines( - $note.find('.original-note-content').text().trim() + $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; + const isTextareaUntouched = + currentContent === initialContent || + currentContent === sanitizedNoteNote; if (isEditing && isTextareaUntouched) { $textarea.val(noteEntity.note); this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; - } - else if (isEditing && !isTextareaUntouched) { + } else if (isEditing && !isTextareaUntouched) { this.putConflictEditWarningInPlace(noteEntity, $note); this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; - } - else { + } else { const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note); this.setupNewNote($updatedNote); } @@ -438,17 +556,31 @@ export default class Notes { } this.note_ids.push(noteEntity.id); - form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`); - row = (form.length || !noteEntity.discussion_line_code) ? form.closest('tr') : $(`#${noteEntity.discussion_line_code}`); + form = + $form || + $( + `.js-discussion-note-form[data-discussion-id="${ + noteEntity.discussion_id + }"]`, + ); + row = + form.length || !noteEntity.discussion_line_code + ? form.closest('tr') + : $(`#${noteEntity.discussion_line_code}`); if (noteEntity.on_image) { row = form; } lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; - diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line'); + 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}"]`); + discussionContainer = $( + `.notes[data-discussion-id="${noteEntity.discussion_id}"]`, + ); if (!discussionContainer.length) { discussionContainer = form.closest('.discussion').find('.notes'); } @@ -456,25 +588,42 @@ export default class Notes { if (noteEntity.diff_discussion_html) { var $discussion = $(noteEntity.diff_discussion_html).renderGFM(); - if (!this.isParallelView() || row.hasClass('js-temp-notes-holder') || noteEntity.on_image) { + if ( + !this.isParallelView() || + row.hasClass('js-temp-notes-holder') || + noteEntity.on_image + ) { // 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()); + 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) { + if ( + (page && page.indexOf('projects:merge_request') !== -1) || + !noteEntity.diff_discussion_html + ) { if (!hasVueMRDiscussionsCookie()) { - Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); + Notes.animateAppendNote( + noteEntity.discussion_html, + $('.main-notes-list'), + ); } } } else { @@ -482,7 +631,10 @@ export default class Notes { Notes.animateAppendNote(noteEntity.html, discussionContainer); } - if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { + if ( + typeof gl.diffNotesCompileComponents !== 'undefined' && + noteEntity.discussion_resolvable + ) { gl.diffNotesCompileComponents(); this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); @@ -494,7 +646,8 @@ export default class Notes { } getLineHolder(changesDiscussionContainer) { - return $(changesDiscussionContainer).closest('.notes_holder') + return $(changesDiscussionContainer) + .closest('.notes_holder') .prevAll('.line_holder') .first() .get(0); @@ -527,8 +680,14 @@ export default class Notes { 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(); + 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); @@ -564,7 +723,10 @@ export default class Notes { form.find('#note_type').val(''); form.find('#note_project_id').remove(); form.find('#in_reply_to_discussion_id').remove(); - form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove(); + form + .find('.js-comment-resolve-button') + .closest('comment-and-resolve-btn') + .remove(); this.parentTimeline = form.parents('.timeline'); if (form.length) { @@ -618,11 +780,17 @@ export default class Notes { } 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.get(0)); + return this.addFlash( + 'Your comment could not be submitted! Please check your network connection and try again.', + 'alert', + formParentTimeline.get(0), + ); } updateNoteError($parentTimeline) { - new Flash('Your comment could not be updated! Please check your network connection and try again.'); + new Flash( + 'Your comment could not be updated! Please check your network connection and try again.', + ); } /** @@ -671,14 +839,16 @@ export default class Notes { } checkContentToAllowEditing($el) { - var initialContent = $el.find('.original-note-content').text().trim(); + var initialContent = $el + .find('.original-note-content') + .text() + .trim(); var currentContent = $el.find('.js-note-text').val(); var isAllowed = true; if (currentContent === initialContent) { this.removeNoteEditForm($el); - } - else { + } else { var $buttons = $el.find('.note-form-actions'); var isWidgetVisible = isInViewport($el.get(0)); @@ -740,8 +910,7 @@ export default class Notes { this.setupNewNote($newNote); // Now that we have taken care of the update, clear it out delete this.updatedNotesTrackingMap[noteId]; - } - else { + } else { $note.find('.js-finish-edit-warning').hide(); this.removeNoteEditForm($note); } @@ -774,7 +943,9 @@ export default class Notes { 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('originalNote')); + return form + .find('.js-note-text') + .val(form.find('form.edit-note').data('originalNote')); } /** @@ -788,58 +959,67 @@ export default class Notes { $note = $(e.currentTarget).closest('.note'); noteElId = $note.attr('id'); noteId = $note.attr('data-note-id'); - lineHolder = $(e.currentTarget).closest('.notes[data-discussion-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'); - const discussionId = $('.notes', $notes).data('discussionId'); - - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - if (gl.diffNoteApps[noteElId]) { - gl.diffNoteApps[noteElId].$destroy(); + $(`.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'); + const discussionId = $('.notes', $notes).data('discussionId'); + + if (typeof gl.diffNotesCompileComponents !== 'undefined') { + if (gl.diffNoteApps[noteElId]) { + gl.diffNoteApps[noteElId].$destroy(); + } } - } - - $note.remove(); - - // 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(); - - $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); - - // The notes tr can contain multiple lists of notes, like on the parallel diff - // notesTr does not exist for image diffs - if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { - const $diffFile = $notes.closest('.diff-file'); - if ($diffFile.length > 0) { - const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { - detail: { - // badgeNumber's start with 1 and index starts with 0 - badgeNumber: $notes.index() + 1, - }, - }); - $diffFile[0].dispatchEvent(removeBadgeEvent); + $note.remove(); + + // 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(); + + $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); + + // The notes tr can contain multiple lists of notes, like on the parallel diff + // notesTr does not exist for image diffs + if ( + notesTr.find('.discussion-notes').length > 1 || + notesTr.length === 0 + ) { + const $diffFile = $notes.closest('.diff-file'); + if ($diffFile.length > 0) { + const removeBadgeEvent = new CustomEvent( + 'removeBadge.imageDiff', + { + detail: { + // badgeNumber's start with 1 and index starts with 0 + badgeNumber: $notes.index() + 1, + }, + }, + ); + + $diffFile[0].dispatchEvent(removeBadgeEvent); + } + + $notes.remove(); + } else if (notesTr.length > 0) { + notesTr.remove(); } - - $notes.remove(); - } else if (notesTr.length > 0) { - notesTr.remove(); } - } - }; - })(this)); + }; + })(this), + ); Notes.refreshVueNotes(); Notes.checkMergeRequestStatus(); @@ -921,7 +1101,12 @@ export default class Notes { // 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('cancelText')); + 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('cancelText')); form.find('.js-note-target-close').remove(); form.find('.js-note-new-discussion').remove(); this.setupNoteForm(form); @@ -957,7 +1142,7 @@ export default class Notes { this.toggleDiffNote({ target: $link, lineType: link.dataset.lineType, - showReplyInput + showReplyInput, }); } @@ -973,7 +1158,9 @@ export default class Notes { // Setup comment form let newForm; - const $noteContainer = $link.closest('.diff-viewer').find('.note-container'); + const $noteContainer = $link + .closest('.diff-viewer') + .find('.note-container'); const $form = $noteContainer.find('> .discussion-form'); if ($form.length === 0) { @@ -986,13 +1173,17 @@ export default class Notes { this.setupDiscussionNoteForm($link, newForm); } - toggleDiffNote({ - target, - lineType, - forceShow, - showReplyInput = false, - }) { - var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar; + 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(); @@ -1004,11 +1195,13 @@ export default class Notes { 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>'; + 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>'; + 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); @@ -1036,7 +1229,9 @@ export default class Notes { notesContent = targetRow.find(notesContentSelector); addForm = true; } else { - const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible'); + const isCurrentlyShown = targetRow + .find('.content:not(:empty)') + .is(':visible'); const isForced = forceShow === true || forceShow === false; const showNow = forceShow === true || (!isCurrentlyShown && !isForced); @@ -1063,11 +1258,12 @@ export default class Notes { row = form.closest('tr'); glForm = form.data('glForm'); 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(); + .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(); @@ -1108,7 +1304,9 @@ export default class Notes { var filename, form; form = $(this).closest('form'); // get only the basename - filename = $(this).val().replace(/^.*[\\\/]/, ''); + filename = $(this) + .val() + .replace(/^.*[\\\/]/, ''); return form.find('.js-attachment-filename').text(filename); } @@ -1180,12 +1378,16 @@ export default class Notes { this.glForm = new GLForm($editForm.find('form'), this.enableGFM); - $editForm.find('form') + $editForm + .find('form') .attr('action', `${postUrl}?html=true`) .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-note-text') + .focus() + .val(originalContent); $editForm.find('.js-md-write-button').trigger('click'); $editForm.find('.referenced-users').hide(); } @@ -1194,7 +1396,9 @@ export default class Notes { 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"> + <a href="#note_${ + noteEntity.id + }" target="_blank" rel="noopener noreferrer"> updated comment </a> to ensure information is not lost @@ -1204,14 +1408,79 @@ export default class Notes { } updateNotesCount(updateCount) { - return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); + return this.notesCountBadge.text( + parseInt(this.notesCountBadge.text(), 10) + updateCount, + ); + } + + static renderPlaceholderComponent($container) { + const el = $container.find('.js-code-placeholder').get(0); + new Vue({ + // eslint-disable-line no-new + el, + components: { + SkeletonLoadingContainer, + }, + render(createElement) { + return createElement('skeleton-loading-container'); + }, + }); + } + + static renderDiffContent($container, data) { + const { discussion_html } = data; + const lines = $(discussion_html).find('.line_holder'); + lines.addClass('fade-in'); + $container.find('tbody').prepend(lines); + const fileHolder = $container.find('.file-holder'); + $container.find('.line-holder-placeholder').remove(); + syntaxHighlight(fileHolder); + } + + static renderDiffError($container) { + $container.find('.line_content').html( + $(` + <div class="nothing-here-block"> + ${__( + 'Unable to load the diff.', + )} <a class="js-toggle-lazy-diff" href="javascript:void(0)">Try again</a>? + </div> + `), + ); + } + + loadLazyDiff(e) { + const $container = $(e.currentTarget).closest('.js-toggle-container'); + Notes.renderPlaceholderComponent($container); + + $container.find('.js-toggle-lazy-diff').removeClass('js-toggle-lazy-diff'); + + const tableEl = $container.find('tbody'); + if (tableEl.length === 0) return; + + const fileHolder = $container.find('.file-holder'); + const url = fileHolder.data('linesPath'); + + axios + .get(url) + .then(({ data }) => { + Notes.renderDiffContent($container, data); + }) + .catch(() => { + Notes.renderDiffError($container); + }); } toggleCommitList(e) { const $element = $(e.currentTarget); - const $closestSystemCommitList = $element.siblings('.system-note-commit-list'); + const $closestSystemCommitList = $element.siblings( + '.system-note-commit-list', + ); - $element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up'); + $element + .find('.fa') + .toggleClass('fa-angle-down') + .toggleClass('fa-angle-up'); $closestSystemCommitList.toggleClass('hide-shade'); } @@ -1221,11 +1490,17 @@ export default class Notes { * intrusive. */ collapseLongCommitList() { - const systemNotes = $('#notes-list').find('li.system-note').has('ul'); + 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(':', ''); + const headerMessage = $systemNote + .find('.note-text') + .find('p:first') + .text() + .replace(':', ''); $systemNote.find('.note-header .system-note-message').html(headerMessage); @@ -1233,7 +1508,9 @@ export default class Notes { $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'); + $systemNote + .find('.note-text') + .addClass('system-note-commit-list hide-shade'); } }); } @@ -1251,14 +1528,10 @@ export default class Notes { cleanForm($form) { // Remove JS classes that are not needed here - $form - .find('.js-comment-type-dropdown') - .removeClass('btn-group'); + $form.find('.js-comment-type-dropdown').removeClass('btn-group'); // Remove dropdown - $form - .find('.dropdown-menu') - .remove(); + $form.find('.dropdown-menu').remove(); return $form; } @@ -1277,7 +1550,11 @@ export default class Notes { // 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() + $note + .find('.original-note-content') + .first() + .text() + .trim(), ); return sanitizedNoteEntityText !== currentNoteText; } @@ -1367,7 +1644,14 @@ export default class Notes { * 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 }) { + 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"> @@ -1381,8 +1665,12 @@ export default class Notes { <div class="note-header"> <div class="note-header-info"> <a href="/${_.escape(currentUsername)}"> - <span class="hidden-xs">${_.escape(currentUsername)}</span> - <span class="note-headline-light">${_.escape(currentUsername)}</span> + <span class="hidden-xs">${_.escape( + currentUsername, + )}</span> + <span class="note-headline-light">${_.escape( + currentUsername, + )}</span> </a> </div> </div> @@ -1393,11 +1681,13 @@ export default class Notes { </div> </div> </div> - </li>` + </li>`, ); $tempNote.find('.hidden-xs').text(_.escape(currentUserFullname)); - $tempNote.find('.note-headline-light').text(`@${_.escape(currentUsername)}`); + $tempNote + .find('.note-headline-light') + .text(`@${_.escape(currentUsername)}`); return $tempNote; } @@ -1413,7 +1703,7 @@ export default class Notes { <i>${formContent}</i> </div> </div> - </li>` + </li>`, ); return $tempNote; @@ -1445,11 +1735,22 @@ export default class Notes { 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 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, formContentOriginal } = this.getFormData($form); + const isDiscussionResolve = $submitBtn.hasClass( + 'js-comment-resolve-button', + ); + const { + formData, + formContent, + formAction, + formContentOriginal, + } = this.getFormData($form); let noteUniqueId; let systemNoteUniqueId; let hasQuickActions = false; @@ -1479,23 +1780,30 @@ export default class Notes { // 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, - })); + $notesContainer.append( + this.createPlaceholderNote({ + formContent: tempFormContent, + uniqueId: noteUniqueId, + isDiscussionNote, + currentUsername: gon.current_username, + currentUserFullname: gon.current_user_fullname, + currentUserAvatar: gon.current_user_avatar_url, + }), + ); } // 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, - })); + $notesContainer.append( + this.createPlaceholderSystemNote({ + formContent: this.getQuickActionDescription( + formContent, + AjaxCache.get(gl.GfmAutoComplete.dataSources.commands), + ), + uniqueId: systemNoteUniqueId, + }), + ); } // Clear the form textarea @@ -1509,8 +1817,9 @@ export default class Notes { /* eslint-disable promise/catch-or-return */ // Make request to submit comment on server - axios.post(`${formAction}?html=true`, formData) - .then((res) => { + axios + .post(`${formAction}?html=true`, formData) + .then(res => { const note = res.data; // Submission successful! remove placeholder @@ -1527,7 +1836,9 @@ export default class Notes { // Reset cached commands list when command is applied if (hasQuickActions) { - $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho'); + $form + .find('textarea.js-note-text') + .trigger('clear-commands-cache.atwho'); } // Clear previous form errors @@ -1572,11 +1883,14 @@ export default class Notes { // append flash-container to the Notes list if ($notesContainer.length) { - $notesContainer.append('<div class="flash-container" style="display: none;"></div>'); + $notesContainer.append( + '<div class="flash-container" style="display: none;"></div>', + ); } Notes.refreshVueNotes(); - } else if (isMainForm) { // Check if this was main thread comment + } 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); @@ -1587,7 +1901,8 @@ export default class Notes { } $form.trigger('ajax:success', [note]); - }).catch(() => { + }) + .catch(() => { // Submission failed, remove placeholder note and show Flash error message $notesContainer.find(`#${noteUniqueId}`).remove(); @@ -1607,7 +1922,9 @@ export default class Notes { // Show form again on UI on failure if (isDiscussionForm && $notesContainer.length) { - const replyButton = $notesContainer.parent().find('.js-discussion-reply-button'); + const replyButton = $notesContainer + .parent() + .find('.js-discussion-reply-button'); this.replyToDiscussionNote(replyButton[0]); $form = $notesContainer.parent().find('form'); } @@ -1652,12 +1969,19 @@ export default class Notes { // Show updated comment content temporarily $noteBodyText.html(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>'); + $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 - axios.post(`${formAction}?html=true`, formData) + axios + .post(`${formAction}?html=true`, formData) .then(({ data }) => { // Submission successful! render final note element this.updateNote(data, $editingNote); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 1785be01a0d..90dcafd75b7 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -1,298 +1,311 @@ <script> - import $ from 'jquery'; - import { mapActions, mapGetters } from 'vuex'; - import _ from 'underscore'; - import Autosize from 'autosize'; - import { __, sprintf } from '~/locale'; - import Flash from '../../flash'; - import Autosave from '../../autosave'; - import TaskList from '../../task_list'; - import { capitalizeFirstCharacter, convertToCamelCase } from '../../lib/utils/text_utility'; - import * as constants from '../constants'; - import eventHub from '../event_hub'; - import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; - import markdownField from '../../vue_shared/components/markdown/field.vue'; - import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; - import loadingButton from '../../vue_shared/components/loading_button.vue'; - import noteSignedOutWidget from './note_signed_out_widget.vue'; - import discussionLockedWidget from './discussion_locked_widget.vue'; - import issuableStateMixin from '../mixins/issuable_state'; +import $ from 'jquery'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import _ from 'underscore'; +import Autosize from 'autosize'; +import { __, sprintf } from '~/locale'; +import Flash from '../../flash'; +import Autosave from '../../autosave'; +import TaskList from '../../task_list'; +import { + capitalizeFirstCharacter, + convertToCamelCase, +} from '../../lib/utils/text_utility'; +import * as constants from '../constants'; +import eventHub from '../event_hub'; +import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; +import markdownField from '../../vue_shared/components/markdown/field.vue'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import loadingButton from '../../vue_shared/components/loading_button.vue'; +import noteSignedOutWidget from './note_signed_out_widget.vue'; +import discussionLockedWidget from './discussion_locked_widget.vue'; +import issuableStateMixin from '../mixins/issuable_state'; - export default { - name: 'CommentForm', - components: { - issueWarning, - noteSignedOutWidget, - discussionLockedWidget, - markdownField, - userAvatarLink, - loadingButton, +export default { + name: 'CommentForm', + components: { + issueWarning, + noteSignedOutWidget, + discussionLockedWidget, + markdownField, + userAvatarLink, + loadingButton, + }, + mixins: [issuableStateMixin], + props: { + noteableType: { + type: String, + required: true, }, - mixins: [ - issuableStateMixin, - ], - props: { - noteableType: { - type: String, - required: true, - }, + }, + data() { + return { + note: '', + noteType: constants.COMMENT, + isSubmitting: false, + isSubmitButtonDisabled: true, + }; + }, + computed: { + ...mapGetters([ + 'getCurrentUserLastNote', + 'getUserData', + 'getNoteableData', + 'getNotesData', + 'openState', + ]), + ...mapState(['isToggleStateButtonLoading']), + noteableDisplayName() { + return this.noteableType.replace(/_/g, ' '); }, - data() { - return { - note: '', - noteType: constants.COMMENT, - isSubmitting: false, - isSubmitButtonDisabled: true, - }; + isLoggedIn() { + return this.getUserData.id; + }, + commentButtonTitle() { + return this.noteType === constants.COMMENT + ? 'Comment' + : 'Start discussion'; + }, + isOpen() { + return ( + this.openState === constants.OPENED || + this.openState === constants.REOPENED + ); }, - computed: { - ...mapGetters([ - 'getCurrentUserLastNote', - 'getUserData', - 'getNoteableData', - 'getNotesData', - 'openState', - ]), - noteableDisplayName() { - return this.noteableType.replace(/_/g, ' '); - }, - isLoggedIn() { - return this.getUserData.id; - }, - commentButtonTitle() { - return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; - }, - isOpen() { - return this.openState === constants.OPENED || this.openState === constants.REOPENED; - }, - canCreateNote() { - return this.getNoteableData.current_user.can_create_note; - }, - issueActionButtonTitle() { - const openOrClose = this.isOpen ? 'close' : 'reopen'; + canCreateNote() { + return this.getNoteableData.current_user.can_create_note; + }, + issueActionButtonTitle() { + const openOrClose = this.isOpen ? 'close' : 'reopen'; - if (this.note.length) { - return sprintf( - __('%{actionText} & %{openOrClose} %{noteable}'), - { - actionText: this.commentButtonTitle, - openOrClose, - noteable: this.noteableDisplayName, - }, - ); - } + if (this.note.length) { + return sprintf(__('%{actionText} & %{openOrClose} %{noteable}'), { + actionText: this.commentButtonTitle, + openOrClose, + noteable: this.noteableDisplayName, + }); + } - return sprintf( - __('%{openOrClose} %{noteable}'), - { - openOrClose: capitalizeFirstCharacter(openOrClose), - noteable: this.noteableDisplayName, - }, - ); - }, - actionButtonClassNames() { - return { - 'btn-reopen': !this.isOpen, - 'btn-close': this.isOpen, - 'js-note-target-close': this.isOpen, - 'js-note-target-reopen': !this.isOpen, - }; - }, - markdownDocsPath() { - return this.getNotesData.markdownDocsPath; - }, - quickActionsDocsPath() { - return this.getNotesData.quickActionsDocsPath; - }, - markdownPreviewPath() { - return this.getNoteableData.preview_note_path; - }, - author() { - return this.getUserData; - }, - canUpdateIssue() { - return this.getNoteableData.current_user.can_update; - }, - endpoint() { - return this.getNoteableData.create_note_path; - }, + return sprintf(__('%{openOrClose} %{noteable}'), { + openOrClose: capitalizeFirstCharacter(openOrClose), + noteable: this.noteableDisplayName, + }); }, - watch: { - note(newNote) { - this.setIsSubmitButtonDisabled(newNote, this.isSubmitting); - }, - isSubmitting(newValue) { - this.setIsSubmitButtonDisabled(this.note, newValue); - }, + actionButtonClassNames() { + return { + 'btn-reopen': !this.isOpen, + 'btn-close': this.isOpen, + 'js-note-target-close': this.isOpen, + 'js-note-target-reopen': !this.isOpen, + }; }, - mounted() { - // jQuery is needed here because it is a custom event being dispatched with jQuery. - $(document).on('issuable:change', (e, isClosed) => { - this.toggleIssueLocalState(isClosed ? constants.CLOSED : constants.REOPENED); - }); + markdownDocsPath() { + return this.getNotesData.markdownDocsPath; + }, + quickActionsDocsPath() { + return this.getNotesData.quickActionsDocsPath; + }, + markdownPreviewPath() { + return this.getNoteableData.preview_note_path; + }, + author() { + return this.getUserData; + }, + canUpdateIssue() { + return this.getNoteableData.current_user.can_update; + }, + endpoint() { + return this.getNoteableData.create_note_path; + }, + }, + watch: { + note(newNote) { + this.setIsSubmitButtonDisabled(newNote, this.isSubmitting); + }, + isSubmitting(newValue) { + this.setIsSubmitButtonDisabled(this.note, newValue); + }, + }, + mounted() { + // jQuery is needed here because it is a custom event being dispatched with jQuery. + $(document).on('issuable:change', (e, isClosed) => { + this.toggleIssueLocalState( + isClosed ? constants.CLOSED : constants.REOPENED, + ); + }); - this.initAutoSave(); - this.initTaskList(); + this.initAutoSave(); + this.initTaskList(); + }, + methods: { + ...mapActions([ + 'saveNote', + 'stopPolling', + 'restartPolling', + 'removePlaceholderNotes', + 'closeIssue', + 'reopenIssue', + 'toggleIssueLocalState', + 'toggleStateButtonLoading', + ]), + setIsSubmitButtonDisabled(note, isSubmitting) { + if (!_.isEmpty(note) && !isSubmitting) { + this.isSubmitButtonDisabled = false; + } else { + this.isSubmitButtonDisabled = true; + } }, - methods: { - ...mapActions([ - 'saveNote', - 'stopPolling', - 'restartPolling', - 'removePlaceholderNotes', - 'closeIssue', - 'reopenIssue', - 'toggleIssueLocalState', - ]), - setIsSubmitButtonDisabled(note, isSubmitting) { - if (!_.isEmpty(note) && !isSubmitting) { - this.isSubmitButtonDisabled = false; - } else { - this.isSubmitButtonDisabled = true; - } - }, - handleSave(withIssueAction) { - this.isSubmitting = true; + handleSave(withIssueAction) { + this.isSubmitting = true; - if (this.note.length) { - const noteData = { - endpoint: this.endpoint, - flashContainer: this.$el, - data: { - note: { - noteable_type: this.noteableType, - noteable_id: this.getNoteableData.id, - note: this.note, - }, + if (this.note.length) { + const noteData = { + endpoint: this.endpoint, + flashContainer: this.$el, + data: { + note: { + noteable_type: this.noteableType, + noteable_id: this.getNoteableData.id, + note: this.note, }, - }; + }, + }; - if (this.noteType === constants.DISCUSSION) { - noteData.data.note.type = constants.DISCUSSION_NOTE; - } - this.note = ''; // Empty textarea while being requested. Repopulate in catch - this.resizeTextarea(); - this.stopPolling(); + if (this.noteType === constants.DISCUSSION) { + noteData.data.note.type = constants.DISCUSSION_NOTE; + } - this.saveNote(noteData) - .then((res) => { - this.isSubmitting = false; - this.restartPolling(); + this.note = ''; // Empty textarea while being requested. Repopulate in catch + this.resizeTextarea(); + this.stopPolling(); - if (res.errors) { - if (res.errors.commands_only) { - this.discard(); - } else { - Flash( - 'Something went wrong while adding your comment. Please try again.', - 'alert', - this.$refs.commentForm, - ); - } - } else { + this.saveNote(noteData) + .then(res => { + this.enableButton(); + this.restartPolling(); + + if (res.errors) { + if (res.errors.commands_only) { this.discard(); + } else { + Flash( + 'Something went wrong while adding your comment. Please try again.', + 'alert', + this.$refs.commentForm, + ); } + } else { + this.discard(); + } - if (withIssueAction) { - this.toggleIssueState(); - } - }) - .catch(() => { - this.isSubmitting = false; - this.discard(false); - const msg = - `Your comment could not be submitted! + if (withIssueAction) { + this.toggleIssueState(); + } + }) + .catch(() => { + this.enableButton(); + this.discard(false); + const msg = `Your comment could not be submitted! Please check your network connection and try again.`; - Flash(msg, 'alert', this.$el); - this.note = noteData.data.note.note; // Restore textarea content. - this.removePlaceholderNotes(); - }); - } else { - this.toggleIssueState(); - } - }, - enableButton() { - this.isSubmitting = false; - }, - toggleIssueState() { - if (this.isOpen) { - this.closeIssue() - .then(() => this.enableButton()) - .catch(() => { - this.enableButton(); - Flash( - sprintf( - __('Something went wrong while closing the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, + Flash(msg, 'alert', this.$el); + this.note = noteData.data.note.note; // Restore textarea content. + this.removePlaceholderNotes(); + }); + } else { + this.toggleIssueState(); + } + }, + enableButton() { + this.isSubmitting = false; + }, + toggleIssueState() { + if (this.isOpen) { + this.closeIssue() + .then(() => this.enableButton()) + .catch(() => { + this.enableButton(); + this.toggleStateButtonLoading(false); + Flash( + sprintf( + __( + 'Something went wrong while closing the %{issuable}. Please try again later', ), - ); - }); - } else { - this.reopenIssue() - .then(() => this.enableButton()) - .catch(() => { - this.enableButton(); - Flash( - sprintf( - __('Something went wrong while reopening the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, + { issuable: this.noteableDisplayName }, + ), + ); + }); + } else { + this.reopenIssue() + .then(() => this.enableButton()) + .catch(() => { + this.enableButton(); + this.toggleStateButtonLoading(false); + Flash( + sprintf( + __( + 'Something went wrong while reopening the %{issuable}. Please try again later', ), - ); - }); - } - }, - discard(shouldClear = true) { - // `blur` is needed to clear slash commands autocomplete cache if event fired. - // `focus` is needed to remain cursor in the textarea. - this.$refs.textarea.blur(); - this.$refs.textarea.focus(); + { issuable: this.noteableDisplayName }, + ), + ); + }); + } + }, + discard(shouldClear = true) { + // `blur` is needed to clear slash commands autocomplete cache if event fired. + // `focus` is needed to remain cursor in the textarea. + this.$refs.textarea.blur(); + this.$refs.textarea.focus(); - if (shouldClear) { - this.note = ''; - this.resizeTextarea(); - this.$refs.markdownField.previewMarkdown = false; - } + if (shouldClear) { + this.note = ''; + this.resizeTextarea(); + this.$refs.markdownField.previewMarkdown = false; + } - this.autosave.reset(); - }, - setNoteType(type) { - this.noteType = type; - }, - editCurrentUserLastNote() { - if (this.note === '') { - const lastNote = this.getCurrentUserLastNote; + this.autosave.reset(); + }, + setNoteType(type) { + this.noteType = type; + }, + editCurrentUserLastNote() { + if (this.note === '') { + const lastNote = this.getCurrentUserLastNote; - if (lastNote) { - eventHub.$emit('enterEditMode', { - noteId: lastNote.id, - }); - } + if (lastNote) { + eventHub.$emit('enterEditMode', { + noteId: lastNote.id, + }); } - }, - initAutoSave() { - if (this.isLoggedIn) { - const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType)); + } + }, + initAutoSave() { + if (this.isLoggedIn) { + const noteableType = capitalizeFirstCharacter( + convertToCamelCase(this.noteableType), + ); - this.autosave = new Autosave( - $(this.$refs.textarea), - ['Note', noteableType, this.getNoteableData.id], - ); - } - }, - initTaskList() { - return new TaskList({ - dataType: 'note', - fieldName: 'note', - selector: '.notes', - }); - }, - resizeTextarea() { - this.$nextTick(() => { - Autosize.update(this.$refs.textarea); - }); - }, + this.autosave = new Autosave($(this.$refs.textarea), [ + 'Note', + noteableType, + this.getNoteableData.id, + ]); + } + }, + initTaskList() { + return new TaskList({ + dataType: 'note', + fieldName: 'note', + selector: '.notes', + }); }, - }; + resizeTextarea() { + this.$nextTick(() => { + Autosize.update(this.$refs.textarea); + }); + }, + }, +}; </script> <template> @@ -419,13 +432,13 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" <loading-button v-if="canUpdateIssue" - :loading="isSubmitting" + :loading="isToggleStateButtonLoading" @click="handleSave(true)" :container-class="[ actionButtonClassNames, 'btn btn-comment btn-comment-and-close js-action-button' ]" - :disabled="isSubmitting" + :disabled="isToggleStateButtonLoading || isSubmitting" :label="issueActionButtonTitle" /> diff --git a/app/assets/javascripts/notes/components/diff_file_header.vue b/app/assets/javascripts/notes/components/diff_file_header.vue index 3bcde17f07c..94d9dc69964 100644 --- a/app/assets/javascripts/notes/components/diff_file_header.vue +++ b/app/assets/javascripts/notes/components/diff_file_header.vue @@ -1,24 +1,24 @@ <script> - import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; - import Icon from '~/vue_shared/components/icon.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; - export default { - components: { - ClipboardButton, - Icon, +export default { + components: { + ClipboardButton, + Icon, + }, + props: { + diffFile: { + type: Object, + required: true, }, - props: { - diffFile: { - type: Object, - required: true, - }, + }, + computed: { + titleTag() { + return this.diffFile.discussionPath ? 'a' : 'span'; }, - computed: { - titleTag() { - return this.diffFile.discussionPath ? 'a' : 'span'; - }, - }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index 1dba84fac18..ee01ec85bbb 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,56 +1,60 @@ <script> - import $ from 'jquery'; - import syntaxHighlight from '~/syntax_highlight'; - import imageDiffHelper from '~/image_diff/helpers/index'; - import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; - import DiffFileHeader from './diff_file_header.vue'; +import $ from 'jquery'; +import syntaxHighlight from '~/syntax_highlight'; +import imageDiffHelper from '~/image_diff/helpers/index'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import DiffFileHeader from './diff_file_header.vue'; - export default { - components: { - DiffFileHeader, +export default { + components: { + DiffFileHeader, + }, + props: { + discussion: { + type: Object, + required: true, }, - props: { - discussion: { - type: Object, - required: true, - }, + }, + computed: { + isImageDiff() { + return !this.diffFile.text; }, - computed: { - isImageDiff() { - return !this.diffFile.text; - }, - diffFileClass() { - const { text } = this.diffFile; - return text ? 'text-file' : 'js-image-file'; - }, - diffRows() { - return $(this.discussion.truncatedDiffLines); - }, - diffFile() { - return convertObjectPropsToCamelCase(this.discussion.diffFile); - }, - imageDiffHtml() { - return this.discussion.imageDiffHtml; - }, + diffFileClass() { + const { text } = this.diffFile; + return text ? 'text-file' : 'js-image-file'; }, - mounted() { - if (this.isImageDiff) { - const canCreateNote = false; - const renderCommentBadge = true; - imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge); - } else { - const fileHolder = $(this.$refs.fileHolder); - this.$nextTick(() => { - syntaxHighlight(fileHolder); - }); - } + diffRows() { + return $(this.discussion.truncatedDiffLines); }, - methods: { - rowTag(html) { - return html.outerHTML ? 'tr' : 'template'; - }, + diffFile() { + return convertObjectPropsToCamelCase(this.discussion.diffFile); }, - }; + imageDiffHtml() { + return this.discussion.imageDiffHtml; + }, + }, + mounted() { + if (this.isImageDiff) { + const canCreateNote = false; + const renderCommentBadge = true; + imageDiffHelper.initImageDiff( + this.$refs.fileHolder, + canCreateNote, + renderCommentBadge, + ); + } else { + const fileHolder = $(this.$refs.fileHolder); + this.$nextTick(() => { + syntaxHighlight(fileHolder); + }); + } + }, + methods: { + rowTag(html) { + return html.outerHTML ? 'tr' : 'template'; + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index 0158f58b569..d492d1cd001 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,67 +1,69 @@ <script> - import { mapGetters } from 'vuex'; - import resolveSvg from 'icons/_icon_resolve_discussion.svg'; - import resolvedSvg from 'icons/_icon_status_success_solid.svg'; - import mrIssueSvg from 'icons/_icon_mr_issue.svg'; - import nextDiscussionSvg from 'icons/_next_discussion.svg'; - import { pluralize } from '../../lib/utils/text_utility'; - import { scrollToElement } from '../../lib/utils/common_utils'; - import tooltip from '../../vue_shared/directives/tooltip'; +import { mapGetters } from 'vuex'; +import resolveSvg from 'icons/_icon_resolve_discussion.svg'; +import resolvedSvg from 'icons/_icon_status_success_solid.svg'; +import mrIssueSvg from 'icons/_icon_mr_issue.svg'; +import nextDiscussionSvg from 'icons/_next_discussion.svg'; +import { pluralize } from '../../lib/utils/text_utility'; +import { scrollToElement } from '../../lib/utils/common_utils'; +import tooltip from '../../vue_shared/directives/tooltip'; - export default { - directives: { - tooltip, +export default { + directives: { + tooltip, + }, + computed: { + ...mapGetters([ + 'getUserData', + 'getNoteableData', + 'discussionCount', + 'unresolvedDiscussions', + 'resolvedDiscussionCount', + ]), + isLoggedIn() { + return this.getUserData.id; }, - computed: { - ...mapGetters([ - 'getUserData', - 'getNoteableData', - 'discussionCount', - 'unresolvedDiscussions', - 'resolvedDiscussionCount', - ]), - isLoggedIn() { - return this.getUserData.id; - }, - hasNextButton() { - return this.isLoggedIn && !this.allResolved; - }, - countText() { - return pluralize('discussion', this.discussionCount); - }, - allResolved() { - return this.resolvedDiscussionCount === this.discussionCount; - }, - resolveAllDiscussionsIssuePath() { - return this.getNoteableData.create_issue_to_resolve_discussions_path; - }, - firstUnresolvedDiscussionId() { - const item = this.unresolvedDiscussions[0] || {}; - - return item.id; - }, + hasNextButton() { + return this.isLoggedIn && !this.allResolved; + }, + countText() { + return pluralize('discussion', this.discussionCount); + }, + allResolved() { + return this.resolvedDiscussionCount === this.discussionCount; }, - created() { - this.resolveSvg = resolveSvg; - this.resolvedSvg = resolvedSvg; - this.mrIssueSvg = mrIssueSvg; - this.nextDiscussionSvg = nextDiscussionSvg; + resolveAllDiscussionsIssuePath() { + return this.getNoteableData.create_issue_to_resolve_discussions_path; + }, + firstUnresolvedDiscussionId() { + const item = this.unresolvedDiscussions[0] || {}; + + return item.id; }, - methods: { - jumpToFirstDiscussion() { - const el = document.querySelector(`[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`); - const activeTab = window.mrTabs.currentAction; + }, + created() { + this.resolveSvg = resolveSvg; + this.resolvedSvg = resolvedSvg; + this.mrIssueSvg = mrIssueSvg; + this.nextDiscussionSvg = nextDiscussionSvg; + }, + methods: { + jumpToFirstDiscussion() { + const el = document.querySelector( + `[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`, + ); + const activeTab = window.mrTabs.currentAction; - if (activeTab === 'commits' || activeTab === 'pipelines') { - window.mrTabs.activateTab('show'); - } + if (activeTab === 'commits' || activeTab === 'pipelines') { + window.mrTabs.activateTab('show'); + } - if (el) { - scrollToElement(el); - } - }, + if (el) { + scrollToElement(el); + } }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue index fc0722042cc..13283b187d1 100644 --- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue +++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue @@ -1,15 +1,13 @@ <script> - import Icon from '~/vue_shared/components/icon.vue'; - import Issuable from '~/vue_shared/mixins/issuable'; +import Icon from '~/vue_shared/components/icon.vue'; +import Issuable from '~/vue_shared/mixins/issuable'; - export default { - components: { - Icon, - }, - mixins: [ - Issuable, - ], - }; +export default { + components: { + Icon, + }, + mixins: [Issuable], +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index c26aa6fa15d..a7e2d857013 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,121 +1,119 @@ <script> - import { mapGetters } from 'vuex'; - import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; - import emojiSmile from 'icons/_emoji_smile.svg'; - import emojiSmiley from 'icons/_emoji_smiley.svg'; - import editSvg from 'icons/_icon_pencil.svg'; - import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg'; - import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg'; - import ellipsisSvg from 'icons/_ellipsis_v.svg'; - import loadingIcon from '~/vue_shared/components/loading_icon.vue'; - import tooltip from '~/vue_shared/directives/tooltip'; +import { mapGetters } from 'vuex'; +import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; +import emojiSmile from 'icons/_emoji_smile.svg'; +import emojiSmiley from 'icons/_emoji_smiley.svg'; +import editSvg from 'icons/_icon_pencil.svg'; +import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg'; +import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg'; +import ellipsisSvg from 'icons/_ellipsis_v.svg'; +import loadingIcon from '~/vue_shared/components/loading_icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; - export default { - name: 'NoteActions', - directives: { - tooltip, - }, - components: { - loadingIcon, - }, - props: { - authorId: { - type: Number, - required: true, - }, - noteId: { - type: Number, - required: true, - }, - accessLevel: { - type: String, - required: false, - default: '', - }, - reportAbusePath: { - type: String, - required: true, - }, - canEdit: { - type: Boolean, - required: true, - }, - canDelete: { - type: Boolean, - required: true, - }, - resolvable: { - type: Boolean, - required: false, - default: false, - }, - isResolved: { - type: Boolean, - required: false, - default: false, - }, - isResolving: { - type: Boolean, - required: false, - default: false, - }, - resolvedBy: { - type: Object, - required: false, - default: () => ({}), - }, - canReportAsAbuse: { - type: Boolean, - required: true, - }, - }, - computed: { - ...mapGetters([ - 'getUserDataByProp', - ]), - shouldShowActionsDropdown() { - return this.currentUserId && (this.canEdit || this.canReportAsAbuse); - }, - canAddAwardEmoji() { - return this.currentUserId; - }, - isAuthoredByCurrentUser() { - return this.authorId === this.currentUserId; - }, - currentUserId() { - return this.getUserDataByProp('id'); - }, - resolveButtonTitle() { - let title = 'Mark as resolved'; +export default { + name: 'NoteActions', + directives: { + tooltip, + }, + components: { + loadingIcon, + }, + props: { + authorId: { + type: Number, + required: true, + }, + noteId: { + type: Number, + required: true, + }, + accessLevel: { + type: String, + required: false, + default: '', + }, + reportAbusePath: { + type: String, + required: true, + }, + canEdit: { + type: Boolean, + required: true, + }, + canDelete: { + type: Boolean, + required: true, + }, + resolvable: { + type: Boolean, + required: false, + default: false, + }, + isResolved: { + type: Boolean, + required: false, + default: false, + }, + isResolving: { + type: Boolean, + required: false, + default: false, + }, + resolvedBy: { + type: Object, + required: false, + default: () => ({}), + }, + canReportAsAbuse: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapGetters(['getUserDataByProp']), + shouldShowActionsDropdown() { + return this.currentUserId && (this.canEdit || this.canReportAsAbuse); + }, + canAddAwardEmoji() { + return this.currentUserId; + }, + isAuthoredByCurrentUser() { + return this.authorId === this.currentUserId; + }, + currentUserId() { + return this.getUserDataByProp('id'); + }, + resolveButtonTitle() { + let title = 'Mark as resolved'; - if (this.resolvedBy) { - title = `Resolved by ${this.resolvedBy.name}`; - } + if (this.resolvedBy) { + title = `Resolved by ${this.resolvedBy.name}`; + } - return title; - }, - }, - created() { - this.emojiSmiling = emojiSmiling; - this.emojiSmile = emojiSmile; - this.emojiSmiley = emojiSmiley; - this.editSvg = editSvg; - this.ellipsisSvg = ellipsisSvg; - this.resolveDiscussionSvg = resolveDiscussionSvg; - this.resolvedDiscussionSvg = resolvedDiscussionSvg; - }, - methods: { - onEdit() { - this.$emit('handleEdit'); - }, - onDelete() { - this.$emit('handleDelete'); - }, - onResolve() { - this.$emit('handleResolve'); - }, - }, - }; + return title; + }, + }, + created() { + this.emojiSmiling = emojiSmiling; + this.emojiSmile = emojiSmile; + this.emojiSmiley = emojiSmiley; + this.editSvg = editSvg; + this.ellipsisSvg = ellipsisSvg; + this.resolveDiscussionSvg = resolveDiscussionSvg; + this.resolvedDiscussionSvg = resolvedDiscussionSvg; + }, + methods: { + onEdit() { + this.$emit('handleEdit'); + }, + onDelete() { + this.$emit('handleDelete'); + }, + onResolve() { + this.$emit('handleResolve'); + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue index 618b807b9cc..34ecbd00c63 100644 --- a/app/assets/javascripts/notes/components/note_attachment.vue +++ b/app/assets/javascripts/notes/components/note_attachment.vue @@ -1,13 +1,13 @@ <script> - export default { - name: 'NoteAttachment', - props: { - attachment: { - type: Object, - required: true, - }, +export default { + name: 'NoteAttachment', + props: { + attachment: { + type: Object, + required: true, }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index caa9701e03f..6cb8229e268 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,179 +1,192 @@ <script> - import { mapActions, mapGetters } from 'vuex'; - import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; - import emojiSmile from 'icons/_emoji_smile.svg'; - import emojiSmiley from 'icons/_emoji_smiley.svg'; - import Flash from '../../flash'; - import { glEmojiTag } from '../../emoji'; - import tooltip from '../../vue_shared/directives/tooltip'; - - export default { - directives: { - tooltip, +import { mapActions, mapGetters } from 'vuex'; +import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; +import emojiSmile from 'icons/_emoji_smile.svg'; +import emojiSmiley from 'icons/_emoji_smiley.svg'; +import Flash from '../../flash'; +import { glEmojiTag } from '../../emoji'; +import tooltip from '../../vue_shared/directives/tooltip'; + +export default { + directives: { + tooltip, + }, + props: { + awards: { + type: Array, + required: true, }, - props: { - awards: { - type: Array, - required: true, - }, - toggleAwardPath: { - type: String, - required: true, - }, - noteAuthorId: { - type: Number, - required: true, - }, - noteId: { - type: Number, - required: true, - }, + toggleAwardPath: { + type: String, + required: true, }, - computed: { - ...mapGetters([ - 'getUserData', - ]), - // `this.awards` is an array with emojis but they are not grouped by emoji name. See below. - // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ] - // This method will group emojis by their name as an Object. See below. - // { - // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ], - // bar: [ { name: bar, user: user1 } ] - // } - // We need to do this otherwise we will render the same emoji over and over again. - groupedAwards() { - const awards = this.awards.reduce((acc, award) => { - if (Object.prototype.hasOwnProperty.call(acc, award.name)) { - acc[award.name].push(award); - } else { - Object.assign(acc, { [award.name]: [award] }); - } - - return acc; - }, {}); - - const orderedAwards = {}; - const { thumbsdown, thumbsup } = awards; - // Always show thumbsup and thumbsdown first - if (thumbsup) { - orderedAwards.thumbsup = thumbsup; - delete awards.thumbsup; - } - if (thumbsdown) { - orderedAwards.thumbsdown = thumbsdown; - delete awards.thumbsdown; - } - - return Object.assign({}, orderedAwards, awards); - }, - isAuthoredByMe() { - return this.noteAuthorId === this.getUserData.id; - }, - isLoggedIn() { - return this.getUserData.id; - }, + noteAuthorId: { + type: Number, + required: true, }, - created() { - this.emojiSmiling = emojiSmiling; - this.emojiSmile = emojiSmile; - this.emojiSmiley = emojiSmiley; + noteId: { + type: Number, + required: true, }, - methods: { - ...mapActions([ - 'toggleAwardRequest', - ]), - getAwardHTML(name) { - return glEmojiTag(name); - }, - getAwardClassBindings(awardList, awardName) { - return { - active: this.hasReactionByCurrentUser(awardList), - disabled: !this.canInteractWithEmoji(awardList, awardName), - }; - }, - canInteractWithEmoji(awardList, awardName) { - let isAllowed = true; - const restrictedEmojis = ['thumbsup', 'thumbsdown']; - - // Users can not add :+1: and :-1: to their own notes - if (this.getUserData.id === this.noteAuthorId && restrictedEmojis.indexOf(awardName) > -1) { - isAllowed = false; - } - - return this.getUserData.id && isAllowed; - }, - hasReactionByCurrentUser(awardList) { - return awardList.filter(award => award.user.id === this.getUserData.id).length; - }, - awardTitle(awardsList) { - const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); - const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; - let awardList = awardsList; - - // Filter myself from list if I am awarded. - if (hasReactionByCurrentUser) { - awardList = awardList.filter(award => award.user.id !== this.getUserData.id); - } - - // Get only 9-10 usernames to show in tooltip text. - const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); - - // Get the remaining list to use in `and x more` text. - const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); - - // Add myself to the begining of the list so title will start with You. - if (hasReactionByCurrentUser) { - namesToShow.unshift('You'); - } - - let title = ''; - - // We have 10+ awarded user, join them with comma and add `and x more`. - if (remainingAwardList.length) { - title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`; - } else if (namesToShow.length > 1) { - // Join all names with comma but not the last one, it will be added with and text. - title = namesToShow.slice(0, namesToShow.length - 1).join(', '); - // If we have more than 2 users we need an extra comma before and text. - title += namesToShow.length > 2 ? ',' : ''; - title += ` and ${namesToShow.slice(-1)}`; // Append and text - } else { // We have only 2 users so join them with and. - title = namesToShow.join(' and '); - } - - return title; - }, - handleAward(awardName) { - if (!this.isLoggedIn) { - return; - } - - let parsedName; - - // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string - switch (awardName) { - case '100': - parsedName = 100; - break; - case '1234': - parsedName = 1234; - break; - default: - parsedName = awardName; - break; + }, + computed: { + ...mapGetters(['getUserData']), + // `this.awards` is an array with emojis but they are not grouped by emoji name. See below. + // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ] + // This method will group emojis by their name as an Object. See below. + // { + // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ], + // bar: [ { name: bar, user: user1 } ] + // } + // We need to do this otherwise we will render the same emoji over and over again. + groupedAwards() { + const awards = this.awards.reduce((acc, award) => { + if (Object.prototype.hasOwnProperty.call(acc, award.name)) { + acc[award.name].push(award); + } else { + Object.assign(acc, { [award.name]: [award] }); } - const data = { - endpoint: this.toggleAwardPath, - noteId: this.noteId, - awardName: parsedName, - }; - - this.toggleAwardRequest(data) - .catch(() => Flash('Something went wrong on our end.')); - }, + return acc; + }, {}); + + const orderedAwards = {}; + const { thumbsdown, thumbsup } = awards; + // Always show thumbsup and thumbsdown first + if (thumbsup) { + orderedAwards.thumbsup = thumbsup; + delete awards.thumbsup; + } + if (thumbsdown) { + orderedAwards.thumbsdown = thumbsdown; + delete awards.thumbsdown; + } + + return Object.assign({}, orderedAwards, awards); + }, + isAuthoredByMe() { + return this.noteAuthorId === this.getUserData.id; + }, + isLoggedIn() { + return this.getUserData.id; + }, + }, + created() { + this.emojiSmiling = emojiSmiling; + this.emojiSmile = emojiSmile; + this.emojiSmiley = emojiSmiley; + }, + methods: { + ...mapActions(['toggleAwardRequest']), + getAwardHTML(name) { + return glEmojiTag(name); + }, + getAwardClassBindings(awardList, awardName) { + return { + active: this.hasReactionByCurrentUser(awardList), + disabled: !this.canInteractWithEmoji(awardList, awardName), + }; + }, + canInteractWithEmoji(awardList, awardName) { + let isAllowed = true; + const restrictedEmojis = ['thumbsup', 'thumbsdown']; + + // Users can not add :+1: and :-1: to their own notes + if ( + this.getUserData.id === this.noteAuthorId && + restrictedEmojis.indexOf(awardName) > -1 + ) { + isAllowed = false; + } + + return this.getUserData.id && isAllowed; + }, + hasReactionByCurrentUser(awardList) { + return awardList.filter(award => award.user.id === this.getUserData.id) + .length; + }, + awardTitle(awardsList) { + const hasReactionByCurrentUser = this.hasReactionByCurrentUser( + awardsList, + ); + const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; + let awardList = awardsList; + + // Filter myself from list if I am awarded. + if (hasReactionByCurrentUser) { + awardList = awardList.filter( + award => award.user.id !== this.getUserData.id, + ); + } + + // Get only 9-10 usernames to show in tooltip text. + const namesToShow = awardList + .slice(0, TOOLTIP_NAME_COUNT) + .map(award => award.user.name); + + // Get the remaining list to use in `and x more` text. + const remainingAwardList = awardList.slice( + TOOLTIP_NAME_COUNT, + awardList.length, + ); + + // Add myself to the begining of the list so title will start with You. + if (hasReactionByCurrentUser) { + namesToShow.unshift('You'); + } + + let title = ''; + + // We have 10+ awarded user, join them with comma and add `and x more`. + if (remainingAwardList.length) { + title = `${namesToShow.join(', ')}, and ${ + remainingAwardList.length + } more.`; + } else if (namesToShow.length > 1) { + // Join all names with comma but not the last one, it will be added with and text. + title = namesToShow.slice(0, namesToShow.length - 1).join(', '); + // If we have more than 2 users we need an extra comma before and text. + title += namesToShow.length > 2 ? ',' : ''; + title += ` and ${namesToShow.slice(-1)}`; // Append and text + } else { + // We have only 2 users so join them with and. + title = namesToShow.join(' and '); + } + + return title; + }, + handleAward(awardName) { + if (!this.isLoggedIn) { + return; + } + + let parsedName; + + // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string + switch (awardName) { + case '100': + parsedName = 100; + break; + case '1234': + parsedName = 1234; + break; + default: + parsedName = awardName; + break; + } + + const data = { + endpoint: this.toggleAwardPath, + noteId: this.noteId, + awardName: parsedName, + }; + + this.toggleAwardRequest(data).catch(() => + Flash('Something went wrong on our end.'), + ); }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index a94f1a28a4c..069f94c5845 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -1,83 +1,81 @@ <script> - import $ from 'jquery'; - import noteEditedText from './note_edited_text.vue'; - import noteAwardsList from './note_awards_list.vue'; - import noteAttachment from './note_attachment.vue'; - import noteForm from './note_form.vue'; - import TaskList from '../../task_list'; - import autosave from '../mixins/autosave'; +import $ from 'jquery'; +import noteEditedText from './note_edited_text.vue'; +import noteAwardsList from './note_awards_list.vue'; +import noteAttachment from './note_attachment.vue'; +import noteForm from './note_form.vue'; +import TaskList from '../../task_list'; +import autosave from '../mixins/autosave'; - export default { - components: { - noteEditedText, - noteAwardsList, - noteAttachment, - noteForm, +export default { + components: { + noteEditedText, + noteAwardsList, + noteAttachment, + noteForm, + }, + mixins: [autosave], + props: { + note: { + type: Object, + required: true, }, - mixins: [ - autosave, - ], - props: { - note: { - type: Object, - required: true, - }, - canEdit: { - type: Boolean, - required: true, - }, - isEditing: { - type: Boolean, - required: false, - default: false, - }, + canEdit: { + type: Boolean, + required: true, }, - computed: { - noteBody() { - return this.note.note; - }, + isEditing: { + type: Boolean, + required: false, + default: false, }, - mounted() { - this.renderGFM(); - this.initTaskList(); + }, + computed: { + noteBody() { + return this.note.note; + }, + }, + mounted() { + this.renderGFM(); + this.initTaskList(); + + if (this.isEditing) { + this.initAutoSave(this.note.noteable_type); + } + }, + updated() { + this.initTaskList(); + this.renderGFM(); - if (this.isEditing) { + if (this.isEditing) { + if (!this.autosave) { this.initAutoSave(this.note.noteable_type); + } else { + this.setAutoSave(); } + } + }, + methods: { + renderGFM() { + $(this.$refs['note-body']).renderGFM(); }, - updated() { - this.initTaskList(); - this.renderGFM(); - - if (this.isEditing) { - if (!this.autosave) { - this.initAutoSave(this.note.noteable_type); - } else { - this.setAutoSave(); - } + initTaskList() { + if (this.canEdit) { + this.taskList = new TaskList({ + dataType: 'note', + fieldName: 'note', + selector: '.notes', + }); } }, - methods: { - renderGFM() { - $(this.$refs['note-body']).renderGFM(); - }, - initTaskList() { - if (this.canEdit) { - this.taskList = new TaskList({ - dataType: 'note', - fieldName: 'note', - selector: '.notes', - }); - } - }, - handleFormUpdate(note, parentElement, callback) { - this.$emit('handleFormUpdate', note, parentElement, callback); - }, - formCancelHandler(shouldConfirm, isDirty) { - this.$emit('cancelFormEdition', shouldConfirm, isDirty); - }, + handleFormUpdate(note, parentElement, callback) { + this.$emit('handleFormUpdate', note, parentElement, callback); + }, + formCancelHandler(shouldConfirm, isDirty) { + this.$emit('cancelFormEdition', shouldConfirm, isDirty); }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index ae2e52554d2..4ddca918495 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -1,32 +1,32 @@ <script> - import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; +import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; - export default { - name: 'EditedNoteText', - components: { - timeAgoTooltip, +export default { + name: 'EditedNoteText', + components: { + timeAgoTooltip, + }, + props: { + actionText: { + type: String, + required: true, }, - props: { - actionText: { - type: String, - required: true, - }, - editedAt: { - type: String, - required: true, - }, - editedBy: { - type: Object, - required: false, - default: () => ({}), - }, - className: { - type: String, - required: false, - default: 'edited-text', - }, + editedAt: { + type: String, + required: true, }, - }; + editedBy: { + type: Object, + required: false, + default: () => ({}), + }, + className: { + type: String, + required: false, + default: 'edited-text', + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 1a13fdbeb7c..c59a2e7a406 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,128 +1,136 @@ <script> - import { mapGetters, mapActions } from 'vuex'; - import eventHub from '../event_hub'; - import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; - import markdownField from '../../vue_shared/components/markdown/field.vue'; - import issuableStateMixin from '../mixins/issuable_state'; - import resolvable from '../mixins/resolvable'; +import { mapGetters, mapActions } from 'vuex'; +import eventHub from '../event_hub'; +import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; +import markdownField from '../../vue_shared/components/markdown/field.vue'; +import issuableStateMixin from '../mixins/issuable_state'; +import resolvable from '../mixins/resolvable'; - export default { - name: 'IssueNoteForm', - components: { - issueWarning, - markdownField, +export default { + name: 'IssueNoteForm', + components: { + issueWarning, + markdownField, + }, + mixins: [issuableStateMixin, resolvable], + props: { + noteBody: { + type: String, + required: false, + default: '', }, - mixins: [ - issuableStateMixin, - resolvable, - ], - props: { - noteBody: { - type: String, - required: false, - default: '', - }, - noteId: { - type: Number, - required: false, - default: 0, - }, - saveButtonTitle: { - type: String, - required: false, - default: 'Save comment', - }, - note: { - type: Object, - required: false, - default: () => ({}), - }, - isEditing: { - type: Boolean, - required: true, - }, + noteId: { + type: Number, + required: false, + default: 0, }, - data() { - return { - updatedNoteBody: this.noteBody, - conflictWhileEditing: false, - isSubmitting: false, - isResolving: false, - resolveAsThread: true, - }; + saveButtonTitle: { + type: String, + required: false, + default: 'Save comment', }, - computed: { - ...mapGetters([ - 'getDiscussionLastNote', - 'getNoteableData', - 'getNoteableDataByProp', - 'getNotesDataByProp', - 'getUserDataByProp', - ]), - noteHash() { - return `#note_${this.noteId}`; - }, - markdownPreviewPath() { - return this.getNoteableDataByProp('preview_note_path'); - }, - markdownDocsPath() { - return this.getNotesDataByProp('markdownDocsPath'); - }, - quickActionsDocsPath() { - return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined; - }, - currentUserId() { - return this.getUserDataByProp('id'); - }, - isDisabled() { - return !this.updatedNoteBody.length || this.isSubmitting; - }, + note: { + type: Object, + required: false, + default: () => ({}), }, - watch: { - noteBody() { - if (this.updatedNoteBody === this.noteBody) { - this.updatedNoteBody = this.noteBody; - } else { - this.conflictWhileEditing = true; - } - }, + isEditing: { + type: Boolean, + required: true, + }, + }, + data() { + return { + updatedNoteBody: this.noteBody, + conflictWhileEditing: false, + isSubmitting: false, + isResolving: false, + resolveAsThread: true, + }; + }, + computed: { + ...mapGetters([ + 'getDiscussionLastNote', + 'getNoteableData', + 'getNoteableDataByProp', + 'getNotesDataByProp', + 'getUserDataByProp', + ]), + noteHash() { + return `#note_${this.noteId}`; + }, + markdownPreviewPath() { + return this.getNoteableDataByProp('preview_note_path'); + }, + markdownDocsPath() { + return this.getNotesDataByProp('markdownDocsPath'); + }, + quickActionsDocsPath() { + return !this.isEditing + ? this.getNotesDataByProp('quickActionsDocsPath') + : undefined; }, - mounted() { - this.$refs.textarea.focus(); + currentUserId() { + return this.getUserDataByProp('id'); }, - methods: { - ...mapActions([ - 'toggleResolveNote', - ]), - handleUpdate(shouldResolve) { - const beforeSubmitDiscussionState = this.discussionResolved; - this.isSubmitting = true; + isDisabled() { + return !this.updatedNoteBody.length || this.isSubmitting; + }, + }, + watch: { + noteBody() { + if (this.updatedNoteBody === this.noteBody) { + this.updatedNoteBody = this.noteBody; + } else { + this.conflictWhileEditing = true; + } + }, + }, + mounted() { + this.$refs.textarea.focus(); + }, + methods: { + ...mapActions(['toggleResolveNote']), + handleUpdate(shouldResolve) { + const beforeSubmitDiscussionState = this.discussionResolved; + this.isSubmitting = true; - this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => { + this.$emit( + 'handleFormUpdate', + this.updatedNoteBody, + this.$refs.editNoteForm, + () => { this.isSubmitting = false; if (shouldResolve) { this.resolveHandler(beforeSubmitDiscussionState); } - }); - }, - editMyLastNote() { - if (this.updatedNoteBody === '') { - const lastNoteInDiscussion = this.getDiscussionLastNote(this.updatedNoteBody); + }, + ); + }, + editMyLastNote() { + if (this.updatedNoteBody === '') { + const lastNoteInDiscussion = this.getDiscussionLastNote( + this.updatedNoteBody, + ); - if (lastNoteInDiscussion) { - eventHub.$emit('enterEditMode', { - noteId: lastNoteInDiscussion.id, - }); - } + if (lastNoteInDiscussion) { + eventHub.$emit('enterEditMode', { + noteId: lastNoteInDiscussion.id, + }); } - }, - cancelHandler(shouldConfirm = false) { - // Sends information about confirm message and if the textarea has changed - this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.updatedNoteBody); - }, + } + }, + cancelHandler(shouldConfirm = false) { + // Sends information about confirm message and if the textarea has changed + this.$emit( + 'cancelFormEdition', + shouldConfirm, + this.noteBody !== this.updatedNoteBody, + ); }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 4743d95b951..c3d1ef1fcc6 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -1,65 +1,63 @@ <script> - import { mapActions } from 'vuex'; - import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; +import { mapActions } from 'vuex'; +import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; - export default { - components: { - timeAgoTooltip, +export default { + components: { + timeAgoTooltip, + }, + props: { + author: { + type: Object, + required: true, }, - props: { - author: { - type: Object, - required: true, - }, - createdAt: { - type: String, - required: true, - }, - actionText: { - type: String, - required: false, - default: '', - }, - actionTextHtml: { - type: String, - required: false, - default: '', - }, - noteId: { - type: Number, - required: true, - }, - includeToggle: { - type: Boolean, - required: false, - default: false, - }, - expanded: { - type: Boolean, - required: false, - default: true, - }, + createdAt: { + type: String, + required: true, }, - computed: { - toggleChevronClass() { - return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down'; - }, - noteTimestampLink() { - return `#note_${this.noteId}`; - }, + actionText: { + type: String, + required: false, + default: '', }, - methods: { - ...mapActions([ - 'setTargetNoteHash', - ]), - handleToggle() { - this.$emit('toggleHandler'); - }, - updateTargetNoteHash() { - this.setTargetNoteHash(this.noteTimestampLink); - }, + actionTextHtml: { + type: String, + required: false, + default: '', }, - }; + noteId: { + type: Number, + required: true, + }, + includeToggle: { + type: Boolean, + required: false, + default: false, + }, + expanded: { + type: Boolean, + required: false, + default: true, + }, + }, + computed: { + toggleChevronClass() { + return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down'; + }, + noteTimestampLink() { + return `#note_${this.noteId}`; + }, + }, + methods: { + ...mapActions(['setTargetNoteHash']), + handleToggle() { + this.$emit('toggleHandler'); + }, + updateTargetNoteHash() { + this.setTargetNoteHash(this.noteTimestampLink); + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue index 45d3c2de355..91f7c269757 100644 --- a/app/assets/javascripts/notes/components/note_signed_out_widget.vue +++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue @@ -1,19 +1,17 @@ <script> - import { mapGetters } from 'vuex'; +import { mapGetters } from 'vuex'; - export default { - computed: { - ...mapGetters([ - 'getNotesDataByProp', - ]), - registerLink() { - return this.getNotesDataByProp('registerPath'); - }, - signInLink() { - return this.getNotesDataByProp('newSessionPath'); - }, +export default { + computed: { + ...mapGetters(['getNotesDataByProp']), + registerLink() { + return this.getNotesDataByProp('registerPath'); }, - }; + signInLink() { + return this.getNotesDataByProp('newSessionPath'); + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 76bb53eaf2f..cf579c5d4dc 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,210 +1,210 @@ <script> - import { mapActions, mapGetters } from 'vuex'; - import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg'; - import nextDiscussionsSvg from 'icons/_next_discussion.svg'; - import Flash from '../../flash'; - import { SYSTEM_NOTE } from '../constants'; - import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; - import noteableNote from './noteable_note.vue'; - import noteHeader from './note_header.vue'; - import noteSignedOutWidget from './note_signed_out_widget.vue'; - import noteEditedText from './note_edited_text.vue'; - import noteForm from './note_form.vue'; - import diffWithNote from './diff_with_note.vue'; - import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; - import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; - import autosave from '../mixins/autosave'; - import noteable from '../mixins/noteable'; - import resolvable from '../mixins/resolvable'; - import tooltip from '../../vue_shared/directives/tooltip'; - import { scrollToElement } from '../../lib/utils/common_utils'; +import { mapActions, mapGetters } from 'vuex'; +import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg'; +import nextDiscussionsSvg from 'icons/_next_discussion.svg'; +import Flash from '../../flash'; +import { SYSTEM_NOTE } from '../constants'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import noteableNote from './noteable_note.vue'; +import noteHeader from './note_header.vue'; +import noteSignedOutWidget from './note_signed_out_widget.vue'; +import noteEditedText from './note_edited_text.vue'; +import noteForm from './note_form.vue'; +import diffWithNote from './diff_with_note.vue'; +import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; +import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; +import autosave from '../mixins/autosave'; +import noteable from '../mixins/noteable'; +import resolvable from '../mixins/resolvable'; +import tooltip from '../../vue_shared/directives/tooltip'; +import { scrollToElement } from '../../lib/utils/common_utils'; - export default { - components: { - noteableNote, - diffWithNote, - userAvatarLink, - noteHeader, - noteSignedOutWidget, - noteEditedText, - noteForm, - placeholderNote, - placeholderSystemNote, +export default { + components: { + noteableNote, + diffWithNote, + userAvatarLink, + noteHeader, + noteSignedOutWidget, + noteEditedText, + noteForm, + placeholderNote, + placeholderSystemNote, + }, + directives: { + tooltip, + }, + mixins: [autosave, noteable, resolvable], + props: { + note: { + type: Object, + required: true, }, - directives: { - tooltip, - }, - mixins: [ - autosave, - noteable, - resolvable, - ], - props: { - note: { - type: Object, - required: true, - }, - }, - data() { + }, + data() { + return { + isReplying: false, + isResolving: false, + resolveAsThread: true, + }; + }, + computed: { + ...mapGetters([ + 'getNoteableData', + 'discussionCount', + 'resolvedDiscussionCount', + 'unresolvedDiscussions', + ]), + discussion() { return { - isReplying: false, - isResolving: false, - resolveAsThread: true, + ...this.note.notes[0], + truncatedDiffLines: this.note.truncated_diff_lines, + diffFile: this.note.diff_file, + diffDiscussion: this.note.diff_discussion, + imageDiffHtml: this.note.image_diff_html, }; }, - computed: { - ...mapGetters([ - 'getNoteableData', - 'discussionCount', - 'resolvedDiscussionCount', - 'unresolvedDiscussions', - ]), - discussion() { - return { - ...this.note.notes[0], - truncatedDiffLines: this.note.truncated_diff_lines, - diffFile: this.note.diff_file, - diffDiscussion: this.note.diff_discussion, - imageDiffHtml: this.note.image_diff_html, - }; - }, - author() { - return this.discussion.author; - }, - canReply() { - return this.getNoteableData.current_user.can_create_note; - }, - newNotePath() { - return this.getNoteableData.create_note_path; - }, - lastUpdatedBy() { - const { notes } = this.note; + author() { + return this.discussion.author; + }, + canReply() { + return this.getNoteableData.current_user.can_create_note; + }, + newNotePath() { + return this.getNoteableData.create_note_path; + }, + lastUpdatedBy() { + const { notes } = this.note; - if (notes.length > 1) { - return notes[notes.length - 1].author; - } + if (notes.length > 1) { + return notes[notes.length - 1].author; + } - return null; - }, - lastUpdatedAt() { - const { notes } = this.note; + return null; + }, + lastUpdatedAt() { + const { notes } = this.note; - if (notes.length > 1) { - return notes[notes.length - 1].created_at; - } + if (notes.length > 1) { + return notes[notes.length - 1].created_at; + } - return null; - }, - hasUnresolvedDiscussion() { - return this.unresolvedDiscussions.length > 0; - }, - wrapperComponent() { - return (this.discussion.diffDiscussion && this.discussion.diffFile) ? diffWithNote : 'div'; - }, - wrapperClass() { - return this.isDiffDiscussion ? '' : 'panel panel-default'; - }, + return null; + }, + hasUnresolvedDiscussion() { + return this.unresolvedDiscussions.length > 0; + }, + wrapperComponent() { + return this.discussion.diffDiscussion && this.discussion.diffFile + ? diffWithNote + : 'div'; }, - mounted() { - if (this.isReplying) { + wrapperClass() { + return this.isDiffDiscussion ? '' : 'panel panel-default'; + }, + }, + mounted() { + if (this.isReplying) { + this.initAutoSave(this.discussion.noteable_type); + } + }, + updated() { + if (this.isReplying) { + if (!this.autosave) { this.initAutoSave(this.discussion.noteable_type); + } else { + this.setAutoSave(); } - }, - updated() { - if (this.isReplying) { - if (!this.autosave) { - this.initAutoSave(this.discussion.noteable_type); - } else { - this.setAutoSave(); + } + }, + created() { + this.resolveDiscussionsSvg = resolveDiscussionsSvg; + this.nextDiscussionsSvg = nextDiscussionsSvg; + }, + methods: { + ...mapActions([ + 'saveNote', + 'toggleDiscussion', + 'removePlaceholderNotes', + 'toggleResolveNote', + ]), + componentName(note) { + if (note.isPlaceholderNote) { + if (note.placeholderType === SYSTEM_NOTE) { + return placeholderSystemNote; } + return placeholderNote; } + + return noteableNote; }, - created() { - this.resolveDiscussionsSvg = resolveDiscussionsSvg; - this.nextDiscussionsSvg = nextDiscussionsSvg; + componentData(note) { + return note.isPlaceholderNote ? this.note.notes[0] : note; }, - methods: { - ...mapActions([ - 'saveNote', - 'toggleDiscussion', - 'removePlaceholderNotes', - 'toggleResolveNote', - ]), - componentName(note) { - if (note.isPlaceholderNote) { - if (note.placeholderType === SYSTEM_NOTE) { - return placeholderSystemNote; - } - return placeholderNote; - } + toggleDiscussionHandler() { + this.toggleDiscussion({ discussionId: this.note.id }); + }, + showReplyForm() { + this.isReplying = true; + }, + cancelReplyForm(shouldConfirm) { + if (shouldConfirm && this.$refs.noteForm.isDirty) { + const msg = 'Are you sure you want to cancel creating this comment?'; - return noteableNote; - }, - componentData(note) { - return note.isPlaceholderNote ? this.note.notes[0] : note; - }, - toggleDiscussionHandler() { - this.toggleDiscussion({ discussionId: this.note.id }); - }, - showReplyForm() { - this.isReplying = true; - }, - cancelReplyForm(shouldConfirm) { - if (shouldConfirm && this.$refs.noteForm.isDirty) { - // eslint-disable-next-line no-alert - if (!confirm('Are you sure you want to cancel creating this comment?')) { - return; - } + // eslint-disable-next-line no-alert + if (!confirm(msg)) { + return; } + } - this.resetAutoSave(); - this.isReplying = false; - }, - saveReply(noteText, form, callback) { - const replyData = { - endpoint: this.newNotePath, - flashContainer: this.$el, - data: { - in_reply_to_discussion_id: this.note.reply_id, - target_type: this.noteableType, - target_id: this.discussion.noteable_id, - note: { note: noteText }, - }, - }; - this.isReplying = false; + this.resetAutoSave(); + this.isReplying = false; + }, + saveReply(noteText, form, callback) { + const replyData = { + endpoint: this.newNotePath, + flashContainer: this.$el, + data: { + in_reply_to_discussion_id: this.note.reply_id, + target_type: this.noteableType, + target_id: this.discussion.noteable_id, + note: { note: noteText }, + }, + }; + this.isReplying = false; - this.saveNote(replyData) - .then(() => { - this.resetAutoSave(); - callback(); - }) - .catch((err) => { - this.removePlaceholderNotes(); - this.isReplying = true; - this.$nextTick(() => { - const msg = `Your comment could not be submitted! + this.saveNote(replyData) + .then(() => { + this.resetAutoSave(); + callback(); + }) + .catch(err => { + this.removePlaceholderNotes(); + this.isReplying = true; + this.$nextTick(() => { + const msg = `Your comment could not be submitted! Please check your network connection and try again.`; - Flash(msg, 'alert', this.$el); - this.$refs.noteForm.note = noteText; - callback(err); - }); + Flash(msg, 'alert', this.$el); + this.$refs.noteForm.note = noteText; + callback(err); }); - }, - jumpToDiscussion() { - const unresolvedIds = this.unresolvedDiscussions.map(d => d.id); - const index = unresolvedIds.indexOf(this.note.id); + }); + }, + jumpToDiscussion() { + const unresolvedIds = this.unresolvedDiscussions.map(d => d.id); + const index = unresolvedIds.indexOf(this.note.id); - if (index >= 0 && index !== unresolvedIds.length) { - const nextId = unresolvedIds[index + 1]; - const el = document.querySelector(`[data-discussion-id="${nextId}"]`); + if (index >= 0 && index !== unresolvedIds.length) { + const nextId = unresolvedIds[index + 1]; + const el = document.querySelector(`[data-discussion-id="${nextId}"]`); - if (el) { - scrollToElement(el); - } + if (el) { + scrollToElement(el); } - }, + } }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 6d5501d7d98..3554027d2b4 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -1,152 +1,152 @@ <script> - import $ from 'jquery'; - import { mapGetters, mapActions } from 'vuex'; - import { escape } from 'underscore'; - import Flash from '../../flash'; - import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; - import noteHeader from './note_header.vue'; - import noteActions from './note_actions.vue'; - import noteBody from './note_body.vue'; - import eventHub from '../event_hub'; - import noteable from '../mixins/noteable'; - import resolvable from '../mixins/resolvable'; +import $ from 'jquery'; +import { mapGetters, mapActions } from 'vuex'; +import { escape } from 'underscore'; +import Flash from '../../flash'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import noteHeader from './note_header.vue'; +import noteActions from './note_actions.vue'; +import noteBody from './note_body.vue'; +import eventHub from '../event_hub'; +import noteable from '../mixins/noteable'; +import resolvable from '../mixins/resolvable'; - export default { - components: { - userAvatarLink, - noteHeader, - noteActions, - noteBody, +export default { + components: { + userAvatarLink, + noteHeader, + noteActions, + noteBody, + }, + mixins: [noteable, resolvable], + props: { + note: { + type: Object, + required: true, }, - mixins: [ - noteable, - resolvable, - ], - props: { - note: { - type: Object, - required: true, - }, + }, + data() { + return { + isEditing: false, + isDeleting: false, + isRequesting: false, + isResolving: false, + }; + }, + computed: { + ...mapGetters(['targetNoteHash', 'getUserData']), + author() { + return this.note.author; }, - data() { + classNameBindings() { return { - isEditing: false, - isDeleting: false, - isRequesting: false, - isResolving: false, + 'is-editing': this.isEditing && !this.isRequesting, + 'is-requesting being-posted': this.isRequesting, + 'disabled-content': this.isDeleting, + target: this.targetNoteHash === this.noteAnchorId, }; }, - computed: { - ...mapGetters([ - 'targetNoteHash', - 'getUserData', - ]), - author() { - return this.note.author; - }, - classNameBindings() { - return { - 'is-editing': this.isEditing && !this.isRequesting, - 'is-requesting being-posted': this.isRequesting, - 'disabled-content': this.isDeleting, - target: this.targetNoteHash === this.noteAnchorId, - }; - }, - canReportAsAbuse() { - return this.note.report_abuse_path && this.author.id !== this.getUserData.id; - }, - noteAnchorId() { - return `note_${this.note.id}`; - }, + canReportAsAbuse() { + return ( + this.note.report_abuse_path && this.author.id !== this.getUserData.id + ); }, - - created() { - eventHub.$on('enterEditMode', ({ noteId }) => { - if (noteId === this.note.id) { - this.isEditing = true; - this.scrollToNoteIfNeeded($(this.$el)); - } - }); + noteAnchorId() { + return `note_${this.note.id}`; }, + }, - methods: { - ...mapActions([ - 'deleteNote', - 'updateNote', - 'toggleResolveNote', - 'scrollToNoteIfNeeded', - ]), - editHandler() { + created() { + eventHub.$on('enterEditMode', ({ noteId }) => { + if (noteId === this.note.id) { this.isEditing = true; - }, - deleteHandler() { - // eslint-disable-next-line no-alert - if (confirm('Are you sure you want to delete this comment?')) { - this.isDeleting = true; + this.scrollToNoteIfNeeded($(this.$el)); + } + }); + }, - this.deleteNote(this.note) - .then(() => { - this.isDeleting = false; - }) - .catch(() => { - Flash('Something went wrong while deleting your note. Please try again.'); - this.isDeleting = false; - }); - } - }, - formUpdateHandler(noteText, parentElement, callback) { - const data = { - endpoint: this.note.path, - note: { - target_type: this.noteableType, - target_id: this.note.noteable_id, - note: { note: noteText }, - }, - }; - this.isRequesting = true; - this.oldContent = this.note.note_html; - this.note.note_html = escape(noteText); + methods: { + ...mapActions([ + 'deleteNote', + 'updateNote', + 'toggleResolveNote', + 'scrollToNoteIfNeeded', + ]), + editHandler() { + this.isEditing = true; + }, + deleteHandler() { + // eslint-disable-next-line no-alert + if (confirm('Are you sure you want to delete this comment?')) { + this.isDeleting = true; - this.updateNote(data) + this.deleteNote(this.note) .then(() => { - this.isEditing = false; - this.isRequesting = false; - this.oldContent = null; - $(this.$refs.noteBody.$el).renderGFM(); - this.$refs.noteBody.resetAutoSave(); - callback(); + this.isDeleting = false; }) .catch(() => { - this.isRequesting = false; - this.isEditing = true; - this.$nextTick(() => { - const msg = 'Something went wrong while editing your comment. Please try again.'; - Flash(msg, 'alert', this.$el); - this.recoverNoteContent(noteText); - callback(); - }); + Flash( + 'Something went wrong while deleting your note. Please try again.', + ); + this.isDeleting = false; }); - }, - formCancelHandler(shouldConfirm, isDirty) { - if (shouldConfirm && isDirty) { - // eslint-disable-next-line no-alert - if (!confirm('Are you sure you want to cancel editing this comment?')) return; - } - this.$refs.noteBody.resetAutoSave(); - if (this.oldContent) { - this.note.note_html = this.oldContent; + } + }, + formUpdateHandler(noteText, parentElement, callback) { + const data = { + endpoint: this.note.path, + note: { + target_type: this.noteableType, + target_id: this.note.noteable_id, + note: { note: noteText }, + }, + }; + this.isRequesting = true; + this.oldContent = this.note.note_html; + this.note.note_html = escape(noteText); + + this.updateNote(data) + .then(() => { + this.isEditing = false; + this.isRequesting = false; this.oldContent = null; - } - this.isEditing = false; - }, - recoverNoteContent(noteText) { - // we need to do this to prevent noteForm inconsistent content warning - // this is something we intentionally do so we need to recover the content - this.note.note = noteText; - this.$refs.noteBody.$refs.noteForm.note.note = noteText; - }, + $(this.$refs.noteBody.$el).renderGFM(); + this.$refs.noteBody.resetAutoSave(); + callback(); + }) + .catch(() => { + this.isRequesting = false; + this.isEditing = true; + this.$nextTick(() => { + const msg = + 'Something went wrong while editing your comment. Please try again.'; + Flash(msg, 'alert', this.$el); + this.recoverNoteContent(noteText); + callback(); + }); + }); + }, + formCancelHandler(shouldConfirm, isDirty) { + if (shouldConfirm && isDirty) { + // eslint-disable-next-line no-alert + if (!confirm('Are you sure you want to cancel editing this comment?')) + return; + } + this.$refs.noteBody.resetAutoSave(); + if (this.oldContent) { + this.note.note_html = this.oldContent; + this.oldContent = null; + } + this.isEditing = false; + }, + recoverNoteContent(noteText) { + // we need to do this to prevent noteForm inconsistent content warning + // this is something we intentionally do so we need to recover the content + this.note.note = noteText; + this.$refs.noteBody.$refs.noteForm.note.note = noteText; }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index c97472c879c..a90c6d6381d 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,160 +1,162 @@ <script> - import $ from 'jquery'; - import { mapGetters, mapActions } from 'vuex'; - import { getLocationHash } from '../../lib/utils/url_utility'; - import Flash from '../../flash'; - import store from '../stores/'; - import * as constants from '../constants'; - import noteableNote from './noteable_note.vue'; - import noteableDiscussion from './noteable_discussion.vue'; - import systemNote from '../../vue_shared/components/notes/system_note.vue'; - import commentForm from './comment_form.vue'; - import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; - import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; +import $ from 'jquery'; +import { mapGetters, mapActions } from 'vuex'; +import { getLocationHash } from '../../lib/utils/url_utility'; +import Flash from '../../flash'; +import store from '../stores/'; +import * as constants from '../constants'; +import noteableNote from './noteable_note.vue'; +import noteableDiscussion from './noteable_discussion.vue'; +import systemNote from '../../vue_shared/components/notes/system_note.vue'; +import commentForm from './comment_form.vue'; +import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; +import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; - export default { - name: 'NotesApp', - components: { - noteableNote, - noteableDiscussion, - systemNote, - commentForm, - loadingIcon, - placeholderNote, - placeholderSystemNote, +export default { + name: 'NotesApp', + components: { + noteableNote, + noteableDiscussion, + systemNote, + commentForm, + loadingIcon, + placeholderNote, + placeholderSystemNote, + }, + props: { + noteableData: { + type: Object, + required: true, }, - props: { - noteableData: { - type: Object, - required: true, - }, - notesData: { - type: Object, - required: true, - }, - userData: { - type: Object, - required: false, - default: () => ({}), - }, + notesData: { + type: Object, + required: true, }, - store, - data() { - return { - isLoading: true, - }; + userData: { + type: Object, + required: false, + default: () => ({}), }, - computed: { - ...mapGetters([ - 'notes', - 'getNotesDataByProp', - 'discussionCount', - ]), - noteableType() { - // FIXME -- @fatihacet Get this from JSON data. - const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants; + }, + store, + data() { + return { + isLoading: true, + }; + }, + computed: { + ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']), + noteableType() { + // FIXME -- @fatihacet Get this from JSON data. + const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants; - return this.noteableData.merge_params ? MERGE_REQUEST_NOTEABLE_TYPE : ISSUE_NOTEABLE_TYPE; - }, - allNotes() { - if (this.isLoading) { - const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0; - - return new Array(totalNotes).fill({ - isSkeletonNote: true, - }); - } - return this.notes; - }, - }, - created() { - this.setNotesData(this.notesData); - this.setNoteableData(this.noteableData); - this.setUserData(this.userData); + return this.noteableData.merge_params + ? MERGE_REQUEST_NOTEABLE_TYPE + : ISSUE_NOTEABLE_TYPE; }, - mounted() { - this.fetchNotes(); + allNotes() { + if (this.isLoading) { + const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0; - const parentElement = this.$el.parentElement; - - if (parentElement && - parentElement.classList.contains('js-vue-notes-event')) { - parentElement.addEventListener('toggleAward', (event) => { - const { awardName, noteId } = event.detail; - this.actionToggleAward({ awardName, noteId }); + return new Array(totalNotes).fill({ + isSkeletonNote: true, }); } - document.addEventListener('refreshVueNotes', this.fetchNotes); - }, - beforeDestroy() { - document.removeEventListener('refreshVueNotes', this.fetchNotes); + return this.notes; }, - methods: { - ...mapActions({ - actionFetchNotes: 'fetchNotes', - poll: 'poll', - actionToggleAward: 'toggleAward', - scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', - setNotesData: 'setNotesData', - setNoteableData: 'setNoteableData', - setUserData: 'setUserData', - setLastFetchedAt: 'setLastFetchedAt', - setTargetNoteHash: 'setTargetNoteHash', - }), - getComponentName(note) { - if (note.isSkeletonNote) { - return skeletonLoadingContainer; - } - if (note.isPlaceholderNote) { - if (note.placeholderType === constants.SYSTEM_NOTE) { - return placeholderSystemNote; - } - return placeholderNote; - } else if (note.individual_note) { - return note.notes[0].system ? systemNote : noteableNote; - } + }, + created() { + this.setNotesData(this.notesData); + this.setNoteableData(this.noteableData); + this.setUserData(this.userData); + }, + mounted() { + this.fetchNotes(); + + const parentElement = this.$el.parentElement; - return noteableDiscussion; - }, - getComponentData(note) { - return note.individual_note ? note.notes[0] : note; - }, - fetchNotes() { - return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath')) - .then(() => this.initPolling()) - .then(() => { - this.isLoading = false; - }) - .then(() => this.$nextTick()) - .then(() => this.checkLocationHash()) - .catch(() => { - this.isLoading = false; - Flash('Something went wrong while fetching comments. Please try again.'); - }); - }, - initPolling() { - if (this.isPollingInitialized) { - return; + if ( + parentElement && + parentElement.classList.contains('js-vue-notes-event') + ) { + parentElement.addEventListener('toggleAward', event => { + const { awardName, noteId } = event.detail; + this.actionToggleAward({ awardName, noteId }); + }); + } + document.addEventListener('refreshVueNotes', this.fetchNotes); + }, + beforeDestroy() { + document.removeEventListener('refreshVueNotes', this.fetchNotes); + }, + methods: { + ...mapActions({ + actionFetchNotes: 'fetchNotes', + poll: 'poll', + actionToggleAward: 'toggleAward', + scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', + setNotesData: 'setNotesData', + setNoteableData: 'setNoteableData', + setUserData: 'setUserData', + setLastFetchedAt: 'setLastFetchedAt', + setTargetNoteHash: 'setTargetNoteHash', + }), + getComponentName(note) { + if (note.isSkeletonNote) { + return skeletonLoadingContainer; + } + if (note.isPlaceholderNote) { + if (note.placeholderType === constants.SYSTEM_NOTE) { + return placeholderSystemNote; } + return placeholderNote; + } else if (note.individual_note) { + return note.notes[0].system ? systemNote : noteableNote; + } - this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); + return noteableDiscussion; + }, + getComponentData(note) { + return note.individual_note ? note.notes[0] : note; + }, + fetchNotes() { + return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath')) + .then(() => this.initPolling()) + .then(() => { + this.isLoading = false; + }) + .then(() => this.$nextTick()) + .then(() => this.checkLocationHash()) + .catch(() => { + this.isLoading = false; + Flash( + 'Something went wrong while fetching comments. Please try again.', + ); + }); + }, + initPolling() { + if (this.isPollingInitialized) { + return; + } - this.poll(); - this.isPollingInitialized = true; - }, - checkLocationHash() { - const hash = getLocationHash(); - const element = document.getElementById(hash); + this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); - if (hash && element) { - this.setTargetNoteHash(hash); - this.scrollToNoteIfNeeded($(element)); - } - }, + this.poll(); + this.isPollingInitialized = true; + }, + checkLocationHash() { + const hash = getLocationHash(); + const element = document.getElementById(hash); + + if (hash && element) { + this.setTargetNoteHash(hash); + this.scrollToNoteIfNeeded($(element)); + } }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 545bf2c99a7..f90775d0157 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,35 +1,43 @@ import Vue from 'vue'; import notesApp from './components/notes_app.vue'; -document.addEventListener('DOMContentLoaded', () => new Vue({ - el: '#js-vue-notes', - components: { - notesApp, - }, - data() { - const notesDataset = document.getElementById('js-vue-notes').dataset; - const parsedUserData = JSON.parse(notesDataset.currentUserData); - const currentUserData = parsedUserData ? { - id: parsedUserData.id, - name: parsedUserData.name, - username: parsedUserData.username, - avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, - path: parsedUserData.path, - } : {}; +document.addEventListener( + 'DOMContentLoaded', + () => + new Vue({ + el: '#js-vue-notes', + components: { + notesApp, + }, + data() { + const notesDataset = document.getElementById('js-vue-notes').dataset; + const parsedUserData = JSON.parse(notesDataset.currentUserData); + let currentUserData = {}; + + if (parsedUserData) { + currentUserData = { + id: parsedUserData.id, + name: parsedUserData.name, + username: parsedUserData.username, + avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, + path: parsedUserData.path, + }; + } - return { - noteableData: JSON.parse(notesDataset.noteableData), - currentUserData, - notesData: JSON.parse(notesDataset.notesData), - }; - }, - render(createElement) { - return createElement('notes-app', { - props: { - noteableData: this.noteableData, - notesData: this.notesData, - userData: this.currentUserData, + return { + noteableData: JSON.parse(notesDataset.noteableData), + currentUserData, + notesData: JSON.parse(notesDataset.notesData), + }; + }, + render(createElement) { + return createElement('notes-app', { + props: { + noteableData: this.noteableData, + notesData: this.notesData, + userData: this.currentUserData, + }, + }); }, - }); - }, -})); + }), +); diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index 837a4029346..3dff715905f 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -5,7 +5,11 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; export default { methods: { initAutoSave(noteableType) { - this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', capitalizeFirstCharacter(noteableType), this.note.id]); + this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [ + 'Note', + capitalizeFirstCharacter(noteableType), + this.note.id, + ]); }, resetAutoSave() { this.autosave.reset(); diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index ab1ae115e52..f79049b85f6 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -12,7 +12,8 @@ export default { discussionResolved() { const { notes, resolved } = this.note; - if (notes) { // Decide resolved state using store. Only valid for discussions. + if (notes) { + // Decide resolved state using store. Only valid for discussions. return notes.every(note => note.resolved && !note.system); } @@ -26,7 +27,9 @@ export default { return __('Comment and resolve discussion'); } - return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion'); + return this.discussionResolved + ? __('Unresolve discussion') + : __('Resolve discussion'); }, }, methods: { @@ -42,7 +45,9 @@ export default { }) .catch(() => { this.isResolving = false; - const msg = __('Something went wrong while resolving this discussion. Please try again.'); + const msg = __( + 'Something went wrong while resolving this discussion. Please try again.', + ); Flash(msg, 'alert', this.$el); }); }, diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index b4c19a9ec22..7c623aac6ed 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -22,7 +22,9 @@ export default { }, toggleResolveNote(endpoint, isResolved) { const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants; - const method = isResolved ? UNRESOLVE_NOTE_METHOD_NAME : RESOLVE_NOTE_METHOD_NAME; + const method = isResolved + ? UNRESOLVE_NOTE_METHOD_NAME + : RESOLVE_NOTE_METHOD_NAME; return Vue.http[method](endpoint); }, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index dc0e3c39775..244a6980b5a 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -12,86 +12,115 @@ import { isInViewport, scrollToElement } from '../../lib/utils/common_utils'; let eTagPoll; -export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data); -export const setNoteableData = ({ commit }, data) => commit(types.SET_NOTEABLE_DATA, data); -export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data); -export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data); -export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data); -export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data); -export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); - -export const fetchNotes = ({ commit }, path) => service - .fetchNotes(path) - .then(res => res.json()) - .then((res) => { - commit(types.SET_INITIAL_NOTES, res); - }); +export const setNotesData = ({ commit }, data) => + commit(types.SET_NOTES_DATA, data); +export const setNoteableData = ({ commit }, data) => + commit(types.SET_NOTEABLE_DATA, data); +export const setUserData = ({ commit }, data) => + commit(types.SET_USER_DATA, data); +export const setLastFetchedAt = ({ commit }, data) => + commit(types.SET_LAST_FETCHED_AT, data); +export const setInitialNotes = ({ commit }, data) => + commit(types.SET_INITIAL_NOTES, data); +export const setTargetNoteHash = ({ commit }, data) => + commit(types.SET_TARGET_NOTE_HASH, data); +export const toggleDiscussion = ({ commit }, data) => + commit(types.TOGGLE_DISCUSSION, data); + +export const fetchNotes = ({ commit }, path) => + service + .fetchNotes(path) + .then(res => res.json()) + .then(res => { + commit(types.SET_INITIAL_NOTES, res); + }); -export const deleteNote = ({ commit }, note) => service - .deleteNote(note.path) - .then(() => { +export const deleteNote = ({ commit }, note) => + service.deleteNote(note.path).then(() => { commit(types.DELETE_NOTE, note); }); -export const updateNote = ({ commit }, { endpoint, note }) => service - .updateNote(endpoint, note) - .then(res => res.json()) - .then((res) => { - commit(types.UPDATE_NOTE, res); - }); +export const updateNote = ({ commit }, { endpoint, note }) => + service + .updateNote(endpoint, note) + .then(res => res.json()) + .then(res => { + commit(types.UPDATE_NOTE, res); + }); -export const replyToDiscussion = ({ commit }, { endpoint, data }) => service - .replyToDiscussion(endpoint, data) - .then(res => res.json()) - .then((res) => { - commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); +export const replyToDiscussion = ({ commit }, { endpoint, data }) => + service + .replyToDiscussion(endpoint, data) + .then(res => res.json()) + .then(res => { + commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); - return res; - }); + return res; + }); -export const createNewNote = ({ commit }, { endpoint, data }) => service - .createNewNote(endpoint, data) - .then(res => res.json()) - .then((res) => { - if (!res.errors) { - commit(types.ADD_NEW_NOTE, res); - } - return res; - }); +export const createNewNote = ({ commit }, { endpoint, data }) => + service + .createNewNote(endpoint, data) + .then(res => res.json()) + .then(res => { + if (!res.errors) { + commit(types.ADD_NEW_NOTE, res); + } + return res; + }); export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES); -export const toggleResolveNote = ({ commit }, { endpoint, isResolved, discussion }) => service - .toggleResolveNote(endpoint, isResolved) - .then(res => res.json()) - .then((res) => { - const mutationType = discussion ? types.UPDATE_DISCUSSION : types.UPDATE_NOTE; +export const toggleResolveNote = ( + { commit }, + { endpoint, isResolved, discussion }, +) => + service + .toggleResolveNote(endpoint, isResolved) + .then(res => res.json()) + .then(res => { + const mutationType = discussion + ? types.UPDATE_DISCUSSION + : types.UPDATE_NOTE; - commit(mutationType, res); - }); + commit(mutationType, res); + }); -export const closeIssue = ({ commit, dispatch, state }) => service - .toggleIssueState(state.notesData.closePath) - .then(res => res.json()) - .then((data) => { - commit(types.CLOSE_ISSUE); - dispatch('emitStateChangedEvent', data); - }); +export const closeIssue = ({ commit, dispatch, state }) => { + dispatch('toggleStateButtonLoading', true); + return service + .toggleIssueState(state.notesData.closePath) + .then(res => res.json()) + .then(data => { + commit(types.CLOSE_ISSUE); + dispatch('emitStateChangedEvent', data); + dispatch('toggleStateButtonLoading', false); + }); +}; -export const reopenIssue = ({ commit, dispatch, state }) => service - .toggleIssueState(state.notesData.reopenPath) - .then(res => res.json()) - .then((data) => { - commit(types.REOPEN_ISSUE); - dispatch('emitStateChangedEvent', data); - }); +export const reopenIssue = ({ commit, dispatch, state }) => { + dispatch('toggleStateButtonLoading', true); + return service + .toggleIssueState(state.notesData.reopenPath) + .then(res => res.json()) + .then(data => { + commit(types.REOPEN_ISSUE); + dispatch('emitStateChangedEvent', data); + dispatch('toggleStateButtonLoading', false); + }); +}; + +export const toggleStateButtonLoading = ({ commit }, value) => + commit(types.TOGGLE_STATE_BUTTON_LOADING, value); export const emitStateChangedEvent = ({ commit, getters }, data) => { - const event = new CustomEvent('issuable_vue_app:change', { detail: { - data, - isClosed: getters.openState === constants.CLOSED, - } }); + const event = new CustomEvent('issuable_vue_app:change', { + detail: { + data, + isClosed: getters.openState === constants.CLOSED, + }, + }); document.dispatchEvent(event); }; @@ -133,59 +162,70 @@ export const saveNote = ({ commit, dispatch }, noteData) => { }); } - return dispatch(methodToDispatch, noteData) - .then((res) => { - const { errors } = res; - const commandsChanges = res.commands_changes; + return dispatch(methodToDispatch, noteData).then(res => { + const { errors } = res; + const commandsChanges = res.commands_changes; - if (hasQuickActions && errors && Object.keys(errors).length) { - eTagPoll.makeRequest(); + if (hasQuickActions && errors && Object.keys(errors).length) { + eTagPoll.makeRequest(); - $('.js-gfm-input').trigger('clear-commands-cache.atwho'); - Flash('Commands applied', 'notice', noteData.flashContainer); - } - - if (commandsChanges) { - if (commandsChanges.emoji_award) { - const votesBlock = $('.js-awards-block').eq(0); - - loadAwardsHandler() - .then((awardsHandler) => { - awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award); - awardsHandler.scrollToAwards(); - }) - .catch(() => { - Flash( - 'Something went wrong while adding your award. Please try again.', - 'alert', - noteData.flashContainer, - ); - }); - } + $('.js-gfm-input').trigger('clear-commands-cache.atwho'); + Flash('Commands applied', 'notice', noteData.flashContainer); + } - if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) { - sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res); - } + if (commandsChanges) { + if (commandsChanges.emoji_award) { + const votesBlock = $('.js-awards-block').eq(0); + + loadAwardsHandler() + .then(awardsHandler => { + awardsHandler.addAwardToEmojiBar( + votesBlock, + commandsChanges.emoji_award, + ); + awardsHandler.scrollToAwards(); + }) + .catch(() => { + Flash( + 'Something went wrong while adding your award. Please try again.', + 'alert', + noteData.flashContainer, + ); + }); } - if (errors && errors.commands_only) { - Flash(errors.commands_only, 'notice', noteData.flashContainer); + if ( + commandsChanges.spend_time != null || + commandsChanges.time_estimate != null + ) { + sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res); } - commit(types.REMOVE_PLACEHOLDER_NOTES); + } - return res; - }); + if (errors && errors.commands_only) { + Flash(errors.commands_only, 'notice', noteData.flashContainer); + } + commit(types.REMOVE_PLACEHOLDER_NOTES); + + return res; + }); }; const pollSuccessCallBack = (resp, commit, state, getters) => { if (resp.notes && resp.notes.length) { const { notesById } = getters; - resp.notes.forEach((note) => { + resp.notes.forEach(note => { if (notesById[note.id]) { commit(types.UPDATE_NOTE, note); - } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { - const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); + } else if ( + note.type === constants.DISCUSSION_NOTE || + note.type === constants.DIFF_NOTE + ) { + const discussion = utils.findNoteObjectById( + state.notes, + note.discussion_id, + ); if (discussion) { commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); @@ -208,9 +248,12 @@ export const poll = ({ commit, state, getters }) => { resource: service, method: 'poll', data: state, - successCallback: resp => resp.json() - .then(data => pollSuccessCallBack(data, commit, state, getters)), - errorCallback: () => Flash('Something went wrong while fetching latest comments.'), + successCallback: resp => + resp + .json() + .then(data => pollSuccessCallBack(data, commit, state, getters)), + errorCallback: () => + Flash('Something went wrong while fetching latest comments.'), }); if (!Visibility.hidden()) { @@ -237,15 +280,22 @@ export const restartPolling = () => { }; export const fetchData = ({ commit, state, getters }) => { - const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt }; + const requestData = { + endpoint: state.notesData.notesPath, + lastFetchedAt: state.lastFetchedAt, + }; - service.poll(requestData) + service + .poll(requestData) .then(resp => resp.json) .then(data => pollSuccessCallBack(data, commit, state, getters)) .catch(() => Flash('Something went wrong while fetching latest comments.')); }; -export const toggleAward = ({ commit, state, getters, dispatch }, { awardName, noteId }) => { +export const toggleAward = ( + { commit, state, getters, dispatch }, + { awardName, noteId }, +) => { commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] }); }; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index e6180101c58..f89591a54d6 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -11,27 +11,31 @@ export const getNoteableDataByProp = state => prop => state.noteableData[prop]; export const openState = state => state.noteableData.state; export const getUserData = state => state.userData || {}; -export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; +export const getUserDataByProp = state => prop => + state.userData && state.userData[prop]; -export const notesById = state => state.notes.reduce((acc, note) => { - note.notes.every(n => Object.assign(acc, { [n.id]: n })); - return acc; -}, {}); +export const notesById = state => + state.notes.reduce((acc, note) => { + note.notes.every(n => Object.assign(acc, { [n.id]: n })); + return acc; + }, {}); const reverseNotes = array => array.slice(0).reverse(); -const isLastNote = (note, state) => !note.system && - state.userData && note.author && +const isLastNote = (note, state) => + !note.system && + state.userData && + note.author && note.author.id === state.userData.id; -export const getCurrentUserLastNote = state => _.flatten( - reverseNotes(state.notes) - .map(note => reverseNotes(note.notes)), +export const getCurrentUserLastNote = state => + _.flatten( + reverseNotes(state.notes).map(note => reverseNotes(note.notes)), ).find(el => isLastNote(el, state)); -export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes) - .find(el => isLastNote(el, state)); +export const getDiscussionLastNote = state => discussion => + reverseNotes(discussion.notes).find(el => isLastNote(el, state)); -export const discussionCount = (state) => { +export const discussionCount = state => { const discussions = state.notes.filter(n => !n.individual_note); return discussions.length; @@ -43,10 +47,10 @@ export const unresolvedDiscussions = (state, getters) => { return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]); }; -export const resolvedDiscussionsById = (state) => { +export const resolvedDiscussionsById = state => { const map = {}; - state.notes.forEach((n) => { + state.notes.forEach(n => { if (n.notes) { const resolved = n.notes.every(note => note.resolved && !note.system); diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js index 488a9ca38d3..9ed19bf171e 100644 --- a/app/assets/javascripts/notes/stores/index.js +++ b/app/assets/javascripts/notes/stores/index.js @@ -12,6 +12,9 @@ export default new Vuex.Store({ targetNoteHash: null, lastFetchedAt: null, + // View layer + isToggleStateButtonLoading: false, + // holds endpoints and permissions provided through haml notesData: {}, userData: {}, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index da1b5a9e51a..b455e23ecde 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -17,3 +17,4 @@ export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const REOPEN_ISSUE = 'REOPEN_ISSUE'; +export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 949628a65c0..c8edc06349f 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -7,7 +7,7 @@ export default { [types.ADD_NEW_NOTE](state, note) { const { discussion_id, type } = note; const [exists] = state.notes.filter(n => n.id === note.discussion_id); - const isDiscussion = (type === constants.DISCUSSION_NOTE); + const isDiscussion = type === constants.DISCUSSION_NOTE; if (!exists) { const noteData = { @@ -63,13 +63,15 @@ export default { const note = notes[i]; const children = note.notes; - if (children.length && !note.individual_note) { // remove placeholder from discussions + if (children.length && !note.individual_note) { + // remove placeholder from discussions for (let j = children.length - 1; j >= 0; j -= 1) { if (children[j].isPlaceholderNote) { children.splice(j, 1); } } - } else if (note.isPlaceholderNote) { // remove placeholders from state root + } else if (note.isPlaceholderNote) { + // remove placeholders from state root notes.splice(i, 1); } } @@ -89,10 +91,10 @@ export default { [types.SET_INITIAL_NOTES](state, notesData) { const notes = []; - notesData.forEach((note) => { + notesData.forEach(note => { // To support legacy notes, should be very rare case. if (note.individual_note && note.notes.length > 1) { - note.notes.forEach((n) => { + note.notes.forEach(n => { notes.push({ ...note, notes: [n], // override notes array to only have one item to mimick individual_note @@ -103,7 +105,7 @@ export default { notes.push({ ...note, - expanded: (oldNote ? oldNote.expanded : note.expanded), + expanded: oldNote ? oldNote.expanded : note.expanded, }); } }); @@ -128,7 +130,9 @@ export default { notesArr.push({ individual_note: true, isPlaceholderNote: true, - placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE, + placeholderType: data.isSystemNote + ? constants.SYSTEM_NOTE + : constants.NOTE, notes: [ { body: data.noteBody, @@ -141,12 +145,16 @@ export default { const { awardName, note } = data; const { id, name, username } = state.userData; - const hasEmojiAwardedByCurrentUser = note.award_emoji - .filter(emoji => emoji.name === data.awardName && emoji.user.id === id); + const hasEmojiAwardedByCurrentUser = note.award_emoji.filter( + emoji => emoji.name === data.awardName && emoji.user.id === id, + ); if (hasEmojiAwardedByCurrentUser.length) { // If current user has awarded this emoji, remove it. - note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1); + note.award_emoji.splice( + note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), + 1, + ); } else { note.award_emoji.push({ name: awardName, @@ -199,4 +207,8 @@ export default { [types.REOPEN_ISSUE](state) { Object.assign(state.noteableData, { state: constants.REOPENED }); }, + + [types.TOGGLE_STATE_BUTTON_LOADING](state, value) { + Object.assign(state, { isToggleStateButtonLoading: value }); + }, }; diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index 275263a2aaa..a0e096ebfaf 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -2,13 +2,15 @@ import AjaxCache from '~/lib/utils/ajax_cache'; const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; -export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0]; +export const findNoteObjectById = (notes, id) => + notes.filter(n => n.id === id)[0]; -export const getQuickActionText = (note) => { +export const getQuickActionText = note => { let text = 'Applying command'; - const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || []; + const quickActions = + AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || []; - const executedCommands = quickActions.filter((command) => { + const executedCommands = quickActions.filter(command => { const commandRegex = new RegExp(`/${command.name}`); return commandRegex.test(note); }); @@ -27,4 +29,5 @@ export const getQuickActionText = (note) => { export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); -export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); +export const stripQuickActions = note => + note.replace(REGEX_QUICK_ACTIONS, '').trim(); diff --git a/app/assets/javascripts/pages/ci/lints/create/index.js b/app/assets/javascripts/pages/ci/lints/new/index.js index 8e8a843da0b..8e8a843da0b 100644 --- a/app/assets/javascripts/pages/ci/lints/create/index.js +++ b/app/assets/javascripts/pages/ci/lints/new/index.js diff --git a/app/assets/javascripts/pages/profiles/notifications/show/index.js b/app/assets/javascripts/pages/profiles/notifications/show/index.js new file mode 100644 index 00000000000..2e24a10fa5c --- /dev/null +++ b/app/assets/javascripts/pages/profiles/notifications/show/index.js @@ -0,0 +1,7 @@ +import NotificationsForm from '../../../../notifications_form'; +import notificationsDropdown from '../../../../notifications_dropdown'; + +document.addEventListener('DOMContentLoaded', () => { + new NotificationsForm(); // eslint-disable-line no-new + notificationsDropdown(); +}); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 26cbb279d4a..85c6862d629 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -1,7 +1,29 @@ +import Vue from 'vue'; +import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import BlobViewer from '~/blob/viewer/index'; import initBlob from '~/pages/projects/init_blob'; document.addEventListener('DOMContentLoaded', () => { new BlobViewer(); // eslint-disable-line no-new initBlob(); + + const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status'); + const statusLink = document.querySelector('.commit-actions .ci-status-link'); + if (statusLink) { + statusLink.remove(); + // eslint-disable-next-line no-new + new Vue({ + el: CommitPipelineStatusEl, + components: { + commitPipelineStatus, + }, + render(createElement) { + return createElement('commit-pipeline-status', { + props: { + endpoint: CommitPipelineStatusEl.dataset.endpoint, + }, + }); + }, + }); + } }); diff --git a/app/assets/javascripts/performance_bar.js b/app/assets/javascripts/performance_bar.js index ef44e2323ef..c22598ee665 100644 --- a/app/assets/javascripts/performance_bar.js +++ b/app/assets/javascripts/performance_bar.js @@ -14,8 +14,6 @@ export default class PerformanceBar { init(opts) { const $container = $(opts.container); - this.$sqlProfileLink = $container.find('.js-toggle-modal-peek-sql'); - this.$sqlProfileModal = $container.find('#modal-peek-pg-queries'); this.$lineProfileLink = $container.find('.js-toggle-modal-peek-line-profile'); this.$lineProfileModal = $('#modal-peek-line-profile'); this.initEventListeners(); @@ -23,7 +21,6 @@ export default class PerformanceBar { } initEventListeners() { - this.$sqlProfileLink.on('click', () => this.handleSQLProfileLink()); this.$lineProfileLink.on('click', e => this.handleLineProfileLink(e)); $(document).on('click', '.js-lineprof-file', PerformanceBar.toggleLineProfileFile); } @@ -36,10 +33,6 @@ export default class PerformanceBar { } } - handleSQLProfileLink() { - PerformanceBar.toggleModal(this.$sqlProfileModal); - } - handleLineProfileLink(e) { const lineProfilerParameter = getParameterValues('lineprofiler'); const lineProfilerParameterRegex = new RegExp(`lineprofiler=${lineProfilerParameter[0]}`); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue new file mode 100644 index 00000000000..7bef2e97349 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -0,0 +1,144 @@ +<script> +import timeagoMixin from '../../vue_shared/mixins/timeago'; +import tooltip from '../../vue_shared/directives/tooltip'; +import LoadingButton from '../../vue_shared/components/loading_button.vue'; +import { visitUrl } from '../../lib/utils/url_utility'; +import createFlash from '../../flash'; +import MemoryUsage from './memory_usage.vue'; +import StatusIcon from './mr_widget_status_icon.vue'; +import MRWidgetService from '../services/mr_widget_service'; + +export default { + name: 'Deployment', + components: { + LoadingButton, + MemoryUsage, + StatusIcon, + }, + directives: { + tooltip, + }, + mixins: [ + timeagoMixin, + ], + props: { + deployment: { + type: Object, + required: true, + }, + }, + data() { + return { + isStopping: false, + }; + }, + computed: { + deployTimeago() { + return this.timeFormated(this.deployment.deployed_at); + }, + hasExternalUrls() { + return !!(this.deployment.external_url && this.deployment.external_url_formatted); + }, + hasDeploymentTime() { + return !!(this.deployment.deployed_at && this.deployment.deployed_at_formatted); + }, + hasDeploymentMeta() { + return !!(this.deployment.url && this.deployment.name); + }, + hasMetrics() { + return !!(this.deployment.metrics_url); + }, + }, + methods: { + stopEnvironment() { + const msg = 'Are you sure you want to stop this environment?'; + const isConfirmed = confirm(msg); // eslint-disable-line + + if (isConfirmed) { + this.isStopping = true; + + MRWidgetService.stopEnvironment(this.deployment.stop_url) + .then(res => res.data) + .then((data) => { + if (data.redirect_url) { + visitUrl(data.redirect_url); + } + + this.isStopping = false; + }) + .catch(() => { + createFlash('Something went wrong while stopping this environment. Please try again.'); + this.isStopping = false; + }); + } + }, + }, +}; +</script> + +<template> + <div class="mr-widget-heading deploy-heading"> + <div class="ci-widget media"> + <div class="ci-status-icon ci-status-icon-success"> + <span class="js-icon-link icon-link"> + <status-icon status="success" /> + </span> + </div> + <div class="media-body"> + <div class="deploy-body"> + <template v-if="hasDeploymentMeta"> + <span> + Deployed to + </span> + <a + :href="deployment.url" + target="_blank" + rel="noopener noreferrer nofollow" + class="deploy-link js-deploy-meta" + > + {{ deployment.name }} + </a> + </template> + <template v-if="hasExternalUrls"> + <span> + on + </span> + <a + :href="deployment.external_url" + target="_blank" + rel="noopener noreferrer nofollow" + class="deploy-link js-deploy-url" + > + <i + class="fa fa-external-link" + aria-hidden="true" + > + </i> + {{ deployment.external_url_formatted }} + </a> + </template> + <span + v-if="hasDeploymentTime" + v-tooltip + :title="deployment.deployed_at_formatted" + class="js-deploy-time" + > + {{ deployTimeago }} + </span> + <loading-button + v-if="deployment.stop_url" + container-class="btn btn-default btn-xs prepend-left-default" + label="Stop environment" + :loading="isStopping" + @click="stopEnvironment" + /> + </div> + <memory-usage + v-if="hasMetrics" + :metrics-url="deployment.metrics_url" + :metrics-monitoring-url="deployment.metrics_monitoring_url" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js deleted file mode 100644 index c7f992384c8..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js +++ /dev/null @@ -1,113 +0,0 @@ -import { getTimeago } from '~/lib/utils/datetime_utility'; -import { visitUrl } from '../../lib/utils/url_utility'; -import Flash from '../../flash'; -import MemoryUsage from './memory_usage.vue'; -import StatusIcon from './mr_widget_status_icon.vue'; -import MRWidgetService from '../services/mr_widget_service'; - -export default { - name: 'MRWidgetDeployment', - props: { - mr: { type: Object, required: true }, - service: { type: Object, required: true }, - }, - components: { - MemoryUsage, - StatusIcon, - }, - methods: { - formatDate(date) { - return getTimeago().format(date); - }, - hasExternalUrls(deployment = {}) { - return deployment.external_url && deployment.external_url_formatted; - }, - hasDeploymentTime(deployment = {}) { - return deployment.deployed_at && deployment.deployed_at_formatted; - }, - hasDeploymentMeta(deployment = {}) { - return deployment.url && deployment.name; - }, - stopEnvironment(deployment) { - const msg = 'Are you sure you want to stop this environment?'; - const isConfirmed = confirm(msg); // eslint-disable-line - - if (isConfirmed) { - MRWidgetService.stopEnvironment(deployment.stop_url) - .then(res => res.data) - .then((data) => { - if (data.redirect_url) { - visitUrl(data.redirect_url); - } - }) - .catch(() => { - new Flash('Something went wrong while stopping this environment. Please try again.'); // eslint-disable-line - }); - } - }, - }, - template: ` - <div class="mr-widget-heading deploy-heading"> - <div v-for="deployment in mr.deployments"> - <div class="ci-widget media"> - <div class="ci-status-icon ci-status-icon-success"> - <span class="js-icon-link icon-link"> - <status-icon status="success" /> - </span> - </div> - <div class="media-body space-children"> - <span> - <span - v-if="hasDeploymentMeta(deployment)"> - Deployed to - </span> - <a - v-if="hasDeploymentMeta(deployment)" - :href="deployment.url" - target="_blank" - rel="noopener noreferrer nofollow" - class="js-deploy-meta inline"> - {{deployment.name}} - </a> - <span - v-if="hasExternalUrls(deployment)"> - on - </span> - <a - v-if="hasExternalUrls(deployment)" - :href="deployment.external_url" - target="_blank" - rel="noopener noreferrer nofollow" - class="js-deploy-url inline"> - <i - class="fa fa-external-link" - aria-hidden="true" /> - {{deployment.external_url_formatted}} - </a> - <span - v-if="hasDeploymentTime(deployment)" - :data-title="deployment.deployed_at_formatted" - class="js-deploy-time" - data-toggle="tooltip" - data-placement="top"> - {{formatDate(deployment.deployed_at)}} - </span> - </span> - <button - type="button" - v-if="deployment.stop_url" - @click="stopEnvironment(deployment)" - class="btn btn-default btn-xs"> - Stop environment - </button> - <memory-usage - v-if="deployment.metrics_url" - :metrics-url="deployment.metrics_url" - :metrics-monitoring-url="deployment.metrics_monitoring_url" - /> - </div> - </div> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index 20624aad0ad..efbe1c96d1c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -14,7 +14,7 @@ export { default as SmartInterval } from '~/smart_interval'; export { default as WidgetHeader } from './components/mr_widget_header.vue'; export { default as WidgetMergeHelp } from './components/mr_widget_merge_help.vue'; export { default as WidgetPipeline } from './components/mr_widget_pipeline.vue'; -export { default as WidgetDeployment } from './components/mr_widget_deployment'; +export { default as Deployment } from './components/deployment.vue'; export { default as WidgetMaintainerEdit } from './components/mr_widget_maintainer_edit.vue'; export { default as WidgetRelatedLinks } from './components/mr_widget_related_links.vue'; export { default as MergedState } from './components/states/mr_widget_merged.vue'; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index cc8bc6af1e1..169adfe0a1d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -5,7 +5,7 @@ import { WidgetHeader, WidgetMergeHelp, WidgetPipeline, - WidgetDeployment, + Deployment, WidgetMaintainerEdit, WidgetRelatedLinks, MergedState, @@ -67,9 +67,6 @@ export default { shouldRenderRelatedLinks() { return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState; }, - shouldRenderDeployments() { - return this.mr.deployments.length; - }, shouldRenderSourceBranchRemovalStatus() { return !this.mr.canRemoveSourceBranch && this.mr.shouldRemoveSourceBranch && (!this.mr.isNothingToMergeState && !this.mr.isMergedState); @@ -216,7 +213,7 @@ export default { 'mr-widget-header': WidgetHeader, 'mr-widget-merge-help': WidgetMergeHelp, 'mr-widget-pipeline': WidgetPipeline, - 'mr-widget-deployment': WidgetDeployment, + Deployment, 'mr-widget-maintainer-edit': WidgetMaintainerEdit, 'mr-widget-related-links': WidgetRelatedLinks, 'mr-widget-merged': MergedState, @@ -250,10 +247,11 @@ export default { :ci-status="mr.ciStatus" :has-ci="mr.hasCI" /> - <mr-widget-deployment - v-if="shouldRenderDeployments" - :mr="mr" - :service="service" /> + <deployment + v-for="deployment in mr.deployments" + :key="deployment.id" + :deployment="deployment" + /> <div class="mr-widget-section"> <component :is="componentName" diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index ddd9dbb2be4..e12b5aab381 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -17,8 +17,6 @@ */ @mixin markdown-table { width: auto; - display: block; - overflow-x: auto; } /* diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 8b680c2dc52..b487f6278c2 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -194,8 +194,6 @@ .commit-actions { @media (min-width: $screen-sm-min) { - font-size: 0; - .fa-spinner { font-size: 12px; } @@ -204,7 +202,7 @@ .ci-status-link { display: inline-block; position: relative; - top: 1px; + top: 2px; } .btn-clipboard, @@ -226,7 +224,7 @@ .ci-status-icon { position: relative; - top: 1px; + top: 2px; } } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index f887a11004f..4692d0fb873 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -718,6 +718,8 @@ } .mr-memory-usage { + width: 100%; + p.usage-info-loading .usage-info-load-spinner { margin-right: 10px; font-size: 16px; @@ -727,3 +729,36 @@ .fork-sprite { margin-right: -5px; } + +.deploy-heading { + .media-body { + min-width: 0; + } +} + +.deploy-body { + display: flex; + flex-wrap: wrap; + + @media (min-width: $screen-xs) { + flex-wrap: nowrap; + white-space: nowrap; + } + + > *:not(:last-child) { + margin-right: .3em; + } +} + +.deploy-link { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 100px; + max-width: 150px; + + @media (min-width: $screen-xs) { + min-width: 0; + max-width: 100%; + } +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 3c565837383..085a2e74328 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -16,7 +16,7 @@ ul.notes { .note-created-ago, .note-updated-at { - white-space: nowrap; + white-space: normal; } .discussion-body { diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index e70a57c2a67..9a0ec936979 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -180,6 +180,11 @@ ul.wiki-pages-list.content-list { } } +.wiki-holder { + overflow-x: auto; + overflow-y: hidden; +} + .wiki { table { @include markdown-table; diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index ee507009e50..cba9a53dc4b 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -19,6 +19,12 @@ class Projects::DiscussionsController < Projects::ApplicationController render_discussion end + def show + render json: { + discussion_html: view_to_html_string('discussions/_diff_with_notes', discussion: discussion, expanded: true) + } + end + private def render_discussion diff --git a/app/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb index d6bcd939522..5c507fe8d50 100644 --- a/app/finders/admin/projects_finder.rb +++ b/app/finders/admin/projects_finder.rb @@ -16,8 +16,7 @@ class Admin::ProjectsFinder items = by_archived(items) items = by_personal(items) items = by_name(items) - items = sort(items) - items.includes(:namespace).order("namespaces.path, projects.name ASC").page(params[:page]) + sort(items).page(params[:page]) end private diff --git a/app/helpers/javascript_helper.rb b/app/helpers/javascript_helper.rb index d5e77c7e271..cd4075b340d 100644 --- a/app/helpers/javascript_helper.rb +++ b/app/helpers/javascript_helper.rb @@ -2,9 +2,4 @@ module JavascriptHelper def page_specific_javascript_tag(js) javascript_include_tag asset_path(js) end - - # deprecated; use webpack_bundle_tag directly instead - def page_specific_javascript_bundle_tag(bundle) - webpack_bundle_tag(bundle) - end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 0dee6df525d..3cbbf8b5dfa 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -347,15 +347,15 @@ class ApplicationSetting < ActiveRecord::Base end def home_page_url_column_exists? - ActiveRecord::Base.connection.column_exists?(:application_settings, :home_page_url) + ::Gitlab::Database.cached_column_exists?(:application_settings, :home_page_url) end def help_page_support_url_column_exists? - ActiveRecord::Base.connection.column_exists?(:application_settings, :help_page_support_url) + ::Gitlab::Database.cached_column_exists?(:application_settings, :help_page_support_url) end def sidekiq_throttling_column_exists? - ActiveRecord::Base.connection.column_exists?(:application_settings, :sidekiq_throttling_enabled) + ::Gitlab::Database.cached_column_exists?(:application_settings, :sidekiq_throttling_enabled) end def domain_whitelist_raw diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index b230b7f47ef..f8a3600e863 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -252,23 +252,23 @@ module Ci # All variables, including those dependent on environment, which could # contain unexpanded variables. def variables(environment: persisted_environment) - variables = predefined_variables - variables += project.predefined_variables - variables += pipeline.predefined_variables - variables += runner.predefined_variables if runner - variables += project.container_registry_variables - variables += project.deployment_variables if has_environment? - variables += project.auto_devops_variables - variables += yaml_variables - variables += user_variables - variables += project.group.secret_variables_for(ref, project).map(&:to_runner_variable) if project.group - variables += secret_variables(environment: environment) - variables += trigger_request.user_variables if trigger_request - variables += pipeline.variables.map(&:to_runner_variable) - variables += pipeline.pipeline_schedule.job_variables if pipeline.pipeline_schedule - variables += persisted_environment_variables if environment - - variables + collection = Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.concat(predefined_variables) + variables.concat(project.predefined_variables) + variables.concat(pipeline.predefined_variables) + variables.concat(runner.predefined_variables) if runner + variables.concat(project.deployment_variables(environment: environment)) if has_environment? + variables.concat(yaml_variables) + variables.concat(user_variables) + variables.concat(project.group.secret_variables_for(ref, project)) if project.group + variables.concat(secret_variables(environment: environment)) + variables.concat(trigger_request.user_variables) if trigger_request + variables.concat(pipeline.variables) + variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule + variables.concat(persisted_environment_variables) if environment + end + + collection.to_runner_variables end def features @@ -430,14 +430,14 @@ module Ci end def user_variables - return [] if user.blank? + Gitlab::Ci::Variables::Collection.new.tap do |variables| + return variables if user.blank? - [ - { key: 'GITLAB_USER_ID', value: user.id.to_s, public: true }, - { key: 'GITLAB_USER_EMAIL', value: user.email, public: true }, - { key: 'GITLAB_USER_LOGIN', value: user.username, public: true }, - { key: 'GITLAB_USER_NAME', value: user.name, public: true } - ] + variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s) + variables.append(key: 'GITLAB_USER_EMAIL', value: user.email) + variables.append(key: 'GITLAB_USER_LOGIN', value: user.username) + variables.append(key: 'GITLAB_USER_NAME', value: user.name) + end end def secret_variables(environment: persisted_environment) @@ -540,60 +540,57 @@ module Ci CI_REGISTRY_USER = 'gitlab-ci-token'.freeze def predefined_variables - variables = [ - { key: 'CI', value: 'true', public: true }, - { key: 'GITLAB_CI', value: 'true', public: true }, - { key: 'GITLAB_FEATURES', value: project.namespace.features.join(','), public: true }, - { key: 'CI_SERVER_NAME', value: 'GitLab', public: true }, - { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true }, - { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true }, - { key: 'CI_JOB_ID', value: id.to_s, public: true }, - { key: 'CI_JOB_NAME', value: name, public: true }, - { key: 'CI_JOB_STAGE', value: stage, public: true }, - { key: 'CI_JOB_TOKEN', value: token, public: false }, - { key: 'CI_COMMIT_SHA', value: sha, public: true }, - { key: 'CI_COMMIT_REF_NAME', value: ref, public: true }, - { key: 'CI_COMMIT_REF_SLUG', value: ref_slug, public: true }, - { key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER, public: true }, - { key: 'CI_REGISTRY_PASSWORD', value: token, public: false }, - { key: 'CI_REPOSITORY_URL', value: repo_url, public: false } - ] - - variables << { key: "CI_COMMIT_TAG", value: ref, public: true } if tag? - variables << { key: "CI_PIPELINE_TRIGGERED", value: 'true', public: true } if trigger_request - variables << { key: "CI_JOB_MANUAL", value: 'true', public: true } if action? - variables.concat(legacy_variables) + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI', value: 'true') + variables.append(key: 'GITLAB_CI', value: 'true') + variables.append(key: 'GITLAB_FEATURES', value: project.namespace.features.join(',')) + variables.append(key: 'CI_SERVER_NAME', value: 'GitLab') + variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) + variables.append(key: 'CI_SERVER_REVISION', value: Gitlab::REVISION) + variables.append(key: 'CI_JOB_ID', value: id.to_s) + variables.append(key: 'CI_JOB_NAME', value: name) + variables.append(key: 'CI_JOB_STAGE', value: stage) + variables.append(key: 'CI_JOB_TOKEN', value: token, public: false) + variables.append(key: 'CI_COMMIT_SHA', value: sha) + variables.append(key: 'CI_COMMIT_REF_NAME', value: ref) + variables.append(key: 'CI_COMMIT_REF_SLUG', value: ref_slug) + variables.append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER) + variables.append(key: 'CI_REGISTRY_PASSWORD', value: token, public: false) + variables.append(key: 'CI_REPOSITORY_URL', value: repo_url, public: false) + variables.append(key: "CI_COMMIT_TAG", value: ref) if tag? + variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request + variables.append(key: "CI_JOB_MANUAL", value: 'true') if action? + variables.concat(legacy_variables) + end end def persisted_environment_variables - return [] unless persisted_environment + Gitlab::Ci::Variables::Collection.new.tap do |variables| + return variables unless persisted_environment - variables = persisted_environment.predefined_variables + variables.concat(persisted_environment.predefined_variables) - # Here we're passing unexpanded environment_url for runner to expand, - # and we need to make sure that CI_ENVIRONMENT_NAME and - # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. - variables << { key: 'CI_ENVIRONMENT_URL', value: environment_url, public: true } if environment_url - - variables + # Here we're passing unexpanded environment_url for runner to expand, + # and we need to make sure that CI_ENVIRONMENT_NAME and + # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. + variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url + end end def legacy_variables - variables = [ - { key: 'CI_BUILD_ID', value: id.to_s, public: true }, - { key: 'CI_BUILD_TOKEN', value: token, public: false }, - { key: 'CI_BUILD_REF', value: sha, public: true }, - { key: 'CI_BUILD_BEFORE_SHA', value: before_sha, public: true }, - { key: 'CI_BUILD_REF_NAME', value: ref, public: true }, - { key: 'CI_BUILD_REF_SLUG', value: ref_slug, public: true }, - { key: 'CI_BUILD_NAME', value: name, public: true }, - { key: 'CI_BUILD_STAGE', value: stage, public: true } - ] - - variables << { key: "CI_BUILD_TAG", value: ref, public: true } if tag? - variables << { key: "CI_BUILD_TRIGGERED", value: 'true', public: true } if trigger_request - variables << { key: "CI_BUILD_MANUAL", value: 'true', public: true } if action? - variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_BUILD_ID', value: id.to_s) + variables.append(key: 'CI_BUILD_TOKEN', value: token, public: false) + variables.append(key: 'CI_BUILD_REF', value: sha) + variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) + variables.append(key: 'CI_BUILD_REF_NAME', value: ref) + variables.append(key: 'CI_BUILD_REF_SLUG', value: ref_slug) + variables.append(key: 'CI_BUILD_NAME', value: name) + variables.append(key: 'CI_BUILD_STAGE', value: stage) + variables.append(key: "CI_BUILD_TAG", value: ref) if tag? + variables.append(key: "CI_BUILD_TRIGGERED", value: 'true') if trigger_request + variables.append(key: "CI_BUILD_MANUAL", value: 'true') if action? + end end def environment_url diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index a72a815bfe8..4966ea62df9 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -473,11 +473,10 @@ module Ci end def predefined_variables - [ - { key: 'CI_PIPELINE_ID', value: id.to_s, public: true }, - { key: 'CI_CONFIG_PATH', value: ci_yaml_file_path, public: true }, - { key: 'CI_PIPELINE_SOURCE', value: source.to_s, public: true } - ] + Gitlab::Ci::Variables::Collection.new + .append(key: 'CI_PIPELINE_ID', value: id.to_s) + .append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path) + .append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) end def queued_duration diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 609620a62bb..7173f88f1c7 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -132,11 +132,10 @@ module Ci end def predefined_variables - [ - { key: 'CI_RUNNER_ID', value: id.to_s, public: true }, - { key: 'CI_RUNNER_DESCRIPTION', value: description, public: true }, - { key: 'CI_RUNNER_TAGS', value: tag_list.to_s, public: true } - ] + Gitlab::Ci::Variables::Collection.new + .append(key: 'CI_RUNNER_ID', value: id.to_s) + .append(key: 'CI_RUNNER_DESCRIPTION', value: description) + .append(key: 'CI_RUNNER_TAGS', value: tag_list.to_s) end def tick_runner_queue diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 738d1e2bc82..ba6552f238f 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -56,19 +56,19 @@ module Clusters def predefined_variables config = YAML.dump(kubeconfig) - variables = [ - { key: 'KUBE_URL', value: api_url, public: true }, - { key: 'KUBE_TOKEN', value: token, public: false }, - { key: 'KUBE_NAMESPACE', value: actual_namespace, public: true }, - { key: 'KUBECONFIG', value: config, public: false, file: true } - ] - - if ca_pem.present? - variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true } - variables << { key: 'KUBE_CA_PEM_FILE', value: ca_pem, public: true, file: true } + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables + .append(key: 'KUBE_URL', value: api_url) + .append(key: 'KUBE_TOKEN', value: token, public: false) + .append(key: 'KUBE_NAMESPACE', value: actual_namespace) + .append(key: 'KUBECONFIG', value: config, public: false, file: true) + + if ca_pem.present? + variables + .append(key: 'KUBE_CA_PEM', value: ca_pem) + .append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true) + end end - - variables end # Constructs a list of terminals from the reactive cache diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index 67a988addbe..f05e606995d 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -7,29 +7,24 @@ module Storage raise Gitlab::UpdatePathError.new('Namespace cannot be moved, because at least one project has tags in container registry') end - expires_full_path_cache - - # Move the namespace directory in all storage paths used by member projects - repository_storage_paths.each do |repository_storage_path| - # Ensure old directory exists before moving it - gitlab_shell.add_namespace(repository_storage_path, full_path_was) - - # Ensure new directory exists before moving it (if there's a parent) - gitlab_shell.add_namespace(repository_storage_path, parent.full_path) if parent + parent_was = if parent_changed? && parent_id_was.present? + Namespace.find(parent_id_was) # raise NotFound early if needed + end - unless gitlab_shell.mv_namespace(repository_storage_path, full_path_was, full_path) + expires_full_path_cache - Rails.logger.error "Exception moving path #{repository_storage_path} from #{full_path_was} to #{full_path}" + move_repositories - # if we cannot move namespace directory we should rollback - # db changes in order to prevent out of sync between db and fs - raise Gitlab::UpdatePathError.new('namespace directory cannot be moved') - end + if parent_changed? + former_parent_full_path = parent_was&.full_path + parent_full_path = parent&.full_path + Gitlab::UploadsTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path) + Gitlab::PagesTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path) + else + Gitlab::UploadsTransfer.new.rename_namespace(full_path_was, full_path) + Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path) end - Gitlab::UploadsTransfer.new.rename_namespace(full_path_was, full_path) - Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path) - remove_exports! # If repositories moved successfully we need to @@ -57,6 +52,26 @@ module Storage private + def move_repositories + # Move the namespace directory in all storage paths used by member projects + repository_storage_paths.each do |repository_storage_path| + # Ensure old directory exists before moving it + gitlab_shell.add_namespace(repository_storage_path, full_path_was) + + # Ensure new directory exists before moving it (if there's a parent) + gitlab_shell.add_namespace(repository_storage_path, parent.full_path) if parent + + unless gitlab_shell.mv_namespace(repository_storage_path, full_path_was, full_path) + + Rails.logger.error "Exception moving path #{repository_storage_path} from #{full_path_was} to #{full_path}" + + # if we cannot move namespace directory we should rollback + # db changes in order to prevent out of sync between db and fs + raise Gitlab::UpdatePathError.new('namespace directory cannot be moved') + end + end + end + def old_repository_storage_paths @old_repository_storage_paths ||= repository_storage_paths end diff --git a/app/models/environment.rb b/app/models/environment.rb index 2b0a88ac5b4..9517723d9d9 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -65,10 +65,9 @@ class Environment < ActiveRecord::Base end def predefined_variables - [ - { key: 'CI_ENVIRONMENT_NAME', value: name, public: true }, - { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true } - ] + Gitlab::Ci::Variables::Collection.new + .append(key: 'CI_ENVIRONMENT_NAME', value: name) + .append(key: 'CI_ENVIRONMENT_SLUG', value: slug) end def recently_updated_on_branch?(ref) diff --git a/app/models/member.rb b/app/models/member.rb index 36090676051..ec8156bbb01 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -55,7 +55,7 @@ class Member < ActiveRecord::Base scope :active_without_invites, -> do left_join_users .where(users: { state: 'active' }) - .where(requested_at: nil) + .non_request .reorder(nil) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index c2bae379a94..149ef7ec429 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -579,9 +579,10 @@ class MergeRequest < ActiveRecord::Base return unless open? old_diff_refs = self.diff_refs + new_diff = create_merge_request_diff + + MergeRequests::MergeRequestDiffCacheService.new.execute(self, new_diff) - create_merge_request_diff - MergeRequests::MergeRequestDiffCacheService.new.execute(self) new_diff_refs = self.diff_refs update_diff_discussion_positions( diff --git a/app/models/project.rb b/app/models/project.rb index 5f9d9785d64..5487194ed3e 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1083,7 +1083,7 @@ class Project < ActiveRecord::Base # Forked import is handled asynchronously return if forked? && !force - if gitlab_shell.add_repository(repository_storage, disk_path) + if gitlab_shell.create_repository(repository_storage, disk_path) repository.after_create true else @@ -1519,8 +1519,8 @@ class Project < ActiveRecord::Base @errors = original_errors end - def add_export_job(current_user:) - job_id = ProjectExportWorker.perform_async(current_user.id, self.id) + def add_export_job(current_user:, params: {}) + job_id = ProjectExportWorker.perform_async(current_user.id, self.id, params) if job_id Rails.logger.info "Export job started for project ID #{self.id} with job ID #{job_id}" @@ -1572,29 +1572,30 @@ class Project < ActiveRecord::Base end def predefined_variables - [ - { key: 'CI_PROJECT_ID', value: id.to_s, public: true }, - { key: 'CI_PROJECT_NAME', value: path, public: true }, - { key: 'CI_PROJECT_PATH', value: full_path, public: true }, - { key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug, public: true }, - { key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path, public: true }, - { key: 'CI_PROJECT_URL', value: web_url, public: true }, - { key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level), public: true } - ] + visibility = Gitlab::VisibilityLevel.string_level(visibility_level) + + Gitlab::Ci::Variables::Collection.new + .append(key: 'CI_PROJECT_ID', value: id.to_s) + .append(key: 'CI_PROJECT_NAME', value: path) + .append(key: 'CI_PROJECT_PATH', value: full_path) + .append(key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug) + .append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path) + .append(key: 'CI_PROJECT_URL', value: web_url) + .append(key: 'CI_PROJECT_VISIBILITY', value: visibility) + .concat(container_registry_variables) + .concat(auto_devops_variables) end def container_registry_variables - return [] unless Gitlab.config.registry.enabled + Gitlab::Ci::Variables::Collection.new.tap do |variables| + return variables unless Gitlab.config.registry.enabled - variables = [ - { key: 'CI_REGISTRY', value: Gitlab.config.registry.host_port, public: true } - ] + variables.append(key: 'CI_REGISTRY', value: Gitlab.config.registry.host_port) - if container_registry_enabled? - variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_url, public: true } + if container_registry_enabled? + variables.append(key: 'CI_REGISTRY_IMAGE', value: container_registry_url) + end end - - variables end def secret_variables_for(ref:, environment: nil) @@ -1614,16 +1615,14 @@ class Project < ActiveRecord::Base end end - def deployment_variables - return [] unless deployment_platform - - deployment_platform.predefined_variables + def deployment_variables(environment: nil) + deployment_platform(environment: environment)&.predefined_variables || [] end def auto_devops_variables return [] unless auto_devops_enabled? - (auto_devops || build_auto_devops)&.variables + (auto_devops || build_auto_devops)&.predefined_variables end def append_or_update_attribute(name, value) diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index 112ed7ed434..ed6c1eddbc1 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -14,9 +14,12 @@ class ProjectAutoDevops < ActiveRecord::Base domain.present? || instance_domain.present? end - def variables - variables = [] - variables << { key: 'AUTO_DEVOPS_DOMAIN', value: domain.presence || instance_domain, public: true } if has_domain? - variables + def predefined_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + if has_domain? + variables.append(key: 'AUTO_DEVOPS_DOMAIN', + value: domain.presence || instance_domain) + end + end end end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index e5035c81df0..601a6a077f5 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -161,11 +161,6 @@ class JiraService < IssueTrackerService add_comment(data, jira_issue) end - # reason why service cannot be tested - def disabled_title - "Please fill in Password and Username." - end - def test(_) result = test_settings success = result.present? diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index e412d15363d..20fed432e55 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -105,19 +105,19 @@ class KubernetesService < DeploymentService def predefined_variables config = YAML.dump(kubeconfig) - variables = [ - { key: 'KUBE_URL', value: api_url, public: true }, - { key: 'KUBE_TOKEN', value: token, public: false }, - { key: 'KUBE_NAMESPACE', value: actual_namespace, public: true }, - { key: 'KUBECONFIG', value: config, public: false, file: true } - ] - - if ca_pem.present? - variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true } - variables << { key: 'KUBE_CA_PEM_FILE', value: ca_pem, public: true, file: true } + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables + .append(key: 'KUBE_URL', value: api_url) + .append(key: 'KUBE_TOKEN', value: token, public: false) + .append(key: 'KUBE_NAMESPACE', value: actual_namespace) + .append(key: 'KUBECONFIG', value: config, public: false, file: true) + + if ca_pem.present? + variables + .append(key: 'KUBE_CA_PEM', value: ca_pem) + .append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true) + end end - - variables end # Constructs a list of terminals from the reactive cache diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index 9c7b58dead5..4cf149ac044 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -39,10 +39,6 @@ class PipelinesEmailService < Service project.pipelines.any? end - def disabled_title - 'Please setup a pipeline on your repository.' - end - def test_data(project, user) data = Gitlab::DataBuilder::Pipeline.build(project.pipelines.last) data[:user] = user.hook_attrs diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index f6041da986c..52e067cb44c 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -169,7 +169,7 @@ class ProjectWiki private def create_repo!(raw_repository) - gitlab_shell.add_repository(project.repository_storage, disk_path) + gitlab_shell.create_repository(project.repository_storage, disk_path) raise CouldNotCreateWikiError unless raw_repository.exists? diff --git a/app/models/service.rb b/app/models/service.rb index 99bf757ae44..2556db68146 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -162,11 +162,6 @@ class Service < ActiveRecord::Base true end - # reason why service cannot be tested - def disabled_title - "Please setup a project repository." - end - # Provide convenient accessor methods # for each serialized property. # Also keep track of updated properties in a similar way as ActiveModel::Dirty diff --git a/app/services/files/create_service.rb b/app/services/files/create_service.rb index 46acdc5406c..a954564946b 100644 --- a/app/services/files/create_service.rb +++ b/app/services/files/create_service.rb @@ -1,11 +1,11 @@ module Files class CreateService < Files::BaseService def create_commit! - handler = Lfs::FileModificationHandler.new(project, @branch_name) + transformer = Lfs::FileTransformer.new(project, @branch_name) - handler.new_file(@file_path, @file_content) do |content_or_lfs_pointer| - create_transformed_commit(content_or_lfs_pointer) - end + result = transformer.new_file(@file_path, @file_content) + + create_transformed_commit(result.content) end private diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb index a03c59f569d..13a1dee4173 100644 --- a/app/services/files/multi_service.rb +++ b/app/services/files/multi_service.rb @@ -3,11 +3,33 @@ module Files UPDATE_FILE_ACTIONS = %w(update move delete).freeze def create_commit! + transformer = Lfs::FileTransformer.new(project, @branch_name) + + actions = actions_after_lfs_transformation(transformer, params[:actions]) + + commit_actions!(actions) + end + + private + + def actions_after_lfs_transformation(transformer, actions) + actions.map do |action| + if action[:action] == 'create' + result = transformer.new_file(action[:file_path], action[:content], encoding: action[:encoding]) + action[:content] = result.content + action[:encoding] = result.encoding + end + + action + end + end + + def commit_actions!(actions) repository.multi_action( current_user, message: @commit_message, branch_name: @branch_name, - actions: params[:actions], + actions: actions, author_email: @author_email, author_name: @author_name, start_project: @start_project, @@ -17,8 +39,6 @@ module Files raise_error(e) end - private - def validate! super diff --git a/app/services/lfs/file_modification_handler.rb b/app/services/lfs/file_modification_handler.rb deleted file mode 100644 index fe9091a6e5d..00000000000 --- a/app/services/lfs/file_modification_handler.rb +++ /dev/null @@ -1,42 +0,0 @@ -module Lfs - class FileModificationHandler - attr_reader :project, :branch_name - - delegate :repository, to: :project - - def initialize(project, branch_name) - @project = project - @branch_name = branch_name - end - - def new_file(file_path, file_content) - if project.lfs_enabled? && lfs_file?(file_path) - lfs_pointer_file = Gitlab::Git::LfsPointerFile.new(file_content) - lfs_object = create_lfs_object!(lfs_pointer_file, file_content) - content = lfs_pointer_file.pointer - - success = yield(content) - - link_lfs_object!(lfs_object) if success - else - yield(file_content) - end - end - - private - - def lfs_file?(file_path) - repository.attributes_at(branch_name, file_path)['filter'] == 'lfs' - end - - def create_lfs_object!(lfs_pointer_file, file_content) - LfsObject.find_or_create_by(oid: lfs_pointer_file.sha256, size: lfs_pointer_file.size) do |lfs_object| - lfs_object.file = CarrierWaveStringFile.new(file_content) - end - end - - def link_lfs_object!(lfs_object) - project.lfs_objects << lfs_object - end - end -end diff --git a/app/services/lfs/file_transformer.rb b/app/services/lfs/file_transformer.rb new file mode 100644 index 00000000000..69281ee3137 --- /dev/null +++ b/app/services/lfs/file_transformer.rb @@ -0,0 +1,66 @@ +module Lfs + # Usage: Calling `new_file` check to see if a file should be in LFS and + # return a transformed result with `content` and `encoding` to commit. + # + # For LFS an LfsObject linked to the project is stored and an LFS + # pointer returned. If the file isn't in LFS the untransformed content + # is returned to save in the commit. + # + # transformer = Lfs::FileTransformer.new(project, @branch_name) + # content_or_lfs_pointer = transformer.new_file(file_path, content).content + # create_transformed_commit(content_or_lfs_pointer) + # + class FileTransformer + attr_reader :project, :branch_name + + delegate :repository, to: :project + + def initialize(project, branch_name) + @project = project + @branch_name = branch_name + end + + def new_file(file_path, file_content, encoding: nil) + if project.lfs_enabled? && lfs_file?(file_path) + file_content = Base64.decode64(file_content) if encoding == 'base64' + lfs_pointer_file = Gitlab::Git::LfsPointerFile.new(file_content) + lfs_object = create_lfs_object!(lfs_pointer_file, file_content) + + link_lfs_object!(lfs_object) + + Result.new(content: lfs_pointer_file.pointer, encoding: 'text') + else + Result.new(content: file_content, encoding: encoding) + end + end + + class Result + attr_reader :content, :encoding + + def initialize(content:, encoding:) + @content = content + @encoding = encoding + end + end + + private + + def lfs_file?(file_path) + cached_attributes.attributes(file_path)['filter'] == 'lfs' + end + + def cached_attributes + @cached_attributes ||= Gitlab::Git::AttributesAtRefParser.new(repository, branch_name) + end + + def create_lfs_object!(lfs_pointer_file, file_content) + LfsObject.find_or_create_by(oid: lfs_pointer_file.sha256, size: lfs_pointer_file.size) do |lfs_object| + lfs_object.file = CarrierWaveStringFile.new(file_content) + end + end + + def link_lfs_object!(lfs_object) + project.lfs_objects << lfs_object + end + end +end diff --git a/app/services/merge_requests/merge_request_diff_cache_service.rb b/app/services/merge_requests/merge_request_diff_cache_service.rb index 2945a7fd4e4..10aa9ae609c 100644 --- a/app/services/merge_requests/merge_request_diff_cache_service.rb +++ b/app/services/merge_requests/merge_request_diff_cache_service.rb @@ -1,8 +1,17 @@ module MergeRequests class MergeRequestDiffCacheService - def execute(merge_request) + def execute(merge_request, new_diff) # Executing the iteration we cache all the highlighted diff information merge_request.diffs.diff_files.to_a + + # Remove cache for all diffs on this MR. Do not use the association on the + # model, as that will interfere with other actions happening when + # reloading the diff. + MergeRequestDiff.where(merge_request: merge_request).each do |merge_request_diff| + next if merge_request_diff == new_diff + + merge_request_diff.diffs.clear_cache! + end end end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index e07ecda27b5..ab94db2c1e5 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -208,9 +208,9 @@ class NotificationService def new_access_request(member) return true unless member.notifiable?(:subscription) - recipients = member.source.members.owners_and_masters + recipients = member.source.members.active_without_invites.owners_and_masters if fallback_to_group_owners_masters?(recipients, member) - recipients = member.source.group.members.owners_and_masters + recipients = member.source.group.members.active_without_invites.owners_and_masters end recipients.each { |recipient| deliver_access_request_email(recipient, member) } diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index af41ce82f65..d16aa3de639 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -26,7 +26,7 @@ module Projects end def project_tree_saver - Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: @current_user, shared: @shared) + Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: @current_user, shared: @shared, params: @params) end def uploads_saver diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index 185e9d7b35d..37269862de6 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -9,6 +9,10 @@ %span.runner-state.runner-state-specific Specific +- add_to_breadcrumbs _("Runners"), admin_runners_path +- breadcrumb_title "##{@runner.id}" +- @no_container = true + - if @runner.shared? .bs-callout.bs-callout-success %h4 This Runner will process jobs from ALL UNASSIGNED projects diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index f9bfc01f213..8680ec2e298 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -2,8 +2,12 @@ - blob = discussion.blob - discussions = { discussion.original_line_code => [discussion] } - diff_file_class = diff_file.text? ? 'text-file' : 'js-image-file' +- diff_data = {} +- expanded = discussion.expanded? || local_assigns.fetch(:expanded, nil) +- unless expanded + - diff_data = { lines_path: project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion) } -.diff-file.file-holder{ class: diff_file_class } +.diff-file.file-holder{ class: diff_file_class, data: diff_data } .js-file-title.file-title.file-title-flex-parent .file-header-content = render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false @@ -11,17 +15,24 @@ - if diff_file.text? .diff-content.code.js-syntax-highlight %table - = render partial: "projects/diffs/line", - collection: discussion.truncated_diff_lines, - as: :line, - locals: { diff_file: diff_file, - discussions: discussions, - discussion_expanded: true, - plain: true } + - if expanded + - discussions = { discussion.original_line_code => [discussion] } + = render partial: "projects/diffs/line", + collection: discussion.truncated_diff_lines, + as: :line, + locals: { diff_file: diff_file, + discussions: discussions, + discussion_expanded: true, + plain: true } + - else + %tr.line_holder.line-holder-placeholder + %td.old_line.diff-line-num + %td.new_line.diff-line-num + %td.line_content + .js-code-placeholder + = render "discussions/diff_discussion", discussions: [discussion], expanded: true - else - partial = (diff_file.new_file? || diff_file.deleted_file?) ? 'single_image_diff' : 'replaced_image_diff' - = render partial: "projects/diffs/#{partial}", locals: { diff_file: diff_file, position: discussion.position.to_json, click_to_comment: false } - .note-container = render partial: "discussions/notes", locals: { discussion: discussion, show_toggle: false, show_image_comment_badge: true, disable_collapse_class: true } diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 8b9fa3d6b05..e9589213f80 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -8,7 +8,7 @@ .discussion.js-toggle-container{ data: { discussion_id: discussion.id } } .discussion-header .discussion-actions - %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button" } + %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button", class: ("js-toggle-lazy-diff" unless expanded) } - if expanded = icon("chevron-up") - else diff --git a/app/views/peek/views/_gc.html.haml b/app/views/peek/views/_gc.html.haml new file mode 100644 index 00000000000..9fc83e56ee7 --- /dev/null +++ b/app/views/peek/views/_gc.html.haml @@ -0,0 +1,7 @@ +- local_assigns.fetch(:view) + +%span.bold + %span{ title: 'Invoke Time', data: { defer_to: "#{view.defer_key}-gc_time" } }... + \/ + %span{ title: 'Invoke Count', data: { defer_to: "#{view.defer_key}-invokes" } }... +gc diff --git a/app/views/peek/views/_gitaly.html.haml b/app/views/peek/views/_gitaly.html.haml index a7d040d6821..945bb287429 100644 --- a/app/views/peek/views/_gitaly.html.haml +++ b/app/views/peek/views/_gitaly.html.haml @@ -1,7 +1,17 @@ - local_assigns.fetch(:view) -%strong - %span{ data: { defer_to: "#{view.defer_key}-duration" } } ... +%button.btn-blank.btn-link.bold{ type: 'button', data: { toggle: 'modal', target: '#modal-peek-gitaly-details' } } + %span{ data: { defer_to: "#{view.defer_key}-duration" } }... \/ - %span{ data: { defer_to: "#{view.defer_key}-calls" } } ... - Gitaly + %span{ data: { defer_to: "#{view.defer_key}-calls" } }... +#modal-peek-gitaly-details.modal{ tabindex: -1, role: 'dialog' } + .modal-dialog.modal-full + .modal-content + .modal-header + %button.close{ type: 'button', data: { dismiss: 'modal' }, 'aria-label' => 'Close' } + %span{ 'aria-hidden' => 'true' } + × + %h4 + Gitaly requests + .modal-body{ data: { defer_to: "#{view.defer_key}-details" } }... +gitaly diff --git a/app/views/peek/views/_redis.html.haml b/app/views/peek/views/_redis.html.haml new file mode 100644 index 00000000000..f7fba6c95fc --- /dev/null +++ b/app/views/peek/views/_redis.html.haml @@ -0,0 +1,7 @@ +- local_assigns.fetch(:view) + +%span.bold + %span{ data: { defer_to: "#{view.defer_key}-duration" } }... + \/ + %span{ data: { defer_to: "#{view.defer_key}-calls" } }... +redis diff --git a/app/views/peek/views/_sidekiq.html.haml b/app/views/peek/views/_sidekiq.html.haml new file mode 100644 index 00000000000..7efbc05890d --- /dev/null +++ b/app/views/peek/views/_sidekiq.html.haml @@ -0,0 +1,7 @@ +- local_assigns.fetch(:view) + +%span.bold + %span{ data: { defer_to: "#{view.defer_key}-duration" } }... + \/ + %span{ data: { defer_to: "#{view.defer_key}-calls" } }... +sidekiq diff --git a/app/views/peek/views/_sql.html.haml b/app/views/peek/views/_sql.html.haml index dd8b524064f..36583df898a 100644 --- a/app/views/peek/views/_sql.html.haml +++ b/app/views/peek/views/_sql.html.haml @@ -1,13 +1,14 @@ -%strong - %a.js-toggle-modal-peek-sql - %span{ data: { defer_to: "#{view.defer_key}-duration" } }... - \/ - %span{ data: { defer_to: "#{view.defer_key}-calls" } }... +%button.btn-blank.btn-link.bold{ type: 'button', data: { toggle: 'modal', target: '#modal-peek-pg-queries' } } + %span{ data: { defer_to: "#{view.defer_key}-duration" } }... + \/ + %span{ data: { defer_to: "#{view.defer_key}-calls" } }... #modal-peek-pg-queries.modal{ tabindex: -1 } .modal-dialog.modal-full .modal-content .modal-header - %button.close.btn.btn-link.btn-sm{ type: 'button', data: { dismiss: 'modal' } } X + %button.close{ type: 'button', data: { dismiss: 'modal' }, 'aria-label' => 'Close' } + %span{ 'aria-hidden' => 'true' } + × %h4 SQL queries .modal-body{ data: { defer_to: "#{view.defer_key}-queries" } }... diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 053ea24b848..684b082efbb 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -15,11 +15,6 @@ .footer-block.row-content-block = service_save_button(@service) - - if @service.valid? && @service.activated? - - unless @service.can_test? - - disabled_class = 'disabled' - - disabled_title = @service.disabled_title - = link_to 'Cancel', project_settings_integrations_path(@project), class: 'btn btn-cancel' - if lookup_context.template_exists?('show', "projects/services/#{@service.to_param}", true) diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb index c100852374a..0b502143e5d 100644 --- a/app/workers/project_export_worker.rb +++ b/app/workers/project_export_worker.rb @@ -4,10 +4,11 @@ class ProjectExportWorker sidekiq_options retry: 3 - def perform(current_user_id, project_id) + def perform(current_user_id, project_id, params = {}) + params = params.with_indifferent_access current_user = User.find(current_user_id) project = Project.find(project_id) - ::Projects::ImportExport::ExportService.new(project, current_user).execute + ::Projects::ImportExport::ExportService.new(project, current_user, params).execute end end |