diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2018-03-19 10:05:08 +0000 |
---|---|---|
committer | Filipa Lacerda <filipa@gitlab.com> | 2018-03-19 10:05:08 +0000 |
commit | 2a5f9709614a33143cb0f47a2602cf161ba4b2cd (patch) | |
tree | e7b5d209853ac608a4aeb67bb4a7161a2f64f143 /app | |
parent | 024a0b837b6cb93f6ba5171541eadfc64e3897d6 (diff) | |
parent | e1739e47c5664c93c66dd58ded59f9d79cd8a426 (diff) | |
download | gitlab-ce-2a5f9709614a33143cb0f47a2602cf161ba4b2cd.tar.gz |
[ci skip] Merge branch 'master' into 42568-pipeline-empty-state
* master: (156 commits)
Make issue boards height calculation clearer and remove magic numbers
Rename `package-qa` in docs
Copyedit CI services docs
Fix dropzone project show
Prettify notes.
Fix "A copy of Gitlab::Metrics::Methods" have been removed error
Add Jupyter Notebook docs
Fixed profile notifications not being editable
Rename manual job to `package-and-qa`
Update vendored gitlab-ci.yml template for auto-devops
Docs: refactor doc general guidelines and style guidelines
Use Gitaly 0.91.0
Add version available info to integrity check rake task docs
Adds the option to override project description on export via API and fixes the project description not being imported
Extract constant for LfsPointerFile::VERSION_LINE
Setup Faraday middleware before adapter
Revert "Merge branch 'update-httparty' into 'master'"
Update Code Quality example documentation
Fix timeouts loading /admin/projects page
OpenShift install docs: Recommend "add-scc-to-user" over "edit scc"
...
Diffstat (limited to 'app')
90 files changed, 2967 insertions, 2292 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/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index a6a172402d8..3b0f0f960b8 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import initBlob from '~/blob_edit/blob_bundle'; import ShortcutsNavigation from '~/shortcuts_navigation'; import NotificationsForm from '~/notifications_form'; import UserCallout from '~/user_callout'; @@ -19,10 +20,22 @@ document.addEventListener('DOMContentLoaded', () => { className: 'js-autodevops-banner', }); - if ($('#tree-slider').length) new TreeView(); // eslint-disable-line no-new - if ($('.blob-viewer').length) new BlobViewer(); // eslint-disable-line no-new - if ($('.project-show-activity').length) new Activities(); // eslint-disable-line no-new - $('#tree-slider').waitForImages(() => { + // Project show page loads different overview content based on user preferences + const treeSlider = document.querySelector('#tree-slider'); + if (treeSlider) { + new TreeView(); // eslint-disable-line no-new + initBlob(); + } + + if (document.querySelector('.blob-viewer')) { + new BlobViewer(); // eslint-disable-line no-new + } + + if (document.querySelector('.project-show-activity')) { + new Activities(); // eslint-disable-line no-new + } + + $(treeSlider).waitForImages(() => { ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath); }); }); 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/components/states/mr_widget_unresolved_discussions.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js deleted file mode 100644 index 67b271c69ca..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js +++ /dev/null @@ -1,27 +0,0 @@ -import statusIcon from '../mr_widget_status_icon.vue'; - -export default { - name: 'MRWidgetUnresolvedDiscussions', - props: { - mr: { type: Object, required: true }, - }, - components: { - statusIcon, - }, - template: ` - <div class="mr-widget-body media"> - <status-icon status="warning" :show-disabled-button="true" /> - <div class="media-body space-children"> - <span class="bold"> - There are unresolved discussions. Please resolve these discussions - </span> - <a - v-if="mr.createIssueToResolveDiscussionsPath" - :href="mr.createIssueToResolveDiscussionsPath" - class="btn btn-default btn-xs js-create-issue"> - Create an issue to resolve them later - </a> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue new file mode 100644 index 00000000000..9ade6a91747 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -0,0 +1,33 @@ +<script> +import statusIcon from '../mr_widget_status_icon.vue'; + +export default { + name: 'UnresolvedDiscussions', + components: { + statusIcon, + }, + props: { + mr: { type: Object, required: true }, + }, +}; +</script> + +<template> + <div class="mr-widget-body media"> + <status-icon + status="warning" + :show-disabled-button="true" + /> + <div class="media-body space-children"> + <span class="bold"> + There are unresolved discussions. Please resolve these discussions + </span> + <a + v-if="mr.createIssueToResolveDiscussionsPath" + :href="mr.createIssueToResolveDiscussionsPath" + class="btn btn-default btn-xs js-create-issue"> + Create an issue to resolve them later + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index 20624aad0ad..021c2237661 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'; @@ -29,7 +29,7 @@ export { default as MissingBranchState } from './components/states/mr_widget_mis export { default as NotAllowedState } from './components/states/mr_widget_not_allowed.vue'; export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge'; export { default as SHAMismatchState } from './components/states/mr_widget_sha_mismatch'; -export { default as UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions'; +export { default as UnresolvedDiscussionsState } from './components/states/unresolved_discussions.vue'; export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked.vue'; export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed'; export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds.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/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 1acde98c3ae..e2d97d0298f 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -9,7 +9,8 @@ padding-left: $contextual-sidebar-width; } - .issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header { + .issues-bulk-update.right-sidebar.right-sidebar-expanded + .issuable-sidebar-header { padding: 10px 0 15px; } } @@ -61,7 +62,8 @@ } .nav-sidebar { - transition: width $sidebar-transition-duration, left $sidebar-transition-duration; + transition: width $sidebar-transition-duration, + left $sidebar-transition-duration; position: fixed; z-index: 400; width: $contextual-sidebar-width; @@ -75,7 +77,7 @@ &:not(.sidebar-collapsed-desktop) { @media (min-width: $screen-sm-min) and (max-width: $screen-md-max) { box-shadow: inset -2px 0 0 $border-color, - 2px 1px 3px $dropdown-shadow-color; + 2px 1px 3px $dropdown-shadow-color; } } @@ -234,7 +236,7 @@ border-radius: 0 3px 3px 0; &::before { - content: ""; + content: ''; position: absolute; top: -30px; bottom: -30px; @@ -305,7 +307,6 @@ } } - // Collapsed nav .toggle-sidebar-button, @@ -454,18 +455,3 @@ z-index: 300; } } - - -// Make issue boards full-height now that sub-nav is gone - -.boards-list { - height: calc(100vh - #{$header-height}); - - @media (min-width: $screen-sm-min) { - height: calc(100vh - 180px); - } -} - -.with-performance-bar .boards-list { - height: calc(100vh - #{$header-height} - #{$performance-bar-height}); -} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 634593aefd0..bea58bade9d 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -184,7 +184,6 @@ } .container-fluid { - .navbar-nav { @media (max-width: $screen-xs-max) { display: -webkit-flex; @@ -337,7 +336,7 @@ .breadcrumbs { display: -webkit-flex; display: flex; - min-height: 48px; + min-height: $breadcrumb-min-height; color: $gl-text-color; } @@ -466,7 +465,7 @@ padding: 0 5px; line-height: 12px; border-radius: 7px; - box-shadow: 0 1px 0 rgba($gl-header-color, .2); + box-shadow: 0 1px 0 rgba($gl-header-color, 0.2); &.issues-count { background-color: $green-500; 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/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index a5a8f6d2206..a81904d5338 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -5,9 +5,9 @@ $grid-size: 8px; $gutter_collapsed_width: 62px; $gutter_width: 290px; $gutter_inner_width: 250px; -$sidebar-transition-duration: .3s; +$sidebar-transition-duration: 0.3s; $sidebar-breakpoint: 1024px; -$default-transition-duration: .15s; +$default-transition-duration: 0.15s; $contextual-sidebar-width: 220px; $contextual-sidebar-collapsed-width: 50px; @@ -129,7 +129,6 @@ $theme-green-800: #145d33; $theme-green-900: #0d4524; $theme-green-950: #072d16; - $black: #000; $black-transparent: rgba(0, 0, 0, 0.3); $almost-black: #242424; @@ -163,7 +162,7 @@ $gl-text-color-secondary: #707070; $gl-text-color-tertiary: #949494; $gl-text-color-quaternary: #d6d6d6; $gl-text-color-inverted: rgba(255, 255, 255, 1); -$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85); +$gl-text-color-secondary-inverted: rgba(255, 255, 255, 0.85); $gl-text-color-disabled: #919191; $gl-text-green: $green-600; $gl-text-green-hover: $green-700; @@ -262,6 +261,7 @@ $highlight-changes-color: rgb(235, 255, 232); $performance-bar-height: 35px; $flash-height: 52px; $context-header-height: 60px; +$breadcrumb-min-height: 48px; /* * Common component specific colors @@ -296,7 +296,7 @@ $tanuki-yellow: #fca326; */ $gl-primary: $blue-500; $gl-success: $green-500; -$gl-success-focus: rgba($gl-success, .4); +$gl-success-focus: rgba($gl-success, 0.4); $gl-info: $blue-500; $gl-warning: $orange-500; $gl-danger: $red-500; @@ -331,8 +331,11 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%); /* * Fonts */ -$monospace_font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; -$regular_font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +$monospace_font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', + 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; +$regular_font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; /* * Dropdowns @@ -343,16 +346,16 @@ $dropdown-max-height: 312px; $dropdown-vertical-offset: 4px; $dropdown-link-color: #555; $dropdown-link-hover-bg: $row-hover; -$dropdown-empty-row-bg: rgba(#000, .04); +$dropdown-empty-row-bg: rgba(#000, 0.04); $dropdown-border-color: $border-color; -$dropdown-shadow-color: rgba(#000, .1); -$dropdown-divider-color: rgba(#000, .1); +$dropdown-shadow-color: rgba(#000, 0.1); +$dropdown-divider-color: rgba(#000, 0.1); $dropdown-title-btn-color: #bfbfbf; $dropdown-input-color: #555; $dropdown-input-fa-color: #c7c7c7; $dropdown-input-focus-border: $focus-border-color; -$dropdown-input-focus-shadow: rgba($dropdown-input-focus-border, .4); -$dropdown-loading-bg: rgba(#fff, .6); +$dropdown-input-focus-shadow: rgba($dropdown-input-focus-border, 0.4); +$dropdown-loading-bg: rgba(#fff, 0.6); $dropdown-chevron-size: 10px; $dropdown-toggle-active-border-color: darken($border-color, 14%); $dropdown-item-hover-bg: $gray-darker; @@ -367,9 +370,9 @@ $dropdown-hover-color: $blue-400; /* * Contextual Sidebar */ -$link-active-background: rgba(0, 0, 0, .04); -$link-hover-background: rgba(0, 0, 0, .06); -$inactive-badge-background: rgba(0, 0, 0, .08); +$link-active-background: rgba(0, 0, 0, 0.04); +$link-hover-background: rgba(0, 0, 0, 0.06); +$inactive-badge-background: rgba(0, 0, 0, 0.08); /* * Buttons @@ -397,14 +400,14 @@ $status-icon-margin: $gl-btn-padding; /* * Award emoji */ -$award-emoji-menu-shadow: rgba(0, 0, 0, .175); +$award-emoji-menu-shadow: rgba(0, 0, 0, 0.175); $award-emoji-positive-add-bg: #fed159; $award-emoji-positive-add-lines: #bb9c13; /* * Search Box */ -$search-input-border-color: rgba($blue-400, .8); +$search-input-border-color: rgba($blue-400, 0.8); $search-input-focus-shadow-color: $dropdown-input-focus-shadow; $search-input-width: 220px; $location-badge-active-bg: $blue-500; @@ -429,7 +432,7 @@ $zen-control-color: #555; * Calendar */ $calendar-hover-bg: #ecf3fe; -$calendar-border-color: rgba(#000, .1); +$calendar-border-color: rgba(#000, 0.1); $calendar-user-contrib-text: #959494; /* @@ -452,6 +455,17 @@ $ci-skipped-color: #888; */ $issue-boards-font-size: 14px; $issue-boards-card-shadow: rgba(186, 186, 186, 0.5); +/* + The following heights are used in boards.scss and are used for calculation of the board height. + They probably should be derived in a smarter way. +*/ +$issue-boards-filter-height: 68px; +$issue-boards-breadcrumbs-height-xs: 63px; +$issue-board-list-difference-xs: $header-height + + $issue-boards-breadcrumbs-height-xs; +$issue-board-list-difference-sm: $header-height + $breadcrumb-min-height; +$issue-board-list-difference-md: $issue-board-list-difference-sm + + $issue-boards-filter-height; /* * Avatar @@ -567,14 +581,14 @@ $label-padding: 7px; $label-padding-modal: 10px; $label-gray-bg: #f8fafc; $label-inverse-bg: #333; -$label-remove-border: rgba(0, 0, 0, .1); +$label-remove-border: rgba(0, 0, 0, 0.1); $label-border-radius: 100px; /* * Animation */ $fade-in-duration: 200ms; -$fade-mask-transition-duration: .1s; +$fade-mask-transition-duration: 0.1s; $fade-mask-transition-curve: ease-in-out; /* @@ -642,7 +656,6 @@ $stat-graph-selection-stroke: #333; $select2-drop-shadow1: rgba(76, 86, 103, 0.247059); $select2-drop-shadow2: rgba(31, 37, 50, 0.317647); - /* * Todo */ @@ -679,7 +692,6 @@ CI variable lists */ $ci-variable-remove-button-width: calc(1em + #{2 * $gl-padding}); - /* Filtered Search */ @@ -706,7 +718,14 @@ Repo editor */ $repo-editor-grey: #f6f7f9; $repo-editor-grey-darker: #e9ebee; -$repo-editor-linear-gradient: linear-gradient(to right, $repo-editor-grey 0%, $repo-editor-grey-darker, 20%, $repo-editor-grey 40%, $repo-editor-grey 100%); +$repo-editor-linear-gradient: linear-gradient( + to right, + $repo-editor-grey 0%, + $repo-editor-grey-darker, + 20%, + $repo-editor-grey 40%, + $repo-editor-grey 100% +); /* Performance Bar @@ -717,8 +736,8 @@ $perf-bar-staging: #291430; $perf-bar-development: #4c1210; $perf-bar-bucket-bg: #111; $perf-bar-bucket-color: #ccc; -$perf-bar-bucket-box-shadow-from: rgba($white-light, .2); -$perf-bar-bucket-box-shadow-to: rgba($black, .25); +$perf-bar-bucket-box-shadow-from: rgba($white-light, 0.2); +$perf-bar-bucket-box-shadow-to: rgba($black, 0.25); /* Issuable warning diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 2803144ef1d..c03d4c2eebf 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -1,4 +1,4 @@ -@import "./issues/issue_count_badge"; +@import './issues/issue_count_badge'; [v-cloak] { display: none; @@ -72,22 +72,37 @@ } .boards-list { - height: calc(100vh - 105px); + height: calc(100vh - #{$issue-board-list-difference-xs}); width: 100%; - padding-top: 25px; - padding-bottom: 25px; - padding-right: ($gl-padding / 2); - padding-left: ($gl-padding / 2); + padding: $gl-padding ($gl-padding / 2); overflow-x: scroll; white-space: nowrap; + min-height: 200px; @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { - height: calc(100vh - 90px); + height: calc(100vh - #{$issue-board-list-difference-sm}); } @media (min-width: $screen-md-min) { - height: calc(100vh - 160px); - min-height: 475px; + height: calc(100vh - #{$issue-board-list-difference-md}); + } + + .with-performance-bar & { + height: calc( + 100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height} + ); + + @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { + height: calc( + 100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height} + ); + } + + @media (min-width: $screen-md-min) { + height: calc( + 100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height} + ); + } } } @@ -454,7 +469,7 @@ &.boards-sidebar-slide-enter-active, &.boards-sidebar-slide-leave-active { transition: width $sidebar-transition-duration, - padding $sidebar-transition-duration; + padding $sidebar-transition-duration; } &.boards-sidebar-slide-enter, @@ -473,7 +488,7 @@ right: 0; bottom: 0; left: 0; - background-color: rgba($black, .3); + background-color: rgba($black, 0.3); z-index: 9999; } @@ -490,7 +505,7 @@ padding: 25px 15px 0; background-color: $white-light; border-radius: $border-radius-default; - box-shadow: 0 2px 12px rgba($black, .5); + box-shadow: 0 2px 12px rgba($black, 0.5); .empty-state { display: -webkit-flex; @@ -568,7 +583,7 @@ .card { border: 1px solid $border-gray-dark; - box-shadow: 0 1px 2px rgba($issue-boards-card-shadow, .3); + box-shadow: 0 1px 2px rgba($issue-boards-card-shadow, 0.3); cursor: pointer; } } 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..c1da2081465 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -41,12 +41,12 @@ module Ci scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } - scope :with_artifacts, ->() do + scope :with_artifacts_archive, ->() do where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)', - '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id')) + '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) end - scope :with_artifacts_not_expired, ->() { with_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } - scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } + scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } + scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) } scope :ref_protected, -> { where(protected: true) } @@ -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..f2edcdd61fd 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 @@ -514,7 +513,7 @@ module Ci # We purposely cast the builds to an Array here. Because we always use the # rows if there are more than 0 this prevents us from having to run two # queries: one to get the count and one to get the rows. - @latest_builds_with_artifacts ||= builds.latest.with_artifacts.to_a + @latest_builds_with_artifacts ||= builds.latest.with_artifacts_archive.to_a end private 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 7ce8befeeeb..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 @@ -134,7 +134,7 @@ module Clusters kubeclient = build_kubeclient! kubeclient.get_pods(namespace: actual_namespace).as_json - rescue KubeException => err + rescue Kubeclient::HttpError => err raise err unless err.error_code == 404 [] 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..d6e663f14e4 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -542,7 +542,7 @@ class Project < ActiveRecord::Base latest_pipeline = pipelines.latest_successful_for(ref) if latest_pipeline - latest_pipeline.builds.latest.with_artifacts + latest_pipeline.builds.latest.with_artifacts_archive else builds.none end @@ -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 ad4ad7903ad..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 @@ -197,7 +197,7 @@ class KubernetesService < DeploymentService kubeclient = build_kubeclient! kubeclient.get_pods(namespace: actual_namespace).as_json - rescue KubeException => err + rescue Kubeclient::HttpError => err raise err unless err.error_code == 404 [] 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/ci/fetch_kubernetes_token_service.rb b/app/services/ci/fetch_kubernetes_token_service.rb index e73c6ad6780..bca883ec0a0 100644 --- a/app/services/ci/fetch_kubernetes_token_service.rb +++ b/app/services/ci/fetch_kubernetes_token_service.rb @@ -32,7 +32,7 @@ module Ci kubeclient = build_kubeclient! kubeclient.get_secrets.as_json - rescue KubeException => err + rescue Kubeclient::HttpError => err raise err unless err.error_code == 404 [] diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index bde090eaeec..90393e951a4 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -12,7 +12,7 @@ module Clusters else check_timeout end - rescue KubeException => ke + rescue Kubeclient::HttpError => ke app.make_errored!("Kubernetes error: #{ke.message}") unless app.errored? end diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb index 8ceeec687cd..4c25a09814b 100644 --- a/app/services/clusters/applications/install_service.rb +++ b/app/services/clusters/applications/install_service.rb @@ -10,7 +10,7 @@ module Clusters ClusterWaitForAppInstallationWorker.perform_in( ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) - rescue KubeException => ke + rescue Kubeclient::HttpError => ke app.make_errored!("Kubernetes error: #{ke.message}") rescue StandardError app.make_errored!("Can't start installation process") 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 |