Commit 771cc617 authored by Robert Speicher's avatar Robert Speicher

Merge branch 'ce-to-ee-2018-03-16' into 'master'

CE upstream - 2018-03-16 21:24 UTC

See merge request gitlab-org/gitlab-ee!5004
parents e58fce3f de13c3a5
...@@ -315,7 +315,7 @@ stages: ...@@ -315,7 +315,7 @@ stages:
## ##
# Trigger a package build in omnibus-gitlab repository # Trigger a package build in omnibus-gitlab repository
# #
package-qa: package-and-qa:
<<: *dedicated-runner <<: *dedicated-runner
image: ruby:2.4-alpine image: ruby:2.4-alpine
before_script: [] before_script: []
......
...@@ -4,13 +4,15 @@ import discussionCounter from '../notes/components/discussion_counter.vue'; ...@@ -4,13 +4,15 @@ import discussionCounter from '../notes/components/discussion_counter.vue';
import store from '../notes/stores'; import store from '../notes/stores';
export default function initMrNotes() { export default function initMrNotes() {
new Vue({ // eslint-disable-line // eslint-disable-next-line no-new
new Vue({
el: '#js-vue-mr-discussions', el: '#js-vue-mr-discussions',
components: { components: {
notesApp, notesApp,
}, },
data() { data() {
const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; const notesDataset = document.getElementById('js-vue-mr-discussions')
.dataset;
return { return {
noteableData: JSON.parse(notesDataset.noteableData), noteableData: JSON.parse(notesDataset.noteableData),
currentUserData: JSON.parse(notesDataset.currentUserData), currentUserData: JSON.parse(notesDataset.currentUserData),
...@@ -28,7 +30,8 @@ export default function initMrNotes() { ...@@ -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', el: '#js-vue-discussion-counter',
components: { components: {
discussionCounter, discussionCounter,
......
...@@ -28,7 +28,13 @@ import GLForm from './gl_form'; ...@@ -28,7 +28,13 @@ import GLForm from './gl_form';
import loadAwardsHandler from './awards_handler'; import loadAwardsHandler from './awards_handler';
import Autosave from './autosave'; import Autosave from './autosave';
import TaskList from './task_list'; 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 imageDiffHelper from './image_diff/helpers/index';
import { localTimeAgo } from './lib/utils/datetime_utility'; import { localTimeAgo } from './lib/utils/datetime_utility';
...@@ -42,9 +48,21 @@ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; ...@@ -42,9 +48,21 @@ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
export default class Notes { 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) { 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,
);
} }
} }
...@@ -82,7 +100,8 @@ export default class Notes { ...@@ -82,7 +100,8 @@ export default class Notes {
this.updatedNotesTrackingMap = {}; this.updatedNotesTrackingMap = {};
this.last_fetched_at = last_fetched_at; this.last_fetched_at = last_fetched_at;
this.noteable_url = document.URL; 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.basePollingInterval = 15000;
this.maxPollingSteps = 4; this.maxPollingSteps = 4;
...@@ -93,15 +112,17 @@ export default class Notes { ...@@ -93,15 +112,17 @@ export default class Notes {
this.taskList = new TaskList({ this.taskList = new TaskList({
dataType: 'note', dataType: 'note',
fieldName: 'note', fieldName: 'note',
selector: '.notes' selector: '.notes',
}); });
this.collapseLongCommitList(); this.collapseLongCommitList();
this.setViewType(view); this.setViewType(view);
// We are in the Merge Requests page so we need another edit form for Changes tab // We are in the Merge Requests page so we need another edit form for Changes tab
if (getPagePath(1) === 'merge_requests') { if (getPagePath(1) === 'merge_requests') {
$('.note-edit-form').clone() $('.note-edit-form')
.addClass('mr-note-edit-form').insertAfter('.note-edit-form'); .clone()
.addClass('mr-note-edit-form')
.insertAfter('.note-edit-form');
} }
const hash = getLocationHash(); const hash = getLocationHash();
...@@ -117,7 +138,9 @@ export default class Notes { ...@@ -117,7 +138,9 @@ export default class Notes {
} }
addBinding() { addBinding() {
this.$wrapperEl = hasVueMRDiscussionsCookie() ? $(document).find('.diffs') : $(document); this.$wrapperEl = hasVueMRDiscussionsCookie()
? $(document).find('.diffs')
: $(document);
// Edit note link // Edit note link
this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this)); this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this));
...@@ -125,27 +148,55 @@ export default class Notes { ...@@ -125,27 +148,55 @@ export default class Notes {
// Reopen and close actions for Issue/MR combined with note form submit // 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-submit-button', this.postComment);
this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment); 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 // resolve a discussion
this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment); this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment);
// remove a note (in general) // remove a note (in general)
this.$wrapperEl.on('click', '.js-note-delete', this.removeNote); this.$wrapperEl.on('click', '.js-note-delete', this.removeNote);
// delete note attachment // 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 // reset main target form when clicking discard
this.$wrapperEl.on('click', '.js-note-discard', this.resetMainTargetForm); this.$wrapperEl.on('click', '.js-note-discard', this.resetMainTargetForm);
// update the file name when an attachment is selected // 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 // 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 // add diff note
this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote); this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote);
// add diff note for images // 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 // 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 // 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); this.$wrapperEl.on('click', '.js-toggle-lazy-diff', this.loadLazyDiff);
// fetch notes when tab becomes visible // fetch notes when tab becomes visible
...@@ -154,9 +205,21 @@ export default class Notes { ...@@ -154,9 +205,21 @@ export default class Notes {
this.$wrapperEl.on('issuable:change', this.refresh); this.$wrapperEl.on('issuable:change', this.refresh);
// ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. // 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-main-target-form', this.addNote);
this.$wrapperEl.on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); this.$wrapperEl.on(
this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); 'ajax:success',
this.$wrapperEl.on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton); '.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 // when a key is clicked on the notes
this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText); this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText);
// When the URL fragment/hash has changed, `#note_xxx` // When the URL fragment/hash has changed, `#note_xxx`
...@@ -195,10 +258,16 @@ export default class Notes { ...@@ -195,10 +258,16 @@ export default class Notes {
} }
static initCommentTypeToggle(form) { static initCommentTypeToggle(form) {
const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle'); const dropdownTrigger = form.querySelector(
const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu'); '.js-comment-type-dropdown .dropdown-toggle',
);
const dropdownList = form.querySelector(
'.js-comment-type-dropdown .dropdown-menu',
);
const noteTypeInput = form.querySelector('#note_type'); 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 closeButton = form.querySelector('.js-note-target-close');
const reopenButton = form.querySelector('.js-note-target-reopen'); const reopenButton = form.querySelector('.js-note-target-reopen');
...@@ -215,7 +284,13 @@ export default class Notes { ...@@ -215,7 +284,13 @@ export default class Notes {
} }
keydownNoteText(e) { keydownNoteText(e) {
var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText; var $textarea,
discussionNoteForm,
editNote,
myLastNote,
myLastNoteEditBtn,
newText,
originalText;
if (isMetaKey(e)) { if (isMetaKey(e)) {
return; return;
} }
...@@ -227,7 +302,12 @@ export default class Notes { ...@@ -227,7 +302,12 @@ export default class Notes {
if ($textarea.val() !== '') { if ($textarea.val() !== '') {
return; 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) { if (myLastNote.length) {
myLastNoteEditBtn = myLastNote.find('.js-note-edit'); myLastNoteEditBtn = myLastNote.find('.js-note-edit');
return myLastNoteEditBtn.trigger('click', [true, myLastNote]); return myLastNoteEditBtn.trigger('click', [true, myLastNote]);
...@@ -238,7 +318,9 @@ export default class Notes { ...@@ -238,7 +318,9 @@ export default class Notes {
discussionNoteForm = $textarea.closest('.js-discussion-note-form'); discussionNoteForm = $textarea.closest('.js-discussion-note-form');
if (discussionNoteForm.length) { if (discussionNoteForm.length) {
if ($textarea.val() !== '') { 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; return;
} }
} }
...@@ -250,7 +332,9 @@ export default class Notes { ...@@ -250,7 +332,9 @@ export default class Notes {
originalText = $textarea.closest('form').data('originalNote'); originalText = $textarea.closest('form').data('originalNote');
newText = $textarea.val(); newText = $textarea.val();
if (originalText !== newText) { 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; return;
} }
} }
...@@ -263,11 +347,14 @@ export default class Notes { ...@@ -263,11 +347,14 @@ export default class Notes {
if (Notes.interval) { if (Notes.interval) {
clearInterval(Notes.interval); clearInterval(Notes.interval);
} }
return Notes.interval = setInterval((function(_this) { return (Notes.interval = setInterval(
return function() { (function(_this) {
return _this.refresh(); return function() {
}; return _this.refresh();
})(this), this.pollingInterval); };
})(this),
this.pollingInterval,
));
} }
refresh() { refresh() {
...@@ -283,20 +370,23 @@ export default class Notes { ...@@ -283,20 +370,23 @@ export default class Notes {
this.refreshing = true; this.refreshing = true;
axios.get(`${this.notes_url}?html=true`, { axios
headers: { .get(`${this.notes_url}?html=true`, {
'X-Last-Fetched-At': this.last_fetched_at, headers: {
}, 'X-Last-Fetched-At': this.last_fetched_at,
}).then(({ data }) => { },
const notes = data.notes; })
this.last_fetched_at = data.last_fetched_at; .then(({ data }) => {
this.setPollingInterval(data.notes.length); const notes = data.notes;
$.each(notes, (i, note) => this.renderNote(note)); this.last_fetched_at = data.last_fetched_at;
this.setPollingInterval(data.notes.length);
this.refreshing = false; $.each(notes, (i, note) => this.renderNote(note));
}).catch(() => {
this.refreshing = false; this.refreshing = false;
}); })
.catch(() => {
this.refreshing = false;
});
} }
/** /**
...@@ -312,7 +402,8 @@ export default class Notes { ...@@ -312,7 +402,8 @@ export default class Notes {
if (shouldReset == null) { if (shouldReset == null) {
shouldReset = true; shouldReset = true;
} }
nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); nthInterval =
this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
if (shouldReset) { if (shouldReset) {
this.pollingInterval = this.basePollingInterval; this.pollingInterval = this.basePollingInterval;
} else if (this.pollingInterval < nthInterval) { } else if (this.pollingInterval < nthInterval) {
...@@ -331,12 +422,17 @@ export default class Notes { ...@@ -331,12 +422,17 @@ export default class Notes {
if ('emoji_award' in noteEntity.commands_changes) { if ('emoji_award' in noteEntity.commands_changes) {
votesBlock = $('.js-awards-block').eq(0); votesBlock = $('.js-awards-block').eq(0);
loadAwardsHandler().then((awardsHandler) => { loadAwardsHandler()
awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award); .then(awardsHandler => {
awardsHandler.scrollToAwards(); awardsHandler.addAwardToEmojiBar(
}).catch(() => { votesBlock,
// ignore noteEntity.commands_changes.emoji_award,
}); );
awardsHandler.scrollToAwards();
})
.catch(() => {
// ignore
});
} }
} }
} }
...@@ -381,11 +477,17 @@ export default class Notes { ...@@ -381,11 +477,17 @@ export default class Notes {
if (!noteEntity.valid) { if (!noteEntity.valid) {
if (noteEntity.errors && noteEntity.errors.commands_only) { if (noteEntity.errors && noteEntity.errors.commands_only) {
if (noteEntity.commands_changes && if (
Object.keys(noteEntity.commands_changes).length > 0) { noteEntity.commands_changes &&
Object.keys(noteEntity.commands_changes).length > 0
) {
$notesList.find('.system-note.being-posted').remove(); $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(); this.refresh();
} }
return; return;
...@@ -407,28 +509,30 @@ export default class Notes { ...@@ -407,28 +509,30 @@ export default class Notes {
this.setupNewNote($newNote); this.setupNewNote($newNote);
this.refresh(); this.refresh();
return this.updateNotesCount(1); return this.updateNotesCount(1);
} } 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. // The server can send the same update multiple times so we need to make sure to only update once per actual update.
else if (Notes.isUpdatedNote(noteEntity, $note)) {
const isEditing = $note.hasClass('is-editing'); const isEditing = $note.hasClass('is-editing');
const initialContent = normalizeNewlines( 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 $textarea = $note.find('.js-note-text');
const currentContent = $textarea.val(); const currentContent = $textarea.val();
// There can be CRLF vs LF mismatches if we don't sanitize and compare the same way // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
const sanitizedNoteNote = normalizeNewlines(noteEntity.note); const sanitizedNoteNote = normalizeNewlines(noteEntity.note);
const isTextareaUntouched = currentContent === initialContent || currentContent === sanitizedNoteNote; const isTextareaUntouched =
currentContent === initialContent ||
currentContent === sanitizedNoteNote;
if (isEditing && isTextareaUntouched) { if (isEditing && isTextareaUntouched) {
$textarea.val(noteEntity.note); $textarea.val(noteEntity.note);
this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
} } else if (isEditing && !isTextareaUntouched) {
else if (isEditing && !isTextareaUntouched) {
this.putConflictEditWarningInPlace(noteEntity, $note); this.putConflictEditWarningInPlace(noteEntity, $note);
this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
} } else {
else {
const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note); const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note);
this.setupNewNote($updatedNote); this.setupNewNote($updatedNote);
} }
...@@ -452,17 +556,31 @@ export default class Notes { ...@@ -452,17 +556,31 @@ export default class Notes {
} }
this.note_ids.push(noteEntity.id); this.note_ids.push(noteEntity.id);
form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`); form =
row = (form.length || !noteEntity.discussion_line_code) ? form.closest('tr') : $(`#${noteEntity.discussion_line_code}`); $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) { if (noteEntity.on_image) {
row = form; row = form;
} }
lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; 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? // 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) { if (!discussionContainer.length) {
discussionContainer = form.closest('.discussion').find('.notes'); discussionContainer = form.closest('.discussion').find('.notes');
} }
...@@ -470,25 +588,42 @@ export default class Notes { ...@@ -470,25 +588,42 @@ export default class Notes {
if (noteEntity.diff_discussion_html) { if (noteEntity.diff_discussion_html) {
var $discussion = $(noteEntity.diff_discussion_html).renderGFM(); 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 // insert the note and the reply button after the temp row
row.after($discussion); row.after($discussion);
} else { } else {
// Merge new discussion HTML in // Merge new discussion HTML in
var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); var $notes = $discussion.find(
var contentContainerClass = '.' + $notes.closest('.notes_content') `.notes[data-discussion-id="${noteEntity.discussion_id}"]`,
.attr('class') );
.split(' ') var contentContainerClass =
.join('.'); '.' +
$notes
row.find(contentContainerClass + ' .content').append($notes.closest('.content').children()); .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 // Init discussion on 'Discussion' page if it is merge request page
const page = $('body').attr('data-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()) { if (!hasVueMRDiscussionsCookie()) {
Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); Notes.animateAppendNote(
noteEntity.discussion_html,
$('.main-notes-list'),
);
} }
} }
} else { } else {
...@@ -496,7 +631,10 @@ export default class Notes { ...@@ -496,7 +631,10 @@ export default class Notes {
Notes.animateAppendNote(noteEntity.html, discussionContainer); Notes.animateAppendNote(noteEntity.html, discussionContainer);
} }
if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { if (
typeof gl.diffNotesCompileComponents !== 'undefined' &&
noteEntity.discussion_resolvable
) {
gl.diffNotesCompileComponents(); gl.diffNotesCompileComponents();
this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); this.renderDiscussionAvatar(diffAvatarContainer, noteEntity);
...@@ -508,7 +646,8 @@ export default class Notes { ...@@ -508,7 +646,8 @@ export default class Notes {
} }
getLineHolder(changesDiscussionContainer) { getLineHolder(changesDiscussionContainer) {
return $(changesDiscussionContainer).closest('.notes_holder') return $(changesDiscussionContainer)
.closest('.notes_holder')
.prevAll('.line_holder') .prevAll('.line_holder')
.first() .first()
.get(0); .get(0);
...@@ -541,8 +680,14 @@ export default class Notes { ...@@ -541,8 +680,14 @@ export default class Notes {
form.find('.js-errors').remove(); form.find('.js-errors').remove();
// reset text and preview // reset text and preview
form.find('.js-md-write-button').click(); form.find('.js-md-write-button').click();
form.find('.js-note-text').val('').trigger('input'); form
form.find('.js-note-text').data('autosave').reset(); .find('.js-note-text')
.val('')
.trigger('input');
form
.find('.js-note-text')
.data('autosave')
.reset();
var event = document.createEvent('Event'); var event = document.createEvent('Event');
event.initEvent('autosize:update', true, false); event.initEvent('autosize:update', true, false);
...@@ -578,7 +723,10 @@ export default class Notes { ...@@ -578,7 +723,10 @@ export default class Notes {
form.find('#note_type').val(''); form.find('#note_type').val('');
form.find('#note_project_id').remove(); form.find('#note_project_id').remove();
form.find('#in_reply_to_discussion_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'); this.parentTimeline = form.parents('.timeline');
if (form.length) { if (form.length) {
...@@ -632,11 +780,17 @@ export default class Notes { ...@@ -632,11 +780,17 @@ export default class Notes {
} else if ($form.hasClass('js-discussion-note-form')) { } else if ($form.hasClass('js-discussion-note-form')) {
formParentTimeline = $form.closest('.discussion-notes').find('.notes'); 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) { 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.',
);
} }
/** /**
...@@ -685,14 +839,16 @@ export default class Notes { ...@@ -685,14 +839,16 @@ export default class Notes {
} }
checkContentToAllowEditing($el) { 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 currentContent = $el.find('.js-note-text').val();
var isAllowed = true; var isAllowed = true;
if (currentContent === initialContent) { if (currentContent === initialContent) {
this.removeNoteEditForm($el); this.removeNoteEditForm($el);
} } else {
else {
var $buttons = $el.find('.note-form-actions'); var $buttons = $el.find('.note-form-actions');
var isWidgetVisible = isInViewport($el.get(0)); var isWidgetVisible = isInViewport($el.get(0));
...@@ -754,8 +910,7 @@ export default class Notes { ...@@ -754,8 +910,7 @@ export default class Notes {
this.setupNewNote($newNote); this.setupNewNote($newNote);
// Now that we have taken care of the update, clear it out // Now that we have taken care of the update, clear it out
delete this.updatedNotesTrackingMap[noteId]; delete this.updatedNotesTrackingMap[noteId];
} } else {
else {
$note.find('.js-finish-edit-warning').hide(); $note.find('.js-finish-edit-warning').hide();
this.removeNoteEditForm($note); this.removeNoteEditForm($note);
} }
...@@ -788,7 +943,9 @@ export default class Notes { ...@@ -788,7 +943,9 @@ export default class Notes {
form.removeClass('current-note-edit-form'); form.removeClass('current-note-edit-form');
form.find('.js-finish-edit-warning').hide(); form.find('.js-finish-edit-warning').hide();
// Replace markdown textarea text with original note text. // 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'));
} }
/** /**
...@@ -802,58 +959,67 @@ export default class Notes { ...@@ -802,58 +959,67 @@ export default class Notes {
$note = $(e.currentTarget).closest('.note'); $note = $(e.currentTarget).closest('.note');
noteElId = $note.attr('id'); noteElId = $note.attr('id');
noteId = $note.attr('data-note-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') .closest('.notes_holder')
.prev('.line_holder'); .prev('.line_holder');
$(`.note[id="${noteElId}"]`).each((function(_this) { $(`.note[id="${noteElId}"]`).each(
// A same note appears in the "Discussion" and in the "Changes" tab, we have (function(_this) {
// to remove all. Using $('.note[id='noteId']') ensure we get all the notes, // A same note appears in the "Discussion" and in the "Changes" tab, we have
// where $('#noteId') would return only one. // to remove all. Using $('.note[id='noteId']') ensure we get all the notes,
return function(i, el) { // where $('#noteId') would return only one.
var $note, $notes; return function(i, el) {
$note = $(el); var $note, $notes;
$notes = $note.closest('.discussion-notes'); $note = $(el);
const discussionId = $('.notes', $notes).data('discussionId'); $notes = $note.closest('.discussion-notes');
const discussionId = $('.notes', $notes).data('discussionId');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (gl.diffNoteApps[noteElId]) { if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNoteApps[noteElId].$destroy(); 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.refreshVueNotes();
Notes.checkMergeRequestStatus(); Notes.checkMergeRequestStatus();
...@@ -935,7 +1101,12 @@ export default class Notes { ...@@ -935,7 +1101,12 @@ export default class Notes {
// DiffNote // DiffNote
form.find('#note_position').val(dataHolder.attr('data-position')); 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-target-close').remove();
form.find('.js-note-new-discussion').remove(); form.find('.js-note-new-discussion').remove();
this.setupNoteForm(form); this.setupNoteForm(form);
...@@ -971,7 +1142,7 @@ export default class Notes { ...@@ -971,7 +1142,7 @@ export default class Notes {
this.toggleDiffNote({ this.toggleDiffNote({
target: $link, target: $link,
lineType: link.dataset.lineType, lineType: link.dataset.lineType,
showReplyInput showReplyInput,
}); });
} }
...@@ -987,7 +1158,9 @@ export default class Notes { ...@@ -987,7 +1158,9 @@ export default class Notes {
// Setup comment form // Setup comment form
let newForm; 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'); const $form = $noteContainer.find('> .discussion-form');
if ($form.length === 0) { if ($form.length === 0) {
...@@ -1000,13 +1173,17 @@ export default class Notes { ...@@ -1000,13 +1173,17 @@ export default class Notes {
this.setupDiscussionNoteForm($link, newForm); this.setupDiscussionNoteForm($link, newForm);
} }
toggleDiffNote({ toggleDiffNote({ target, lineType, forceShow, showReplyInput = false }) {
target, var $link,
lineType, addForm,
forceShow, hasNotes,
showReplyInput = false, newForm,
}) { noteForm,
var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar; replyButton,
row,
rowCssToAdd,
targetContent,
isDiffCommentAvatar;
$link = $(target); $link = $(target);
row = $link.closest('tr'); row = $link.closest('tr');
const nextRow = row.next(); const nextRow = row.next();
...@@ -1018,11 +1195,13 @@ export default class Notes { ...@@ -1018,11 +1195,13 @@ export default class Notes {
hasNotes = nextRow.is('.notes_holder'); hasNotes = nextRow.is('.notes_holder');
addForm = false; addForm = false;
let lineTypeSelector = ''; 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 // In parallel view, look inside the correct left/right pane
if (this.isParallelView()) { if (this.isParallelView()) {
lineTypeSelector = `.${lineType}`; 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`; const notesContentSelector = `.notes_content${lineTypeSelector} .content`;
let notesContent = targetRow.find(notesContentSelector); let notesContent = targetRow.find(notesContentSelector);
...@@ -1050,7 +1229,9 @@ export default class Notes { ...@@ -1050,7 +1229,9 @@ export default class Notes {
notesContent = targetRow.find(notesContentSelector); notesContent = targetRow.find(notesContentSelector);
addForm = true; addForm = true;
} else { } 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 isForced = forceShow === true || forceShow === false;
const showNow = forceShow === true || (!isCurrentlyShown && !isForced); const showNow = forceShow === true || (!isCurrentlyShown && !isForced);
...@@ -1077,11 +1258,12 @@ export default class Notes { ...@@ -1077,11 +1258,12 @@ export default class Notes {
row = form.closest('tr'); row = form.closest('tr');
glForm = form.data('glForm'); glForm = form.data('glForm');
glForm.destroy(); glForm.destroy();
form.find('.js-note-text').data('autosave').reset();
// show the reply button (will only work for replies)
form form
.prev('.discussion-reply-holder') .find('.js-note-text')
.show(); .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')) { if (row.is('.js-temp-notes-holder')) {
// remove temporary row for diff lines // remove temporary row for diff lines
return row.remove(); return row.remove();
...@@ -1122,7 +1304,9 @@ export default class Notes { ...@@ -1122,7 +1304,9 @@ export default class Notes {
var filename, form; var filename, form;
form = $(this).closest('form'); form = $(this).closest('form');
// get only the basename // get only the basename
filename = $(this).val().replace(/^.*[\\\/]/, ''); filename = $(this)
.val()
.replace(/^.*[\\\/]/, '');
return form.find('.js-attachment-filename').text(filename); return form.find('.js-attachment-filename').text(filename);
} }
...@@ -1194,12 +1378,16 @@ export default class Notes { ...@@ -1194,12 +1378,16 @@ export default class Notes {
this.glForm = new GLForm($editForm.find('form'), this.enableGFM); this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
$editForm.find('form') $editForm
.find('form')
.attr('action', `${postUrl}?html=true`) .attr('action', `${postUrl}?html=true`)
.attr('data-remote', 'true'); .attr('data-remote', 'true');
$editForm.find('.js-form-target-id').val(targetId); $editForm.find('.js-form-target-id').val(targetId);
$editForm.find('.js-form-target-type').val(targetType); $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('.js-md-write-button').trigger('click');
$editForm.find('.referenced-users').hide(); $editForm.find('.referenced-users').hide();
} }
...@@ -1208,7 +1396,9 @@ export default class Notes { ...@@ -1208,7 +1396,9 @@ export default class Notes {
if ($note.find('.js-conflict-edit-warning').length === 0) { if ($note.find('.js-conflict-edit-warning').length === 0) {
const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger"> const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger">
This comment has changed since you started editing, please review the 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 updated comment
</a> </a>
to ensure information is not lost to ensure information is not lost
...@@ -1218,12 +1408,15 @@ export default class Notes { ...@@ -1218,12 +1408,15 @@ export default class Notes {
} }
updateNotesCount(updateCount) { 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) { static renderPlaceholderComponent($container) {
const el = $container.find('.js-code-placeholder').get(0); const el = $container.find('.js-code-placeholder').get(0);
new Vue({ // eslint-disable-line no-new new Vue({
// eslint-disable-line no-new
el, el,
components: { components: {
SkeletonLoadingContainer, SkeletonLoadingContainer,
...@@ -1248,7 +1441,9 @@ export default class Notes { ...@@ -1248,7 +1441,9 @@ export default class Notes {
$container.find('.line_content').html( $container.find('.line_content').html(
$(` $(`
<div class="nothing-here-block"> <div class="nothing-here-block">
${__('Unable to load the diff.')} <a class="js-toggle-lazy-diff" href="javascript:void(0)">Try again</a>? ${__(
'Unable to load the diff.',
)} <a class="js-toggle-lazy-diff" href="javascript:void(0)">Try again</a>?
</div> </div>
`), `),
); );
...@@ -1266,7 +1461,8 @@ export default class Notes { ...@@ -1266,7 +1461,8 @@ export default class Notes {
const fileHolder = $container.find('.file-holder'); const fileHolder = $container.find('.file-holder');
const url = fileHolder.data('linesPath'); const url = fileHolder.data('linesPath');
axios.get(url) axios
.get(url)
.then(({ data }) => { .then(({ data }) => {
Notes.renderDiffContent($container, data); Notes.renderDiffContent($container, data);
}) })
...@@ -1277,9 +1473,14 @@ export default class Notes { ...@@ -1277,9 +1473,14 @@ export default class Notes {
toggleCommitList(e) { toggleCommitList(e) {
const $element = $(e.currentTarget); 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'); $closestSystemCommitList.toggleClass('hide-shade');
} }
...@@ -1289,11 +1490,17 @@ export default class Notes { ...@@ -1289,11 +1490,17 @@ export default class Notes {
* intrusive. * intrusive.
*/ */
collapseLongCommitList() { 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) { $.each(systemNotes, function(index, systemNote) {
const $systemNote = $(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); $systemNote.find('.note-header .system-note-message').html(headerMessage);
...@@ -1301,7 +1508,9 @@ export default class Notes { ...@@ -1301,7 +1508,9 @@ export default class Notes {
$systemNote.find('.note-text').addClass('system-note-commit-list'); $systemNote.find('.note-text').addClass('system-note-commit-list');
$systemNote.find('.system-note-commit-list-toggler').show(); $systemNote.find('.system-note-commit-list-toggler').show();
} else { } else {
$systemNote.find('.note-text').addClass('system-note-commit-list hide-shade'); $systemNote
.find('.note-text')
.addClass('system-note-commit-list hide-shade');
} }
}); });
} }
...@@ -1319,14 +1528,10 @@ export default class Notes { ...@@ -1319,14 +1528,10 @@ export default class Notes {
cleanForm($form) { cleanForm($form) {
// Remove JS classes that are not needed here // Remove JS classes that are not needed here
$form $form.find('.js-comment-type-dropdown').removeClass('btn-group');
.find('.js-comment-type-dropdown')
.removeClass('btn-group');
// Remove dropdown // Remove dropdown
$form $form.find('.dropdown-menu').remove();
.find('.dropdown-menu')
.remove();
return $form; return $form;
} }
...@@ -1345,7 +1550,11 @@ export default class Notes { ...@@ -1345,7 +1550,11 @@ export default class Notes {
// There can be CRLF vs LF mismatches if we don't sanitize and compare the same way // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
const sanitizedNoteEntityText = normalizeNewlines(noteEntity.note.trim()); const sanitizedNoteEntityText = normalizeNewlines(noteEntity.note.trim());
const currentNoteText = normalizeNewlines( const currentNoteText = normalizeNewlines(
$note.find('.original-note-content').first().text().trim() $note
.find('.original-note-content')
.first()
.text()
.trim(),
); );
return sanitizedNoteEntityText !== currentNoteText; return sanitizedNoteEntityText !== currentNoteText;
} }
...@@ -1435,7 +1644,14 @@ export default class Notes { ...@@ -1435,7 +1644,14 @@ export default class Notes {
* Once comment is _actually_ posted on server, we will have final element * Once comment is _actually_ posted on server, we will have final element
* in response that we will show in place of this temporary 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 discussionClass = isDiscussionNote ? 'discussion' : '';
const $tempNote = $( const $tempNote = $(
`<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry"> `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
...@@ -1449,8 +1665,12 @@ export default class Notes { ...@@ -1449,8 +1665,12 @@ export default class Notes {
<div class="note-header"> <div class="note-header">
<div class="note-header-info"> <div class="note-header-info">
<a href="/${_.escape(currentUsername)}"> <a href="/${_.escape(currentUsername)}">
<span class="hidden-xs">${_.escape(currentUsername)}</span> <span class="hidden-xs">${_.escape(
<span class="note-headline-light">${_.escape(currentUsername)}</span> currentUsername,
)}</span>
<span class="note-headline-light">${_.escape(
currentUsername,
)}</span>
</a> </a>
</div> </div>
</div> </div>
...@@ -1461,11 +1681,13 @@ export default class Notes { ...@@ -1461,11 +1681,13 @@ export default class Notes {
</div> </div>
</div> </div>
</div> </div>
</li>` </li>`,
); );
$tempNote.find('.hidden-xs').text(_.escape(currentUserFullname)); $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; return $tempNote;
} }
...@@ -1481,7 +1703,7 @@ export default class Notes { ...@@ -1481,7 +1703,7 @@ export default class Notes {
<i>${formContent}</i> <i>${formContent}</i>
</div> </div>
</div> </div>
</li>` </li>`,
); );
return $tempNote; return $tempNote;
...@@ -1513,11 +1735,22 @@ export default class Notes { ...@@ -1513,11 +1735,22 @@ export default class Notes {
const $submitBtn = $(e.target); const $submitBtn = $(e.target);
let $form = $submitBtn.parents('form'); let $form = $submitBtn.parents('form');
const $closeBtn = $form.find('.js-note-target-close'); 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 isMainForm = $form.hasClass('js-main-target-form');
const isDiscussionForm = $form.hasClass('js-discussion-note-form'); const isDiscussionForm = $form.hasClass('js-discussion-note-form');
const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button'); const isDiscussionResolve = $submitBtn.hasClass(
const { formData, formContent, formAction, formContentOriginal } = this.getFormData($form); 'js-comment-resolve-button',
);
const {
formData,
formContent,
formAction,
formContentOriginal,
} = this.getFormData($form);
let noteUniqueId; let noteUniqueId;
let systemNoteUniqueId; let systemNoteUniqueId;
let hasQuickActions = false; let hasQuickActions = false;
...@@ -1547,23 +1780,30 @@ export default class Notes { ...@@ -1547,23 +1780,30 @@ export default class Notes {
// Show placeholder note // Show placeholder note
if (tempFormContent) { if (tempFormContent) {
noteUniqueId = _.uniqueId('tempNote_'); noteUniqueId = _.uniqueId('tempNote_');
$notesContainer.append(this.createPlaceholderNote({ $notesContainer.append(
formContent: tempFormContent, this.createPlaceholderNote({
uniqueId: noteUniqueId, formContent: tempFormContent,
isDiscussionNote, uniqueId: noteUniqueId,
currentUsername: gon.current_username, isDiscussionNote,
currentUserFullname: gon.current_user_fullname, currentUsername: gon.current_username,
currentUserAvatar: gon.current_user_avatar_url, currentUserFullname: gon.current_user_fullname,
})); currentUserAvatar: gon.current_user_avatar_url,
}),
);
} }
// Show placeholder system note // Show placeholder system note
if (hasQuickActions) { if (hasQuickActions) {
systemNoteUniqueId = _.uniqueId('tempSystemNote_'); systemNoteUniqueId = _.uniqueId('tempSystemNote_');
$notesContainer.append(this.createPlaceholderSystemNote({ $notesContainer.append(
formContent: this.getQuickActionDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)), this.createPlaceholderSystemNote({
uniqueId: systemNoteUniqueId, formContent: this.getQuickActionDescription(
})); formContent,
AjaxCache.get(gl.GfmAutoComplete.dataSources.commands),
),
uniqueId: systemNoteUniqueId,
}),
);
} }
// Clear the form textarea // Clear the form textarea
...@@ -1577,8 +1817,9 @@ export default class Notes { ...@@ -1577,8 +1817,9 @@ export default class Notes {
/* eslint-disable promise/catch-or-return */ /* eslint-disable promise/catch-or-return */
// Make request to submit comment on server // Make request to submit comment on server
axios.post(`${formAction}?html=true`, formData) axios
.then((res) => { .post(`${formAction}?html=true`, formData)
.then(res => {
const note = res.data; const note = res.data;
// Submission successful! remove placeholder // Submission successful! remove placeholder
...@@ -1595,7 +1836,9 @@ export default class Notes { ...@@ -1595,7 +1836,9 @@ export default class Notes {
// Reset cached commands list when command is applied // Reset cached commands list when command is applied
if (hasQuickActions) { 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 // Clear previous form errors
...@@ -1640,11 +1883,14 @@ export default class Notes { ...@@ -1640,11 +1883,14 @@ export default class Notes {
// append flash-container to the Notes list // append flash-container to the Notes list
if ($notesContainer.length) { 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(); 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 // Show final note element on UI and perform form and action buttons cleanup
this.addNote($form, note); this.addNote($form, note);
this.reenableTargetFormSubmitButton(e); this.reenableTargetFormSubmitButton(e);
...@@ -1655,7 +1901,8 @@ export default class Notes { ...@@ -1655,7 +1901,8 @@ export default class Notes {
} }
$form.trigger('ajax:success', [note]); $form.trigger('ajax:success', [note]);
}).catch(() => { })
.catch(() => {
// Submission failed, remove placeholder note and show Flash error message // Submission failed, remove placeholder note and show Flash error message
$notesContainer.find(`#${noteUniqueId}`).remove(); $notesContainer.find(`#${noteUniqueId}`).remove();
...@@ -1675,7 +1922,9 @@ export default class Notes { ...@@ -1675,7 +1922,9 @@ export default class Notes {
// Show form again on UI on failure // Show form again on UI on failure
if (isDiscussionForm && $notesContainer.length) { 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]); this.replyToDiscussionNote(replyButton[0]);
$form = $notesContainer.parent().find('form'); $form = $notesContainer.parent().find('form');
} }
...@@ -1720,12 +1969,19 @@ export default class Notes { ...@@ -1720,12 +1969,19 @@ export default class Notes {
// Show updated comment content temporarily // Show updated comment content temporarily
$noteBodyText.html(formContent); $noteBodyText.html(formContent);
$editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half'); $editingNote
$editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>'); .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 */ /* eslint-disable promise/catch-or-return */
// Make request to update comment on server // Make request to update comment on server
axios.post(`${formAction}?html=true`, formData) axios
.post(`${formAction}?html=true`, formData)
.then(({ data }) => { .then(({ data }) => {
// Submission successful! render final note element // Submission successful! render final note element
this.updateNote(data, $editingNote); this.updateNote(data, $editingNote);
......
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import _ from 'underscore'; import _ from 'underscore';
import Autosize from 'autosize'; import Autosize from 'autosize';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import Flash from '../../flash'; import Flash from '../../flash';
import Autosave from '../../autosave'; import Autosave from '../../autosave';
import TaskList from '../../task_list'; import TaskList from '../../task_list';
import { capitalizeFirstCharacter, convertToCamelCase } from '../../lib/utils/text_utility'; import {
import * as constants from '../constants'; capitalizeFirstCharacter,
import eventHub from '../event_hub'; convertToCamelCase,
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; } from '../../lib/utils/text_utility';
import markdownField from '../../vue_shared/components/markdown/field.vue'; import * as constants from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import eventHub from '../event_hub';
import loadingButton from '../../vue_shared/components/loading_button.vue'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue';
import discussionLockedWidget from './discussion_locked_widget.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import issuableStateMixin from '../mixins/issuable_state'; 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 { export default {
name: 'CommentForm', name: 'CommentForm',
components: { components: {
issueWarning, issueWarning,
noteSignedOutWidget, noteSignedOutWidget,
discussionLockedWidget, discussionLockedWidget,
markdownField, markdownField,
userAvatarLink, userAvatarLink,
loadingButton, loadingButton,
},
mixins: [issuableStateMixin],
props: {
noteableType: {
type: String,
required: true,
}, },
mixins: [ },
issuableStateMixin, data() {
], return {
props: { note: '',
noteableType: { noteType: constants.COMMENT,
type: String, isSubmitting: false,
required: true, isSubmitButtonDisabled: true,
}, };
},
computed: {
...mapGetters([
'getCurrentUserLastNote',
'getUserData',
'getNoteableData',
'getNotesData',
'openState',
]),
...mapState(['isToggleStateButtonLoading']),
noteableDisplayName() {
return this.noteableType.replace(/_/g, ' ');
}, },
data() { isLoggedIn() {
return { return this.getUserData.id;
note: '', },
noteType: constants.COMMENT, commentButtonTitle() {
isSubmitting: false, return this.noteType === constants.COMMENT
isSubmitButtonDisabled: true, ? 'Comment'
}; : 'Start discussion';
}, },
computed: { isOpen() {
...mapGetters([ return (
'getCurrentUserLastNote', this.openState === constants.OPENED ||
'getUserData', this.openState === constants.REOPENED
'getNoteableData', );
'getNotesData', },
'openState', canCreateNote() {
]), return this.getNoteableData.current_user.can_create_note;
...mapState([ },
'isToggleStateButtonLoading', issueActionButtonTitle() {
]), const openOrClose = this.isOpen ? 'close' : 'reopen';
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';
if (this.note.length) { if (this.note.length) {
return sprintf( return sprintf(__('%{actionText} & %{openOrClose} %{noteable}'), {
__('%{actionText} & %{openOrClose} %{noteable}'), actionText: this.commentButtonTitle,
{ openOrClose,
actionText: this.commentButtonTitle, noteable: this.noteableDisplayName,
openOrClose, });
noteable: this.noteableDisplayName, }
},
);
}
return sprintf( return sprintf(__('%{openOrClose} %{noteable}'), {
__('%{openOrClose} %{noteable}'), openOrClose: capitalizeFirstCharacter(openOrClose),
{ noteable: this.noteableDisplayName,
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;
},
}, },
watch: { actionButtonClassNames() {
note(newNote) { return {
this.setIsSubmitButtonDisabled(newNote, this.isSubmitting); 'btn-reopen': !this.isOpen,
}, 'btn-close': this.isOpen,
isSubmitting(newValue) { 'js-note-target-close': this.isOpen,
this.setIsSubmitButtonDisabled(this.note, newValue); 'js-note-target-reopen': !this.isOpen,
}, };
}, },
mounted() { markdownDocsPath() {
// jQuery is needed here because it is a custom event being dispatched with jQuery. return this.getNotesData.markdownDocsPath;
$(document).on('issuable:change', (e, isClosed) => { },
this.toggleIssueLocalState(isClosed ? constants.CLOSED : constants.REOPENED); 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.initAutoSave();
this.initTaskList(); 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: { handleSave(withIssueAction) {
...mapActions([ this.isSubmitting = true;
'saveNote',
'stopPolling',
'restartPolling',
'removePlaceholderNotes',
'closeIssue',
'reopenIssue',
'toggleIssueLocalState',
'toggleStateButtonLoading',
]),
setIsSubmitButtonDisabled(note, isSubmitting) {
if (!_.isEmpty(note) && !isSubmitting) {
this.isSubmitButtonDisabled = false;
} else {
this.isSubmitButtonDisabled = true;
}
},
handleSave(withIssueAction) {
this.isSubmitting = true;
if (this.note.length) { if (this.note.length) {
const noteData = { const noteData = {
endpoint: this.endpoint, endpoint: this.endpoint,
flashContainer: this.$el, flashContainer: this.$el,
data: { data: {
note: { note: {
noteable_type: this.noteableType, noteable_type: this.noteableType,
noteable_id: this.getNoteableData.id, noteable_id: this.getNoteableData.id,
note: this.note, note: this.note,
},
}, },
}; },
};
if (this.noteType === constants.DISCUSSION) { if (this.noteType === constants.DISCUSSION) {
noteData.data.note.type = constants.DISCUSSION_NOTE; noteData.data.note.type = constants.DISCUSSION_NOTE;
} }
this.note = ''; // Empty textarea while being requested. Repopulate in catch this.note = ''; // Empty textarea while being requested. Repopulate in catch
this.resizeTextarea(); this.resizeTextarea();
this.stopPolling(); this.stopPolling();
this.saveNote(noteData) this.saveNote(noteData)
.then((res) => { .then(res => {
this.enableButton(); this.enableButton();
this.restartPolling(); this.restartPolling();
if (res.errors) { if (res.errors) {
if (res.errors.commands_only) { 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(); this.discard();
} else {
Flash(
'Something went wrong while adding your comment. Please try again.',
'alert',
this.$refs.commentForm,
);
} }
} else {
this.discard();
}
if (withIssueAction) { if (withIssueAction) {
this.toggleIssueState(); this.toggleIssueState();
} }
}) })
.catch(() => { .catch(() => {
this.enableButton(); this.enableButton();
this.discard(false); this.discard(false);
const msg = const msg = `Your comment could not be submitted!
`Your comment could not be submitted!
Please check your network connection and try again.`; Please check your network connection and try again.`;
Flash(msg, 'alert', this.$el); Flash(msg, 'alert', this.$el);
this.note = noteData.data.note.note; // Restore textarea content. this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes(); this.removePlaceholderNotes();
}); });
} else { } else {
this.toggleIssueState(); this.toggleIssueState();
} }
}, },
enableButton() { enableButton() {
this.isSubmitting = false; this.isSubmitting = false;
}, },
toggleIssueState() { toggleIssueState() {
if (this.isOpen) { if (this.isOpen) {
this.closeIssue() this.closeIssue()
.then(() => this.enableButton()) .then(() => this.enableButton())
.catch(() => { .catch(() => {
this.enableButton(); this.enableButton();
this.toggleStateButtonLoading(false); this.toggleStateButtonLoading(false);
Flash( Flash(
sprintf( sprintf(
__('Something went wrong while closing the %{issuable}. Please try again later'), __(
{ issuable: this.noteableDisplayName }, 'Something went wrong while closing the %{issuable}. Please try again later',
), ),
); { issuable: this.noteableDisplayName },
}); ),
} else { );
this.reopenIssue() });
.then(() => this.enableButton()) } else {
.catch(() => { this.reopenIssue()
this.enableButton(); .then(() => this.enableButton())
this.toggleStateButtonLoading(false); .catch(() => {
Flash( this.enableButton();
sprintf( this.toggleStateButtonLoading(false);
__('Something went wrong while reopening the %{issuable}. Please try again later'), Flash(
{ issuable: this.noteableDisplayName }, sprintf(
__(
'Something went wrong while reopening the %{issuable}. Please try again later',
), ),
); { 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. discard(shouldClear = true) {
this.$refs.textarea.blur(); // `blur` is needed to clear slash commands autocomplete cache if event fired.
this.$refs.textarea.focus(); // `focus` is needed to remain cursor in the textarea.
this.$refs.textarea.blur();
this.$refs.textarea.focus();
if (shouldClear) { if (shouldClear) {
this.note = ''; this.note = '';
this.resizeTextarea(); this.resizeTextarea();
this.$refs.markdownField.previewMarkdown = false; this.$refs.markdownField.previewMarkdown = false;
} }
this.autosave.reset(); this.autosave.reset();
}, },
setNoteType(type) { setNoteType(type) {
this.noteType = type; this.noteType = type;
}, },
editCurrentUserLastNote() { editCurrentUserLastNote() {
if (this.note === '') { if (this.note === '') {
const lastNote = this.getCurrentUserLastNote; const lastNote = this.getCurrentUserLastNote;
if (lastNote) { if (lastNote) {
eventHub.$emit('enterEditMode', { eventHub.$emit('enterEditMode', {
noteId: lastNote.id, noteId: lastNote.id,
}); });
}
} }
}, }
initAutoSave() { },
if (this.isLoggedIn) { initAutoSave() {
const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType)); if (this.isLoggedIn) {
const noteableType = capitalizeFirstCharacter(
convertToCamelCase(this.noteableType),
);
this.autosave = new Autosave( this.autosave = new Autosave($(this.$refs.textarea), [
$(this.$refs.textarea), 'Note',
['Note', noteableType, this.getNoteableData.id], noteableType,
); this.getNoteableData.id,
} ]);
}, }
initTaskList() { },
return new TaskList({ initTaskList() {
dataType: 'note', return new TaskList({
fieldName: 'note', dataType: 'note',
selector: '.notes', fieldName: 'note',
}); selector: '.notes',
}, });
resizeTextarea() { },
this.$nextTick(() => { resizeTextarea() {
Autosize.update(this.$refs.textarea); this.$nextTick(() => {
}); Autosize.update(this.$refs.textarea);
}, });
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
export default { export default {
components: { components: {
ClipboardButton, ClipboardButton,
Icon, Icon,
},
props: {
diffFile: {
type: Object,
required: true,
}, },
props: { },
diffFile: { computed: {
type: Object, titleTag() {
required: true, return this.diffFile.discussionPath ? 'a' : 'span';
},
}, },
computed: { },
titleTag() { };
return this.diffFile.discussionPath ? 'a' : 'span';
},
},
};
</script> </script>
<template> <template>
......
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import syntaxHighlight from '~/syntax_highlight'; import syntaxHighlight from '~/syntax_highlight';
import imageDiffHelper from '~/image_diff/helpers/index'; import imageDiffHelper from '~/image_diff/helpers/index';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import DiffFileHeader from './diff_file_header.vue'; import DiffFileHeader from './diff_file_header.vue';
export default { export default {
components: { components: {
DiffFileHeader, DiffFileHeader,
},
props: {
discussion: {
type: Object,
required: true,
}, },
props: { },
discussion: { computed: {
type: Object, isImageDiff() {
required: true, return !this.diffFile.text;
},
}, },
computed: { diffFileClass() {
isImageDiff() { const { text } = this.diffFile;
return !this.diffFile.text; return text ? 'text-file' : 'js-image-file';
},
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;
},
}, },
mounted() { diffRows() {
if (this.isImageDiff) { return $(this.discussion.truncatedDiffLines);
const canCreateNote = false;
const renderCommentBadge = true;
imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge);
} else {
const fileHolder = $(this.$refs.fileHolder);
this.$nextTick(() => {
syntaxHighlight(fileHolder);
});
}
}, },
methods: { diffFile() {
rowTag(html) { return convertObjectPropsToCamelCase(this.discussion.diffFile);
return html.outerHTML ? 'tr' : 'template';
},
}, },
}; 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> </script>
<template> <template>
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import resolveSvg from 'icons/_icon_resolve_discussion.svg'; import resolveSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedSvg from 'icons/_icon_status_success_solid.svg'; import resolvedSvg from 'icons/_icon_status_success_solid.svg';
import mrIssueSvg from 'icons/_icon_mr_issue.svg'; import mrIssueSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionSvg from 'icons/_next_discussion.svg'; import nextDiscussionSvg from 'icons/_next_discussion.svg';
import { pluralize } from '../../lib/utils/text_utility'; import { pluralize } from '../../lib/utils/text_utility';
import { scrollToElement } from '../../lib/utils/common_utils'; import { scrollToElement } from '../../lib/utils/common_utils';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
export default { export default {
directives: { directives: {
tooltip, tooltip,
},
computed: {
...mapGetters([
'getUserData',
'getNoteableData',
'discussionCount',
'unresolvedDiscussions',
'resolvedDiscussionCount',
]),
isLoggedIn() {
return this.getUserData.id;
}, },
computed: { hasNextButton() {
...mapGetters([ return this.isLoggedIn && !this.allResolved;
'getUserData', },
'getNoteableData', countText() {
'discussionCount', return pluralize('discussion', this.discussionCount);
'unresolvedDiscussions', },
'resolvedDiscussionCount', allResolved() {
]), return this.resolvedDiscussionCount === this.discussionCount;
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;
},
}, },
created() { resolveAllDiscussionsIssuePath() {
this.resolveSvg = resolveSvg; return this.getNoteableData.create_issue_to_resolve_discussions_path;
this.resolvedSvg = resolvedSvg; },
this.mrIssueSvg = mrIssueSvg; firstUnresolvedDiscussionId() {
this.nextDiscussionSvg = nextDiscussionSvg; const item = this.unresolvedDiscussions[0] || {};
return item.id;
}, },
methods: { },
jumpToFirstDiscussion() { created() {
const el = document.querySelector(`[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`); this.resolveSvg = resolveSvg;
const activeTab = window.mrTabs.currentAction; 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') { if (activeTab === 'commits' || activeTab === 'pipelines') {
window.mrTabs.activateTab('show'); window.mrTabs.activateTab('show');
} }
if (el) { if (el) {
scrollToElement(el); scrollToElement(el);
} }
},
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import Issuable from '~/vue_shared/mixins/issuable'; import Issuable from '~/vue_shared/mixins/issuable';
export default { export default {
components: { components: {
Icon, Icon,
}, },
mixins: [ mixins: [Issuable],
Issuable, };
],
};
</script> </script>
<template> <template>
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
import emojiSmile from 'icons/_emoji_smile.svg'; import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg'; import emojiSmiley from 'icons/_emoji_smiley.svg';
import editSvg from 'icons/_icon_pencil.svg'; import editSvg from 'icons/_icon_pencil.svg';
import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg'; import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg'; import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg';
import ellipsisSvg from 'icons/_ellipsis_v.svg'; import ellipsisSvg from 'icons/_ellipsis_v.svg';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
export default { export default {
name: 'NoteActions', name: 'NoteActions',
directives: { directives: {
tooltip, tooltip,
}, },
components: { components: {
loadingIcon, loadingIcon,
}, },
props: { props: {
authorId: { authorId: {
type: Number, type: Number,
required: true, required: true,
}, },
noteId: { noteId: {
type: Number, type: Number,
required: true, required: true,
}, },
accessLevel: { accessLevel: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
}, },
reportAbusePath: { reportAbusePath: {
type: String, type: String,
required: true, required: true,
}, },
canEdit: { canEdit: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
canDelete: { canDelete: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
resolvable: { resolvable: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
isResolved: { isResolved: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
isResolving: { isResolving: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
resolvedBy: { resolvedBy: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
canReportAsAbuse: { canReportAsAbuse: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
}, },
computed: { computed: {
...mapGetters([ ...mapGetters(['getUserDataByProp']),
'getUserDataByProp', shouldShowActionsDropdown() {
]), return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
shouldShowActionsDropdown() { },
return this.currentUserId && (this.canEdit || this.canReportAsAbuse); canAddAwardEmoji() {
}, return this.currentUserId;
canAddAwardEmoji() { },
return this.currentUserId; isAuthoredByCurrentUser() {
}, return this.authorId === this.currentUserId;
isAuthoredByCurrentUser() { },
return this.authorId === this.currentUserId; currentUserId() {
}, return this.getUserDataByProp('id');
currentUserId() { },
return this.getUserDataByProp('id'); resolveButtonTitle() {
}, let title = 'Mark as resolved';
resolveButtonTitle() {
let title = 'Mark as resolved';
if (this.resolvedBy) { if (this.resolvedBy) {
title = `Resolved by ${this.resolvedBy.name}`; title = `Resolved by ${this.resolvedBy.name}`;
} }
return title; return title;
}, },
}, },
created() { created() {
this.emojiSmiling = emojiSmiling; this.emojiSmiling = emojiSmiling;
this.emojiSmile = emojiSmile; this.emojiSmile = emojiSmile;
this.emojiSmiley = emojiSmiley; this.emojiSmiley = emojiSmiley;
this.editSvg = editSvg; this.editSvg = editSvg;
this.ellipsisSvg = ellipsisSvg; this.ellipsisSvg = ellipsisSvg;
this.resolveDiscussionSvg = resolveDiscussionSvg; this.resolveDiscussionSvg = resolveDiscussionSvg;
this.resolvedDiscussionSvg = resolvedDiscussionSvg; this.resolvedDiscussionSvg = resolvedDiscussionSvg;
}, },
methods: { methods: {
onEdit() { onEdit() {
this.$emit('handleEdit'); this.$emit('handleEdit');
}, },
onDelete() { onDelete() {
this.$emit('handleDelete'); this.$emit('handleDelete');
}, },
onResolve() { onResolve() {
this.$emit('handleResolve'); this.$emit('handleResolve');
}, },
}, },
}; };
</script> </script>
<template> <template>
......
<script> <script>
export default { export default {
name: 'NoteAttachment', name: 'NoteAttachment',
props: { props: {
attachment: { attachment: {
type: Object, type: Object,
required: true, required: true,
},
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
import emojiSmile from 'icons/_emoji_smile.svg'; import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg'; import emojiSmiley from 'icons/_emoji_smiley.svg';
import Flash from '../../flash'; import Flash from '../../flash';
import { glEmojiTag } from '../../emoji'; import { glEmojiTag } from '../../emoji';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
export default { export default {
directives: { directives: {
tooltip, tooltip,
},
props: {
awards: {
type: Array,
required: true,
}, },
props: { toggleAwardPath: {
awards: { type: String,
type: Array, required: true,
required: true,
},
toggleAwardPath: {
type: String,
required: true,
},
noteAuthorId: {
type: Number,
required: true,
},
noteId: {
type: Number,
required: true,
},
}, },
computed: { noteAuthorId: {
...mapGetters([ type: Number,
'getUserData', required: true,
]),
// `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;
},
}, },
created() { noteId: {
this.emojiSmiling = emojiSmiling; type: Number,
this.emojiSmile = emojiSmile; required: true,
this.emojiSmiley = emojiSmiley;
}, },
methods: { },
...mapActions([ computed: {
'toggleAwardRequest', ...mapGetters(['getUserData']),
]), // `this.awards` is an array with emojis but they are not grouped by emoji name. See below.
getAwardHTML(name) { // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ]
return glEmojiTag(name); // This method will group emojis by their name as an Object. See below.
}, // {
getAwardClassBindings(awardList, awardName) { // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ],
return { // bar: [ { name: bar, user: user1 } ]
active: this.hasReactionByCurrentUser(awardList), // }
disabled: !this.canInteractWithEmoji(awardList, awardName), // We need to do this otherwise we will render the same emoji over and over again.
}; groupedAwards() {
}, const awards = this.awards.reduce((acc, award) => {
canInteractWithEmoji(awardList, awardName) { if (Object.prototype.hasOwnProperty.call(acc, award.name)) {
let isAllowed = true; acc[award.name].push(award);
const restrictedEmojis = ['thumbsup', 'thumbsdown']; } else {
Object.assign(acc, { [award.name]: [award] });
// 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 = { return acc;
endpoint: this.toggleAwardPath, }, {});
noteId: this.noteId,
awardName: parsedName, const orderedAwards = {};
}; const { thumbsdown, thumbsup } = awards;
// Always show thumbsup and thumbsdown first
this.toggleAwardRequest(data) if (thumbsup) {
.catch(() => Flash('Something went wrong on our end.')); 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> </script>
<template> <template>
......
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import noteEditedText from './note_edited_text.vue'; import noteEditedText from './note_edited_text.vue';
import noteAwardsList from './note_awards_list.vue'; import noteAwardsList from './note_awards_list.vue';
import noteAttachment from './note_attachment.vue'; import noteAttachment from './note_attachment.vue';
import noteForm from './note_form.vue'; import noteForm from './note_form.vue';
import TaskList from '../../task_list'; import TaskList from '../../task_list';
import autosave from '../mixins/autosave'; import autosave from '../mixins/autosave';
export default { export default {
components: { components: {
noteEditedText, noteEditedText,
noteAwardsList, noteAwardsList,
noteAttachment, noteAttachment,
noteForm, noteForm,
},
mixins: [autosave],
props: {
note: {
type: Object,
required: true,
}, },
mixins: [ canEdit: {
autosave, type: Boolean,
], required: true,
props: {
note: {
type: Object,
required: true,
},
canEdit: {
type: Boolean,
required: true,
},
isEditing: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { isEditing: {
noteBody() { type: Boolean,
return this.note.note; required: false,
}, default: false,
}, },
mounted() { },
this.renderGFM(); computed: {
this.initTaskList(); 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); this.initAutoSave(this.note.noteable_type);
} else {
this.setAutoSave();
} }
}
},
methods: {
renderGFM() {
$(this.$refs['note-body']).renderGFM();
}, },
updated() { initTaskList() {
this.initTaskList(); if (this.canEdit) {
this.renderGFM(); this.taskList = new TaskList({
dataType: 'note',
if (this.isEditing) { fieldName: 'note',
if (!this.autosave) { selector: '.notes',
this.initAutoSave(this.note.noteable_type); });
} else {
this.setAutoSave();
}
} }
}, },
methods: { handleFormUpdate(note, parentElement, callback) {
renderGFM() { this.$emit('handleFormUpdate', note, parentElement, callback);
$(this.$refs['note-body']).renderGFM(); },
}, formCancelHandler(shouldConfirm, isDirty) {
initTaskList() { this.$emit('cancelFormEdition', shouldConfirm, isDirty);
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);
},
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default { export default {
name: 'EditedNoteText', name: 'EditedNoteText',
components: { components: {
timeAgoTooltip, timeAgoTooltip,
},
props: {
actionText: {
type: String,
required: true,
}, },
props: { editedAt: {
actionText: { type: String,
type: String, required: true,
required: true,
},
editedAt: {
type: String,
required: true,
},
editedBy: {
type: Object,
required: false,
default: () => ({}),
},
className: {
type: String,
required: false,
default: 'edited-text',
},
}, },
}; editedBy: {
type: Object,
required: false,
default: () => ({}),
},
className: {
type: String,
required: false,
default: 'edited-text',
},
},
};
</script> </script>
<template> <template>
......
<script> <script>
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state'; import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable'; import resolvable from '../mixins/resolvable';
export default { export default {
name: 'IssueNoteForm', name: 'IssueNoteForm',
components: { components: {
issueWarning, issueWarning,
markdownField, markdownField,
},
mixins: [issuableStateMixin, resolvable],
props: {
noteBody: {
type: String,
required: false,
default: '',
}, },
mixins: [ noteId: {
issuableStateMixin, type: Number,
resolvable, required: false,
], default: 0,
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,
},
}, },
data() { saveButtonTitle: {
return { type: String,
updatedNoteBody: this.noteBody, required: false,
conflictWhileEditing: false, default: 'Save comment',
isSubmitting: false,
isResolving: false,
resolveAsThread: true,
};
}, },
computed: { note: {
...mapGetters([ type: Object,
'getDiscussionLastNote', required: false,
'getNoteableData', default: () => ({}),
'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;
},
}, },
watch: { isEditing: {
noteBody() { type: Boolean,
if (this.updatedNoteBody === this.noteBody) { required: true,
this.updatedNoteBody = this.noteBody; },
} else { },
this.conflictWhileEditing = 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() { currentUserId() {
this.$refs.textarea.focus(); return this.getUserDataByProp('id');
}, },
methods: { isDisabled() {
...mapActions([ return !this.updatedNoteBody.length || this.isSubmitting;
'toggleResolveNote', },
]), },
handleUpdate(shouldResolve) { watch: {
const beforeSubmitDiscussionState = this.discussionResolved; noteBody() {
this.isSubmitting = true; 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; this.isSubmitting = false;
if (shouldResolve) { if (shouldResolve) {
this.resolveHandler(beforeSubmitDiscussionState); this.resolveHandler(beforeSubmitDiscussionState);
} }
}); },
}, );
editMyLastNote() { },
if (this.updatedNoteBody === '') { editMyLastNote() {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.updatedNoteBody); if (this.updatedNoteBody === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(
this.updatedNoteBody,
);
if (lastNoteInDiscussion) { if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', { eventHub.$emit('enterEditMode', {
noteId: lastNoteInDiscussion.id, noteId: lastNoteInDiscussion.id,
}); });
}
} }
}, }
cancelHandler(shouldConfirm = false) { },
// Sends information about confirm message and if the textarea has changed cancelHandler(shouldConfirm = false) {
this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.updatedNoteBody); // Sends information about confirm message and if the textarea has changed
}, this.$emit(
'cancelFormEdition',
shouldConfirm,
this.noteBody !== this.updatedNoteBody,
);
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default { export default {
components: { components: {
timeAgoTooltip, timeAgoTooltip,
},
props: {
author: {
type: Object,
required: true,
}, },
props: { createdAt: {
author: { type: String,
type: Object, required: true,
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,
},
}, },
computed: { actionText: {
toggleChevronClass() { type: String,
return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down'; required: false,
}, default: '',
noteTimestampLink() {
return `#note_${this.noteId}`;
},
}, },
methods: { actionTextHtml: {
...mapActions([ type: String,
'setTargetNoteHash', required: false,
]), default: '',
handleToggle() {
this.$emit('toggleHandler');
},
updateTargetNoteHash() {
this.setTargetNoteHash(this.noteTimestampLink);
},
}, },
}; 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> </script>
<template> <template>
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
export default { export default {
computed: { computed: {
...mapGetters([ ...mapGetters(['getNotesDataByProp']),
'getNotesDataByProp', registerLink() {
]), return this.getNotesDataByProp('registerPath');
registerLink() {
return this.getNotesDataByProp('registerPath');
},
signInLink() {
return this.getNotesDataByProp('newSessionPath');
},
}, },
}; signInLink() {
return this.getNotesDataByProp('newSessionPath');
},
},
};
</script> </script>
<template> <template>
......
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg'; import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionsSvg from 'icons/_next_discussion.svg'; import nextDiscussionsSvg from 'icons/_next_discussion.svg';
import Flash from '../../flash'; import Flash from '../../flash';
import { SYSTEM_NOTE } from '../constants'; import { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import noteableNote from './noteable_note.vue'; import noteableNote from './noteable_note.vue';
import noteHeader from './note_header.vue'; import noteHeader from './note_header.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue';
import noteEditedText from './note_edited_text.vue'; import noteEditedText from './note_edited_text.vue';
import noteForm from './note_form.vue'; import noteForm from './note_form.vue';
import diffWithNote from './diff_with_note.vue'; import diffWithNote from './diff_with_note.vue';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import autosave from '../mixins/autosave'; import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable'; import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable'; import resolvable from '../mixins/resolvable';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
import { scrollToElement } from '../../lib/utils/common_utils'; import { scrollToElement } from '../../lib/utils/common_utils';
export default { export default {
components: { components: {
noteableNote, noteableNote,
diffWithNote, diffWithNote,
userAvatarLink, userAvatarLink,
noteHeader, noteHeader,
noteSignedOutWidget, noteSignedOutWidget,
noteEditedText, noteEditedText,
noteForm, noteForm,
placeholderNote, placeholderNote,
placeholderSystemNote, placeholderSystemNote,
},
directives: {
tooltip,
},
mixins: [autosave, noteable, resolvable],
props: {
note: {
type: Object,
required: true,
}, },
directives: { },
tooltip, data() {
}, return {
mixins: [ isReplying: false,
autosave, isResolving: false,
noteable, resolveAsThread: true,
resolvable, };
], },
props: { computed: {
note: { ...mapGetters([
type: Object, 'getNoteableData',
required: true, 'discussionCount',
}, 'resolvedDiscussionCount',
}, 'unresolvedDiscussions',
data() { ]),
discussion() {
return { return {
isReplying: false, ...this.note.notes[0],
isResolving: false, truncatedDiffLines: this.note.truncated_diff_lines,
resolveAsThread: true, diffFile: this.note.diff_file,
diffDiscussion: this.note.diff_discussion,
imageDiffHtml: this.note.image_diff_html,
}; };
}, },
computed: { author() {
...mapGetters([ return this.discussion.author;
'getNoteableData', },
'discussionCount', canReply() {
'resolvedDiscussionCount', return this.getNoteableData.current_user.can_create_note;
'unresolvedDiscussions', },
]), newNotePath() {
discussion() { return this.getNoteableData.create_note_path;
return { },
...this.note.notes[0], lastUpdatedBy() {
truncatedDiffLines: this.note.truncated_diff_lines, const { notes } = this.note;
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;
if (notes.length > 1) { if (notes.length > 1) {
return notes[notes.length - 1].author; return notes[notes.length - 1].author;
} }
return null; return null;
}, },
lastUpdatedAt() { lastUpdatedAt() {
const { notes } = this.note; const { notes } = this.note;
if (notes.length > 1) { if (notes.length > 1) {
return notes[notes.length - 1].created_at; return notes[notes.length - 1].created_at;
} }
return null; return null;
}, },
hasUnresolvedDiscussion() { hasUnresolvedDiscussion() {
return this.unresolvedDiscussions.length > 0; return this.unresolvedDiscussions.length > 0;
}, },
wrapperComponent() { wrapperComponent() {
return (this.discussion.diffDiscussion && this.discussion.diffFile) ? diffWithNote : 'div'; return this.discussion.diffDiscussion && this.discussion.diffFile
}, ? diffWithNote
wrapperClass() { : 'div';
return this.isDiffDiscussion ? '' : 'panel panel-default';
},
}, },
mounted() { wrapperClass() {
if (this.isReplying) { 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); this.initAutoSave(this.discussion.noteable_type);
} else {
this.setAutoSave();
} }
}, }
updated() { },
if (this.isReplying) { created() {
if (!this.autosave) { this.resolveDiscussionsSvg = resolveDiscussionsSvg;
this.initAutoSave(this.discussion.noteable_type); this.nextDiscussionsSvg = nextDiscussionsSvg;
} else { },
this.setAutoSave(); methods: {
...mapActions([
'saveNote',
'toggleDiscussion',
'removePlaceholderNotes',
'toggleResolveNote',
]),
componentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === SYSTEM_NOTE) {
return placeholderSystemNote;
} }
return placeholderNote;
} }
return noteableNote;
}, },
created() { componentData(note) {
this.resolveDiscussionsSvg = resolveDiscussionsSvg; return note.isPlaceholderNote ? this.note.notes[0] : note;
this.nextDiscussionsSvg = nextDiscussionsSvg;
}, },
methods: { toggleDiscussionHandler() {
...mapActions([ this.toggleDiscussion({ discussionId: this.note.id });
'saveNote', },
'toggleDiscussion', showReplyForm() {
'removePlaceholderNotes', this.isReplying = true;
'toggleResolveNote', },
]), cancelReplyForm(shouldConfirm) {
componentName(note) { if (shouldConfirm && this.$refs.noteForm.isDirty) {
if (note.isPlaceholderNote) { const msg = 'Are you sure you want to cancel creating this comment?';
if (note.placeholderType === SYSTEM_NOTE) {
return placeholderSystemNote;
}
return placeholderNote;
}
return noteableNote; // eslint-disable-next-line no-alert
}, if (!confirm(msg)) {
componentData(note) { return;
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;
}
} }
}
this.resetAutoSave(); this.resetAutoSave();
this.isReplying = false; this.isReplying = false;
}, },
saveReply(noteText, form, callback) { saveReply(noteText, form, callback) {
const replyData = { const replyData = {
endpoint: this.newNotePath, endpoint: this.newNotePath,
flashContainer: this.$el, flashContainer: this.$el,
data: { data: {
in_reply_to_discussion_id: this.note.reply_id, in_reply_to_discussion_id: this.note.reply_id,
target_type: this.noteableType, target_type: this.noteableType,
target_id: this.discussion.noteable_id, target_id: this.discussion.noteable_id,
note: { note: noteText }, note: { note: noteText },
}, },
}; };
this.isReplying = false; this.isReplying = false;
this.saveNote(replyData) this.saveNote(replyData)
.then(() => { .then(() => {
this.resetAutoSave(); this.resetAutoSave();
callback(); callback();
}) })
.catch((err) => { .catch(err => {
this.removePlaceholderNotes(); this.removePlaceholderNotes();
this.isReplying = true; this.isReplying = true;
this.$nextTick(() => { this.$nextTick(() => {
const msg = `Your comment could not be submitted! const msg = `Your comment could not be submitted!
Please check your network connection and try again.`; Please check your network connection and try again.`;
Flash(msg, 'alert', this.$el); Flash(msg, 'alert', this.$el);
this.$refs.noteForm.note = noteText; this.$refs.noteForm.note = noteText;
callback(err); callback(err);
});
}); });
}, });
jumpToDiscussion() { },
const unresolvedIds = this.unresolvedDiscussions.map(d => d.id); jumpToDiscussion() {
const index = unresolvedIds.indexOf(this.note.id); const unresolvedIds = this.unresolvedDiscussions.map(d => d.id);
const index = unresolvedIds.indexOf(this.note.id);
if (index >= 0 && index !== unresolvedIds.length) { if (index >= 0 && index !== unresolvedIds.length) {
const nextId = unresolvedIds[index + 1]; const nextId = unresolvedIds[index + 1];
const el = document.querySelector(`[data-discussion-id="${nextId}"]`); const el = document.querySelector(`[data-discussion-id="${nextId}"]`);
if (el) { if (el) {
scrollToElement(el); scrollToElement(el);
}
} }
}, }
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import { escape } from 'underscore'; import { escape } from 'underscore';
import Flash from '../../flash'; import Flash from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import noteHeader from './note_header.vue'; import noteHeader from './note_header.vue';
import noteActions from './note_actions.vue'; import noteActions from './note_actions.vue';
import noteBody from './note_body.vue'; import noteBody from './note_body.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import noteable from '../mixins/noteable'; import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable'; import resolvable from '../mixins/resolvable';
export default { export default {
components: { components: {
userAvatarLink, userAvatarLink,
noteHeader, noteHeader,
noteActions, noteActions,
noteBody, noteBody,
},
mixins: [noteable, resolvable],
props: {
note: {
type: Object,
required: true,
}, },
mixins: [ },
noteable, data() {
resolvable, return {
], isEditing: false,
props: { isDeleting: false,
note: { isRequesting: false,
type: Object, isResolving: false,
required: true, };
}, },
computed: {
...mapGetters(['targetNoteHash', 'getUserData']),
author() {
return this.note.author;
}, },
data() { classNameBindings() {
return { return {
isEditing: false, 'is-editing': this.isEditing && !this.isRequesting,
isDeleting: false, 'is-requesting being-posted': this.isRequesting,
isRequesting: false, 'disabled-content': this.isDeleting,
isResolving: false, target: this.targetNoteHash === this.noteAnchorId,
}; };
}, },
computed: { canReportAsAbuse() {
...mapGetters([ return (
'targetNoteHash', this.note.report_abuse_path && this.author.id !== this.getUserData.id
'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}`;
},
}, },
noteAnchorId() {
created() { return `note_${this.note.id}`;
eventHub.$on('enterEditMode', ({ noteId }) => {
if (noteId === this.note.id) {
this.isEditing = true;
this.scrollToNoteIfNeeded($(this.$el));
}
});
}, },
},
methods: { created() {
...mapActions([ eventHub.$on('enterEditMode', ({ noteId }) => {
'deleteNote', if (noteId === this.note.id) {
'updateNote',
'toggleResolveNote',
'scrollToNoteIfNeeded',
]),
editHandler() {
this.isEditing = true; this.isEditing = true;
}, this.scrollToNoteIfNeeded($(this.$el));
deleteHandler() { }
// eslint-disable-next-line no-alert });
if (confirm('Are you sure you want to delete this comment?')) { },
this.isDeleting = true;
this.deleteNote(this.note) methods: {
.then(() => { ...mapActions([
this.isDeleting = false; 'deleteNote',
}) 'updateNote',
.catch(() => { 'toggleResolveNote',
Flash('Something went wrong while deleting your note. Please try again.'); 'scrollToNoteIfNeeded',
this.isDeleting = false; ]),
}); editHandler() {
} this.isEditing = true;
}, },
formUpdateHandler(noteText, parentElement, callback) { deleteHandler() {
const data = { // eslint-disable-next-line no-alert
endpoint: this.note.path, if (confirm('Are you sure you want to delete this comment?')) {
note: { this.isDeleting = true;
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) this.deleteNote(this.note)
.then(() => { .then(() => {
this.isEditing = false; this.isDeleting = false;
this.isRequesting = false;
this.oldContent = null;
$(this.$refs.noteBody.$el).renderGFM();
this.$refs.noteBody.resetAutoSave();
callback();
}) })
.catch(() => { .catch(() => {
this.isRequesting = false; Flash(
this.isEditing = true; 'Something went wrong while deleting your note. Please try again.',
this.$nextTick(() => { );
const msg = 'Something went wrong while editing your comment. Please try again.'; this.isDeleting = false;
Flash(msg, 'alert', this.$el);
this.recoverNoteContent(noteText);
callback();
});
}); });
}, }
formCancelHandler(shouldConfirm, isDirty) { },
if (shouldConfirm && isDirty) { formUpdateHandler(noteText, parentElement, callback) {
// eslint-disable-next-line no-alert const data = {
if (!confirm('Are you sure you want to cancel editing this comment?')) return; endpoint: this.note.path,
} note: {
this.$refs.noteBody.resetAutoSave(); target_type: this.noteableType,
if (this.oldContent) { target_id: this.note.noteable_id,
this.note.note_html = this.oldContent; 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.oldContent = null;
} $(this.$refs.noteBody.$el).renderGFM();
this.isEditing = false; this.$refs.noteBody.resetAutoSave();
}, callback();
recoverNoteContent(noteText) { })
// we need to do this to prevent noteForm inconsistent content warning .catch(() => {
// this is something we intentionally do so we need to recover the content this.isRequesting = false;
this.note.note = noteText; this.isEditing = true;
this.$refs.noteBody.$refs.noteForm.note.note = noteText; 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> </script>
<template> <template>
......
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import { getLocationHash } from '../../lib/utils/url_utility'; import { getLocationHash } from '../../lib/utils/url_utility';
import Flash from '../../flash'; import Flash from '../../flash';
import store from '../stores/'; import store from '../stores/';
import * as constants from '../constants'; import * as constants from '../constants';
import noteableNote from './noteable_note.vue'; import noteableNote from './noteable_note.vue';
import noteableDiscussion from './noteable_discussion.vue'; import noteableDiscussion from './noteable_discussion.vue';
import systemNote from '../../vue_shared/components/notes/system_note.vue'; import systemNote from '../../vue_shared/components/notes/system_note.vue';
import commentForm from './comment_form.vue'; import commentForm from './comment_form.vue';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
export default { export default {
name: 'NotesApp', name: 'NotesApp',
components: { components: {
noteableNote, noteableNote,
noteableDiscussion, noteableDiscussion,
systemNote, systemNote,
commentForm, commentForm,
loadingIcon, loadingIcon,
placeholderNote, placeholderNote,
placeholderSystemNote, placeholderSystemNote,
},
props: {
noteableData: {
type: Object,
required: true,
}, },
props: { notesData: {
noteableData: { type: Object,
type: Object, required: true,
required: true,
},
notesData: {
type: Object,
required: true,
},
userData: {
type: Object,
required: false,
default: () => ({}),
},
}, },
store, userData: {
data() { type: Object,
return { required: false,
isLoading: true, default: () => ({}),
};
}, },
computed: { },
...mapGetters([ store,
'notes', data() {
'getNotesDataByProp', return {
'discussionCount', isLoading: true,
]), };
noteableType() { },
// FIXME -- @fatihacet Get this from JSON data. computed: {
const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants; ...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; return this.noteableData.merge_params
}, ? MERGE_REQUEST_NOTEABLE_TYPE
allNotes() { : ISSUE_NOTEABLE_TYPE;
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);
}, },
mounted() { allNotes() {
this.fetchNotes(); if (this.isLoading) {
const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0;
const parentElement = this.$el.parentElement; return new Array(totalNotes).fill({
isSkeletonNote: true,
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); return this.notes;
},
beforeDestroy() {
document.removeEventListener('refreshVueNotes', this.fetchNotes);
}, },
methods: { },
...mapActions({ created() {
actionFetchNotes: 'fetchNotes', this.setNotesData(this.notesData);
poll: 'poll', this.setNoteableData(this.noteableData);
actionToggleAward: 'toggleAward', this.setUserData(this.userData);
scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', },
setNotesData: 'setNotesData', mounted() {
setNoteableData: 'setNoteableData', this.fetchNotes();
setUserData: 'setUserData',
setLastFetchedAt: 'setLastFetchedAt', const parentElement = this.$el.parentElement;
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;
}
return noteableDiscussion; if (
}, parentElement &&
getComponentData(note) { parentElement.classList.contains('js-vue-notes-event')
return note.individual_note ? note.notes[0] : note; ) {
}, parentElement.addEventListener('toggleAward', event => {
fetchNotes() { const { awardName, noteId } = event.detail;
return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath')) this.actionToggleAward({ awardName, noteId });
.then(() => this.initPolling()) });
.then(() => { }
this.isLoading = false; document.addEventListener('refreshVueNotes', this.fetchNotes);
}) },
.then(() => this.$nextTick()) beforeDestroy() {
.then(() => this.checkLocationHash()) document.removeEventListener('refreshVueNotes', this.fetchNotes);
.catch(() => { },
this.isLoading = false; methods: {
Flash('Something went wrong while fetching comments. Please try again.'); ...mapActions({
}); actionFetchNotes: 'fetchNotes',
}, poll: 'poll',
initPolling() { actionToggleAward: 'toggleAward',
if (this.isPollingInitialized) { scrollToNoteIfNeeded: 'scrollToNoteIfNeeded',
return; 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.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
this.isPollingInitialized = true;
},
checkLocationHash() {
const hash = getLocationHash();
const element = document.getElementById(hash);
if (hash && element) { this.poll();
this.setTargetNoteHash(hash); this.isPollingInitialized = true;
this.scrollToNoteIfNeeded($(element)); },
} checkLocationHash() {
}, const hash = getLocationHash();
const element = document.getElementById(hash);
if (hash && element) {
this.setTargetNoteHash(hash);
this.scrollToNoteIfNeeded($(element));
}
}, },
}; },
};
</script> </script>
<template> <template>
......
import Vue from 'vue'; import Vue from 'vue';
import notesApp from './components/notes_app.vue'; import notesApp from './components/notes_app.vue';
document.addEventListener('DOMContentLoaded', () => new Vue({ document.addEventListener(
el: '#js-vue-notes', 'DOMContentLoaded',
components: { () =>
notesApp, new Vue({
}, el: '#js-vue-notes',
data() { components: {
const notesDataset = document.getElementById('js-vue-notes').dataset; notesApp,
const parsedUserData = JSON.parse(notesDataset.currentUserData); },
const currentUserData = parsedUserData ? { data() {
id: parsedUserData.id, const notesDataset = document.getElementById('js-vue-notes').dataset;
name: parsedUserData.name, const parsedUserData = JSON.parse(notesDataset.currentUserData);
username: parsedUserData.username, let currentUserData = {};
avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url,
path: parsedUserData.path, if (parsedUserData) {
} : {}; currentUserData = {
id: parsedUserData.id,
name: parsedUserData.name,
username: parsedUserData.username,
avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url,
path: parsedUserData.path,
};
}
return { return {
noteableData: JSON.parse(notesDataset.noteableData), noteableData: JSON.parse(notesDataset.noteableData),
currentUserData, currentUserData,
notesData: JSON.parse(notesDataset.notesData), notesData: JSON.parse(notesDataset.notesData),
}; };
}, },
render(createElement) { render(createElement) {
return createElement('notes-app', { return createElement('notes-app', {
props: { props: {
noteableData: this.noteableData, noteableData: this.noteableData,
notesData: this.notesData, notesData: this.notesData,
userData: this.currentUserData, userData: this.currentUserData,
},
});
}, },
}); }),
}, );
}));
...@@ -5,7 +5,11 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; ...@@ -5,7 +5,11 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
export default { export default {
methods: { methods: {
initAutoSave(noteableType) { 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() { resetAutoSave() {
this.autosave.reset(); this.autosave.reset();
......
...@@ -12,7 +12,8 @@ export default { ...@@ -12,7 +12,8 @@ export default {
discussionResolved() { discussionResolved() {
const { notes, resolved } = this.note; 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); return notes.every(note => note.resolved && !note.system);
} }
...@@ -26,7 +27,9 @@ export default { ...@@ -26,7 +27,9 @@ export default {
return __('Comment and resolve discussion'); return __('Comment and resolve discussion');
} }
return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion'); return this.discussionResolved
? __('Unresolve discussion')
: __('Resolve discussion');
}, },
}, },
methods: { methods: {
...@@ -42,7 +45,9 @@ export default { ...@@ -42,7 +45,9 @@ export default {
}) })
.catch(() => { .catch(() => {
this.isResolving = false; 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); Flash(msg, 'alert', this.$el);
}); });
}, },
......
...@@ -22,7 +22,9 @@ export default { ...@@ -22,7 +22,9 @@ export default {
}, },
toggleResolveNote(endpoint, isResolved) { toggleResolveNote(endpoint, isResolved) {
const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants; 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); return Vue.http[method](endpoint);
}, },
......
...@@ -12,97 +12,115 @@ import { isInViewport, scrollToElement } from '../../lib/utils/common_utils'; ...@@ -12,97 +12,115 @@ import { isInViewport, scrollToElement } from '../../lib/utils/common_utils';
let eTagPoll; let eTagPoll;
export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data); export const setNotesData = ({ commit }, data) =>
export const setNoteableData = ({ commit }, data) => commit(types.SET_NOTEABLE_DATA, data); commit(types.SET_NOTES_DATA, data);
export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data); export const setNoteableData = ({ commit }, data) =>
export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data); commit(types.SET_NOTEABLE_DATA, data);
export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data); export const setUserData = ({ commit }, data) =>
export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data); commit(types.SET_USER_DATA, data);
export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); export const setLastFetchedAt = ({ commit }, data) =>
commit(types.SET_LAST_FETCHED_AT, data);
export const fetchNotes = ({ commit }, path) => service export const setInitialNotes = ({ commit }, data) =>
.fetchNotes(path) commit(types.SET_INITIAL_NOTES, data);
.then(res => res.json()) export const setTargetNoteHash = ({ commit }, data) =>
.then((res) => { commit(types.SET_TARGET_NOTE_HASH, data);
commit(types.SET_INITIAL_NOTES, res); 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 export const deleteNote = ({ commit }, note) =>
.deleteNote(note.path) service.deleteNote(note.path).then(() => {
.then(() => {
commit(types.DELETE_NOTE, note); commit(types.DELETE_NOTE, note);
}); });
export const updateNote = ({ commit }, { endpoint, note }) => service export const updateNote = ({ commit }, { endpoint, note }) =>
.updateNote(endpoint, note) service
.then(res => res.json()) .updateNote(endpoint, note)
.then((res) => { .then(res => res.json())
commit(types.UPDATE_NOTE, res); .then(res => {
}); commit(types.UPDATE_NOTE, res);
});
export const replyToDiscussion = ({ commit }, { endpoint, data }) => service export const replyToDiscussion = ({ commit }, { endpoint, data }) =>
.replyToDiscussion(endpoint, data) service
.then(res => res.json()) .replyToDiscussion(endpoint, data)
.then((res) => { .then(res => res.json())
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); .then(res => {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
return res; return res;
}); });
export const createNewNote = ({ commit }, { endpoint, data }) => service export const createNewNote = ({ commit }, { endpoint, data }) =>
.createNewNote(endpoint, data) service
.then(res => res.json()) .createNewNote(endpoint, data)
.then((res) => { .then(res => res.json())
if (!res.errors) { .then(res => {
commit(types.ADD_NEW_NOTE, res); if (!res.errors) {
} commit(types.ADD_NEW_NOTE, res);
return res; }
}); return res;
});
export const removePlaceholderNotes = ({ commit }) => export const removePlaceholderNotes = ({ commit }) =>
commit(types.REMOVE_PLACEHOLDER_NOTES); commit(types.REMOVE_PLACEHOLDER_NOTES);
export const toggleResolveNote = ({ commit }, { endpoint, isResolved, discussion }) => service export const toggleResolveNote = (
.toggleResolveNote(endpoint, isResolved) { commit },
.then(res => res.json()) { endpoint, isResolved, discussion },
.then((res) => { ) =>
const mutationType = discussion ? types.UPDATE_DISCUSSION : types.UPDATE_NOTE; 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 }) => { export const closeIssue = ({ commit, dispatch, state }) => {
dispatch('toggleStateButtonLoading', true); dispatch('toggleStateButtonLoading', true);
return service return service
.toggleIssueState(state.notesData.closePath) .toggleIssueState(state.notesData.closePath)
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then(data => {
commit(types.CLOSE_ISSUE); commit(types.CLOSE_ISSUE);
dispatch('emitStateChangedEvent', data); dispatch('emitStateChangedEvent', data);
dispatch('toggleStateButtonLoading', false); dispatch('toggleStateButtonLoading', false);
}); });
}; };
export const reopenIssue = ({ commit, dispatch, state }) => { export const reopenIssue = ({ commit, dispatch, state }) => {
dispatch('toggleStateButtonLoading', true); dispatch('toggleStateButtonLoading', true);
return service return service
.toggleIssueState(state.notesData.reopenPath) .toggleIssueState(state.notesData.reopenPath)
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then(data => {
commit(types.REOPEN_ISSUE); commit(types.REOPEN_ISSUE);
dispatch('emitStateChangedEvent', data); dispatch('emitStateChangedEvent', data);
dispatch('toggleStateButtonLoading', false); dispatch('toggleStateButtonLoading', false);
}); });
}; };
export const toggleStateButtonLoading = ({ commit }, value) => export const toggleStateButtonLoading = ({ commit }, value) =>
commit(types.TOGGLE_STATE_BUTTON_LOADING, value); commit(types.TOGGLE_STATE_BUTTON_LOADING, value);
export const emitStateChangedEvent = ({ commit, getters }, data) => { export const emitStateChangedEvent = ({ commit, getters }, data) => {
const event = new CustomEvent('issuable_vue_app:change', { detail: { const event = new CustomEvent('issuable_vue_app:change', {
data, detail: {
isClosed: getters.openState === constants.CLOSED, data,
} }); isClosed: getters.openState === constants.CLOSED,
},
});
document.dispatchEvent(event); document.dispatchEvent(event);
}; };
...@@ -144,59 +162,70 @@ export const saveNote = ({ commit, dispatch }, noteData) => { ...@@ -144,59 +162,70 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
}); });
} }
return dispatch(methodToDispatch, noteData) return dispatch(methodToDispatch, noteData).then(res => {
.then((res) => { const { errors } = res;
const { errors } = res; const commandsChanges = res.commands_changes;
const commandsChanges = res.commands_changes;
if (hasQuickActions && errors && Object.keys(errors).length) { if (hasQuickActions && errors && Object.keys(errors).length) {
eTagPoll.makeRequest(); eTagPoll.makeRequest();
$('.js-gfm-input').trigger('clear-commands-cache.atwho');
Flash('Commands applied', 'notice', noteData.flashContainer);
}
if (commandsChanges) { $('.js-gfm-input').trigger('clear-commands-cache.atwho');
if (commandsChanges.emoji_award) { Flash('Commands applied', 'notice', noteData.flashContainer);
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 (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) { if (commandsChanges) {
sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res); 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) { if (
Flash(errors.commands_only, 'notice', noteData.flashContainer); 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) => { const pollSuccessCallBack = (resp, commit, state, getters) => {
if (resp.notes && resp.notes.length) { if (resp.notes && resp.notes.length) {
const { notesById } = getters; const { notesById } = getters;
resp.notes.forEach((note) => { resp.notes.forEach(note => {
if (notesById[note.id]) { if (notesById[note.id]) {
commit(types.UPDATE_NOTE, note); commit(types.UPDATE_NOTE, note);
} else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { } else if (
const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); note.type === constants.DISCUSSION_NOTE ||
note.type === constants.DIFF_NOTE
) {
const discussion = utils.findNoteObjectById(
state.notes,
note.discussion_id,
);
if (discussion) { if (discussion) {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note);
...@@ -219,9 +248,12 @@ export const poll = ({ commit, state, getters }) => { ...@@ -219,9 +248,12 @@ export const poll = ({ commit, state, getters }) => {
resource: service, resource: service,
method: 'poll', method: 'poll',
data: state, data: state,
successCallback: resp => resp.json() successCallback: resp =>
.then(data => pollSuccessCallBack(data, commit, state, getters)), resp
errorCallback: () => Flash('Something went wrong while fetching latest comments.'), .json()
.then(data => pollSuccessCallBack(data, commit, state, getters)),
errorCallback: () =>
Flash('Something went wrong while fetching latest comments.'),
}); });
if (!Visibility.hidden()) { if (!Visibility.hidden()) {
...@@ -248,15 +280,22 @@ export const restartPolling = () => { ...@@ -248,15 +280,22 @@ export const restartPolling = () => {
}; };
export const fetchData = ({ commit, state, getters }) => { 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(resp => resp.json)
.then(data => pollSuccessCallBack(data, commit, state, getters)) .then(data => pollSuccessCallBack(data, commit, state, getters))
.catch(() => Flash('Something went wrong while fetching latest comments.')); .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] }); commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] });
}; };
......
...@@ -11,27 +11,31 @@ export const getNoteableDataByProp = state => prop => state.noteableData[prop]; ...@@ -11,27 +11,31 @@ export const getNoteableDataByProp = state => prop => state.noteableData[prop];
export const openState = state => state.noteableData.state; export const openState = state => state.noteableData.state;
export const getUserData = state => state.userData || {}; 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) => { export const notesById = state =>
note.notes.every(n => Object.assign(acc, { [n.id]: n })); state.notes.reduce((acc, note) => {
return acc; note.notes.every(n => Object.assign(acc, { [n.id]: n }));
}, {}); return acc;
}, {});
const reverseNotes = array => array.slice(0).reverse(); const reverseNotes = array => array.slice(0).reverse();
const isLastNote = (note, state) => !note.system && const isLastNote = (note, state) =>
state.userData && note.author && !note.system &&
state.userData &&
note.author &&
note.author.id === state.userData.id; note.author.id === state.userData.id;
export const getCurrentUserLastNote = state => _.flatten( export const getCurrentUserLastNote = state =>
reverseNotes(state.notes) _.flatten(
.map(note => reverseNotes(note.notes)), reverseNotes(state.notes).map(note => reverseNotes(note.notes)),
).find(el => isLastNote(el, state)); ).find(el => isLastNote(el, state));
export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes) export const getDiscussionLastNote = state => discussion =>
.find(el => isLastNote(el, state)); 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); const discussions = state.notes.filter(n => !n.individual_note);
return discussions.length; return discussions.length;
...@@ -43,10 +47,10 @@ export const unresolvedDiscussions = (state, getters) => { ...@@ -43,10 +47,10 @@ export const unresolvedDiscussions = (state, getters) => {
return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]); return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]);
}; };
export const resolvedDiscussionsById = (state) => { export const resolvedDiscussionsById = state => {
const map = {}; const map = {};
state.notes.forEach((n) => { state.notes.forEach(n => {
if (n.notes) { if (n.notes) {
const resolved = n.notes.every(note => note.resolved && !note.system); const resolved = n.notes.every(note => note.resolved && !note.system);
......
...@@ -7,7 +7,7 @@ export default { ...@@ -7,7 +7,7 @@ export default {
[types.ADD_NEW_NOTE](state, note) { [types.ADD_NEW_NOTE](state, note) {
const { discussion_id, type } = note; const { discussion_id, type } = note;
const [exists] = state.notes.filter(n => n.id === note.discussion_id); 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) { if (!exists) {
const noteData = { const noteData = {
...@@ -63,13 +63,15 @@ export default { ...@@ -63,13 +63,15 @@ export default {
const note = notes[i]; const note = notes[i];
const children = note.notes; 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) { for (let j = children.length - 1; j >= 0; j -= 1) {
if (children[j].isPlaceholderNote) { if (children[j].isPlaceholderNote) {
children.splice(j, 1); 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); notes.splice(i, 1);
} }
} }
...@@ -89,10 +91,10 @@ export default { ...@@ -89,10 +91,10 @@ export default {
[types.SET_INITIAL_NOTES](state, notesData) { [types.SET_INITIAL_NOTES](state, notesData) {
const notes = []; const notes = [];
notesData.forEach((note) => { notesData.forEach(note => {
// To support legacy notes, should be very rare case. // To support legacy notes, should be very rare case.
if (note.individual_note && note.notes.length > 1) { if (note.individual_note && note.notes.length > 1) {
note.notes.forEach((n) => { note.notes.forEach(n => {
notes.push({ notes.push({
...note, ...note,
notes: [n], // override notes array to only have one item to mimick individual_note notes: [n], // override notes array to only have one item to mimick individual_note
...@@ -103,7 +105,7 @@ export default { ...@@ -103,7 +105,7 @@ export default {
notes.push({ notes.push({
...note, ...note,
expanded: (oldNote ? oldNote.expanded : note.expanded), expanded: oldNote ? oldNote.expanded : note.expanded,
}); });
} }
}); });
...@@ -128,7 +130,9 @@ export default { ...@@ -128,7 +130,9 @@ export default {
notesArr.push({ notesArr.push({
individual_note: true, individual_note: true,
isPlaceholderNote: true, isPlaceholderNote: true,
placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE, placeholderType: data.isSystemNote
? constants.SYSTEM_NOTE
: constants.NOTE,
notes: [ notes: [
{ {
body: data.noteBody, body: data.noteBody,
...@@ -141,12 +145,16 @@ export default { ...@@ -141,12 +145,16 @@ export default {
const { awardName, note } = data; const { awardName, note } = data;
const { id, name, username } = state.userData; const { id, name, username } = state.userData;
const hasEmojiAwardedByCurrentUser = note.award_emoji const hasEmojiAwardedByCurrentUser = note.award_emoji.filter(
.filter(emoji => emoji.name === data.awardName && emoji.user.id === id); emoji => emoji.name === data.awardName && emoji.user.id === id,
);
if (hasEmojiAwardedByCurrentUser.length) { if (hasEmojiAwardedByCurrentUser.length) {
// If current user has awarded this emoji, remove it. // 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 { } else {
note.award_emoji.push({ note.award_emoji.push({
name: awardName, name: awardName,
......
...@@ -2,13 +2,15 @@ import AjaxCache from '~/lib/utils/ajax_cache'; ...@@ -2,13 +2,15 @@ import AjaxCache from '~/lib/utils/ajax_cache';
const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; 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'; 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}`); const commandRegex = new RegExp(`/${command.name}`);
return commandRegex.test(note); return commandRegex.test(note);
}); });
...@@ -27,4 +29,5 @@ export const getQuickActionText = (note) => { ...@@ -27,4 +29,5 @@ export const getQuickActionText = (note) => {
export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(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();
import NotificationsForm from '../../../../notifications_form';
import notificationsDropdown from '../../../../notifications_dropdown';
document.addEventListener('DOMContentLoaded', () => {
new NotificationsForm(); // eslint-disable-line no-new
notificationsDropdown();
});
...@@ -16,7 +16,7 @@ ul.notes { ...@@ -16,7 +16,7 @@ ul.notes {
.note-created-ago, .note-created-ago,
.note-updated-at { .note-updated-at {
white-space: nowrap; white-space: normal;
} }
.discussion-body { .discussion-body {
......
...@@ -16,8 +16,7 @@ class Admin::ProjectsFinder ...@@ -16,8 +16,7 @@ class Admin::ProjectsFinder
items = by_archived(items) items = by_archived(items)
items = by_personal(items) items = by_personal(items)
items = by_name(items) items = by_name(items)
items = sort(items) sort(items).page(params[:page])
items.includes(:namespace).order("namespaces.path, projects.name ASC").page(params[:page])
end end
private private
......
...@@ -1533,8 +1533,8 @@ class Project < ActiveRecord::Base ...@@ -1533,8 +1533,8 @@ class Project < ActiveRecord::Base
@errors = original_errors @errors = original_errors
end end
def add_export_job(current_user:) def add_export_job(current_user:, params: {})
job_id = ProjectExportWorker.perform_async(current_user.id, self.id) job_id = ProjectExportWorker.perform_async(current_user.id, self.id, params)
if job_id if job_id
Rails.logger.info "Export job started for project ID #{self.id} with job ID #{job_id}" Rails.logger.info "Export job started for project ID #{self.id} with job ID #{job_id}"
......
...@@ -26,7 +26,7 @@ module Projects ...@@ -26,7 +26,7 @@ module Projects
end end
def project_tree_saver 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 end
def uploads_saver def uploads_saver
......
...@@ -4,10 +4,11 @@ class ProjectExportWorker ...@@ -4,10 +4,11 @@ class ProjectExportWorker
sidekiq_options retry: 3 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) current_user = User.find(current_user_id)
project = Project.find(project_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
end end
---
title: Adds the option to the project export API to override the project description and display GitLab export description once imported
merge_request: 17744
author:
type: added
---
title: Fix timeouts loading /admin/projects page
merge_request:
author:
type: performance
...@@ -15,9 +15,10 @@ POST /projects/:id/export ...@@ -15,9 +15,10 @@ POST /projects/:id/export
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | -------------- | -------- | ---------------------------------------- | | --------- | -------------- | -------- | ---------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `description` | string | no | Overrides the project description |
```console ```console
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/export curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --form "description=Foo Bar" https://gitlab.example.com/api/v4/projects/1/export
``` ```
```json ```json
......
...@@ -464,7 +464,9 @@ bother us. In any case, it is something to keep in mind when deploying GitLab ...@@ -464,7 +464,9 @@ bother us. In any case, it is something to keep in mind when deploying GitLab
on a production cluster. on a production cluster.
In order to deploy GitLab on a production cluster, you will need to assign the In order to deploy GitLab on a production cluster, you will need to assign the
GitLab service account to the `anyuid` Security Context. GitLab service account to the `anyuid` [Security Context Constraints][scc].
For OpenShift v3.0, you will need to do this manually:
1. Edit the Security Context: 1. Edit the Security Context:
```sh ```sh
...@@ -477,6 +479,12 @@ GitLab service account to the `anyuid` Security Context. ...@@ -477,6 +479,12 @@ GitLab service account to the `anyuid` Security Context.
1. Save and exit the editor 1. Save and exit the editor
For OpenShift v3.1 and above, you can do:
```sh
oc adm policy add-scc-to-user anyuid system:serviceaccount:gitlab:gitlab-ce-user
```
## Conclusion ## Conclusion
By now, you should have an understanding of the basic OpenShift Origin concepts By now, you should have an understanding of the basic OpenShift Origin concepts
...@@ -513,3 +521,4 @@ PaaS and managing your applications with the ease of containers. ...@@ -513,3 +521,4 @@ PaaS and managing your applications with the ease of containers.
[autoscaling]: https://docs.openshift.org/latest/dev_guide/pod_autoscaling.html "Documentation - Autoscale" [autoscaling]: https://docs.openshift.org/latest/dev_guide/pod_autoscaling.html "Documentation - Autoscale"
[basic-cli]: https://docs.openshift.org/latest/cli_reference/basic_cli_operations.html "Documentation - Basic CLI operations" [basic-cli]: https://docs.openshift.org/latest/cli_reference/basic_cli_operations.html "Documentation - Basic CLI operations"
[openshift-docs]: https://docs.openshift.org "OpenShift documentation" [openshift-docs]: https://docs.openshift.org "OpenShift documentation"
[scc]: https://docs.openshift.org/latest/admin_guide/manage_scc.html "Documentation - Managing Security Context Constraints"
\ No newline at end of file
...@@ -53,6 +53,22 @@ To get started with the command line, please read through the ...@@ -53,6 +53,22 @@ To get started with the command line, please read through the
Use GitLab's [file finder](../../../workflow/file_finder.md) to search for files in a repository. Use GitLab's [file finder](../../../workflow/file_finder.md) to search for files in a repository.
### Jupyter Notebook files
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/2508) in GitLab 9.1
[Jupyter][jupyter] Notebook (previously IPython Notebook) files are used for
interactive computing in many fields and contain a complete record of the
user's sessions and include code, narrative text, equations and rich output.
When added to a repository, Jupyter Notebooks with a `.ipynb` extension will be
rendered to HTML when viewed.
![Jupyter Notebook Rich Output](img/jupyter_notebook.png)
Interactive features, including JavaScript plots, will not work when viewed in
GitLab.
## Branches ## Branches
When you submit changes in a new [branch](branches/index.md), you create a new version When you submit changes in a new [branch](branches/index.md), you create a new version
...@@ -158,3 +174,5 @@ Lock your files to prevent any conflicting changes. ...@@ -158,3 +174,5 @@ Lock your files to prevent any conflicting changes.
## Repository's API ## Repository's API
You can access your repos via [repository API](../../../api/repositories.md). You can access your repos via [repository API](../../../api/repositories.md).
[jupyter]: https://jupyter.org
...@@ -31,8 +31,13 @@ module API ...@@ -31,8 +31,13 @@ module API
desc 'Start export' do desc 'Start export' do
detail 'This feature was introduced in GitLab 10.6.' detail 'This feature was introduced in GitLab 10.6.'
end end
params do
optional :description, type: String, desc: 'Override the project description'
end
post ':id/export' do post ':id/export' do
user_project.add_export_job(current_user: current_user) project_export_params = declared_params(include_missing: false)
user_project.add_export_job(current_user: current_user, params: project_export_params)
accepted! accepted!
end end
......
...@@ -52,6 +52,8 @@ module ContainerRegistry ...@@ -52,6 +52,8 @@ module ContainerRegistry
conn.request(:authorization, :bearer, options[:token].to_s) conn.request(:authorization, :bearer, options[:token].to_s)
end end
yield(conn) if block_given?
conn.adapter :net_http conn.adapter :net_http
end end
...@@ -80,8 +82,7 @@ module ContainerRegistry ...@@ -80,8 +82,7 @@ module ContainerRegistry
def faraday def faraday
@faraday ||= Faraday.new(@base_uri) do |conn| @faraday ||= Faraday.new(@base_uri) do |conn|
initialize_connection(conn, @options) initialize_connection(conn, @options, &method(:accept_manifest))
accept_manifest(conn)
end end
end end
......
...@@ -35,6 +35,8 @@ module Gitlab ...@@ -35,6 +35,8 @@ module Gitlab
end end
def restored_project def restored_project
return @project unless @tree_hash
@restored_project ||= restore_project @restored_project ||= restore_project
end end
...@@ -81,9 +83,13 @@ module Gitlab ...@@ -81,9 +83,13 @@ module Gitlab
end end
def restore_project def restore_project
return @project unless @tree_hash params = project_params
if params[:description].present?
params[:description_html] = nil
end
@project.update_columns(project_params) @project.update_columns(params)
@project @project
end end
......
...@@ -5,7 +5,8 @@ module Gitlab ...@@ -5,7 +5,8 @@ module Gitlab
attr_reader :full_path attr_reader :full_path
def initialize(project:, current_user:, shared:) def initialize(project:, current_user:, shared:, params: {})
@params = params
@project = project @project = project
@current_user = current_user @current_user = current_user
@shared = shared @shared = shared
...@@ -25,6 +26,10 @@ module Gitlab ...@@ -25,6 +26,10 @@ module Gitlab
private private
def project_json_tree def project_json_tree
if @params[:description].present?
project_json['description'] = @params[:description]
end
project_json['project_members'] += group_members_json project_json['project_members'] += group_members_json
project_json.to_json project_json.to_json
......
...@@ -16,6 +16,6 @@ feature 'User visits the notifications tab', :js do ...@@ -16,6 +16,6 @@ feature 'User visits the notifications tab', :js do
first('#notifications-button').click first('#notifications-button').click
click_link('On mention') click_link('On mention')
expect(page).to have_content('On mention') expect(page).to have_selector('#notifications-button', text: 'On mention')
end end
end end
...@@ -41,6 +41,7 @@ feature 'Import/Export - project import integration test', :js do ...@@ -41,6 +41,7 @@ feature 'Import/Export - project import integration test', :js do
project = Project.last project = Project.last
expect(project).not_to be_nil expect(project).not_to be_nil
expect(project.description).to eq("Foo Bar")
expect(project.issues).not_to be_empty expect(project.issues).not_to be_empty
expect(project.merge_requests).not_to be_empty expect(project.merge_requests).not_to be_empty
expect(project_hook_exists?(project)).to be true expect(project_hook_exists?(project)).to be true
......
...@@ -42,6 +42,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do ...@@ -42,6 +42,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
expect(project.project_feature.merge_requests_access_level).to eq(ProjectFeature::ENABLED) expect(project.project_feature.merge_requests_access_level).to eq(ProjectFeature::ENABLED)
end end
it 'has the project description' do
expect(Project.find_by_path('project').description).to eq('Nisi et repellendus ut enim quo accusamus vel magnam.')
end
it 'has the project html description' do it 'has the project html description' do
expect(Project.find_by_path('project').description_html).to eq('description') expect(Project.find_by_path('project').description_html).to eq('description')
end end
......
...@@ -29,8 +29,17 @@ describe Gitlab::ImportExport::ProjectTreeSaver do ...@@ -29,8 +29,17 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
project_json(project_tree_saver.full_path) project_json(project_tree_saver.full_path)
end end
context 'with description override' do
let(:params) { { description: 'Foo Bar' } }
let(:project_tree_saver) { described_class.new(project: project, current_user: user, shared: shared, params: params) }
it 'overrides the project description' do
expect(saved_project_json).to include({ 'description' => params[:description] })
end
end
it 'saves the correct json' do it 'saves the correct json' do
expect(saved_project_json).to include({ "visibility_level" => 20 }) expect(saved_project_json).to include({ 'description' => 'description', 'visibility_level' => 20 })
end end
it 'has approvals_before_merge set' do it 'has approvals_before_merge set' do
...@@ -263,6 +272,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver do ...@@ -263,6 +272,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
:issues_disabled, :issues_disabled,
:wiki_enabled, :wiki_enabled,
:builds_private, :builds_private,
description: 'description',
issues: [issue], issues: [issue],
snippets: [snippet], snippets: [snippet],
releases: [release], releases: [release],
......
...@@ -285,6 +285,17 @@ describe API::ProjectExport do ...@@ -285,6 +285,17 @@ describe API::ProjectExport do
context 'when user is not a member' do context 'when user is not a member' do
it_behaves_like 'post project export start not found' it_behaves_like 'post project export start not found'
end end
context 'when overriding description' do
it 'starts' do
params = { description: "Foo" }
expect_any_instance_of(Projects::ImportExport::ExportService).to receive(:execute)
post api(path, project.owner), params
expect(response).to have_gitlab_http_status(202)
end
end
end end
end end
end end
...@@ -285,8 +285,8 @@ production: ...@@ -285,8 +285,8 @@ production:
export CI_APPLICATION_TAG=$CI_COMMIT_SHA export CI_APPLICATION_TAG=$CI_COMMIT_SHA
export CI_CONTAINER_NAME=ci_job_build_${CI_JOB_ID} export CI_CONTAINER_NAME=ci_job_build_${CI_JOB_ID}
export TILLER_NAMESPACE=$KUBE_NAMESPACE export TILLER_NAMESPACE=$KUBE_NAMESPACE
# Extract "MAJOR.MINOR" from CI_SERVER_VERSION and generate "MAJOR-MINOR-stable" for Static Code Analysis # Extract "MAJOR.MINOR" from CI_SERVER_VERSION and generate "MAJOR-MINOR-stable" for Security Products
export SCA_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
function sast_container() { function sast_container() {
if [[ -n "$CI_REGISTRY_USER" ]]; then if [[ -n "$CI_REGISTRY_USER" ]]; then
...@@ -307,11 +307,10 @@ production: ...@@ -307,11 +307,10 @@ production:
} }
function codeclimate() { function codeclimate() {
docker run --env CODECLIMATE_CODE="$PWD" \ docker run --env SOURCE_CODE="$PWD" \
--volume "$PWD":/code \ --volume "$PWD":/code \
--volume /var/run/docker.sock:/var/run/docker.sock \ --volume /var/run/docker.sock:/var/run/docker.sock \
--volume /tmp/cc:/tmp/cc \ "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code
"registry.gitlab.com/gitlab-org/security-products/codequality/codeclimate:${SCA_VERSION}" analyze -f json > codeclimate.json
} }
function sast() { function sast() {
...@@ -328,7 +327,7 @@ production: ...@@ -328,7 +327,7 @@ production:
--env SAST_DISABLE_REMOTE_CHECKS="${SAST_DISABLE_REMOTE_CHECKS:-false}" \ --env SAST_DISABLE_REMOTE_CHECKS="${SAST_DISABLE_REMOTE_CHECKS:-false}" \
--volume "$PWD:/code" \ --volume "$PWD:/code" \
--volume /var/run/docker.sock:/var/run/docker.sock \ --volume /var/run/docker.sock:/var/run/docker.sock \
"registry.gitlab.com/gitlab-org/security-products/sast:$SCA_VERSION" /app/bin/run /code "registry.gitlab.com/gitlab-org/security-products/sast:$SP_VERSION" /app/bin/run /code
;; ;;
*) *)
echo "GitLab EE is required" echo "GitLab EE is required"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment