Commit 88bd9bac authored by Mike Greiling's avatar Mike Greiling

Merge branch 'acet-notes-prettified' into 'master'

Prettify notes app

See merge request gitlab-org/gitlab-ce!17812
parents bcc04515 fdc9ae2e
...@@ -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();
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