/* eslint-disable no-restricted-properties, babel/camelcase,
no-unused-expressions, default-case,
consistent-return, no-alert, no-param-reassign,
no-shadow, no-useless-escape,
class-methods-use-this */
/* global ResolveService */
/*
old_notes_spec.js is the spec for the legacy, jQuery notes application. It has nothing to do with the new, fancy Vue notes app.
*/
import $ from 'jquery';
import '~/lib/utils/jquery_at_who';
import { escape, uniqueId } from 'lodash';
import Cookies from 'js-cookie';
import Autosize from 'autosize';
import Vue from 'vue';
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import AjaxCache from '~/lib/utils/ajax_cache';
import syntaxHighlight from '~/syntax_highlight';
import axios from './lib/utils/axios_utils';
import { getLocationHash } from './lib/utils/url_utility';
import { deprecatedCreateFlash as Flash } from './flash';
import { defaultAutocompleteConfig } from './gfm_auto_complete';
import CommentTypeToggle from './comment_type_toggle';
import GLForm from './gl_form';
import loadAwardsHandler from './awards_handler';
import Autosave from './autosave';
import TaskList from './task_list';
import {
isInViewport,
getPagePath,
scrollToElement,
isMetaKey,
isInMRPage,
} from './lib/utils/common_utils';
import { localTimeAgo } from './lib/utils/datetime_utility';
import { sprintf, s__, __ } from './locale';
window.autosize = Autosize;
function normalizeNewlines(str) {
return str.replace(/\r\n/g, '\n');
}
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
export default class Notes {
static initialize(notes_url, note_ids, last_fetched_at, view, enableGFM) {
if (!this.instance) {
this.instance = new Notes(notes_url, note_ids, last_fetched_at, view, enableGFM);
}
}
static getInstance() {
return this.instance;
}
constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = defaultAutocompleteConfig) {
this.updateTargetButtons = this.updateTargetButtons.bind(this);
this.updateComment = this.updateComment.bind(this);
this.visibilityChange = this.visibilityChange.bind(this);
this.cancelDiscussionForm = this.cancelDiscussionForm.bind(this);
this.onAddDiffNote = this.onAddDiffNote.bind(this);
this.onAddImageDiffNote = this.onAddImageDiffNote.bind(this);
this.setupDiscussionNoteForm = this.setupDiscussionNoteForm.bind(this);
this.onReplyToDiscussionNote = this.onReplyToDiscussionNote.bind(this);
this.removeNote = this.removeNote.bind(this);
this.cancelEdit = this.cancelEdit.bind(this);
this.updateNote = this.updateNote.bind(this);
this.addDiscussionNote = this.addDiscussionNote.bind(this);
this.addNoteError = this.addNoteError.bind(this);
this.addNote = this.addNote.bind(this);
this.resetMainTargetForm = this.resetMainTargetForm.bind(this);
this.refresh = this.refresh.bind(this);
this.keydownNoteText = this.keydownNoteText.bind(this);
this.toggleCommitList = this.toggleCommitList.bind(this);
this.postComment = this.postComment.bind(this);
this.clearFlashWrapper = this.clearFlash.bind(this);
this.onHashChange = this.onHashChange.bind(this);
this.notes_url = notes_url;
this.note_ids = note_ids;
this.enableGFM = enableGFM;
// Used to keep track of updated notes while people are editing things
this.updatedNotesTrackingMap = {};
this.last_fetched_at = last_fetched_at;
this.noteable_url = document.URL;
this.notesCountBadge ||
(this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'));
this.basePollingInterval = 15000;
this.maxPollingSteps = 4;
this.$wrapperEl = isInMRPage() ? $(document).find('.diffs') : $(document);
this.cleanBinding();
this.addBinding();
this.setPollingInterval();
this.setupMainTargetNoteForm(enableGFM);
this.taskList = new TaskList({
dataType: 'note',
fieldName: 'note',
selector: '.notes',
});
this.collapseLongCommitList();
this.setViewType(view);
// We are in the Merge Requests page so we need another edit form for Changes tab
if (getPagePath(1) === 'merge_requests') {
$('.note-edit-form')
.clone()
.addClass('mr-note-edit-form')
.insertAfter('.note-edit-form');
}
const hash = getLocationHash();
const $anchor = hash && document.getElementById(hash);
if ($anchor) {
this.loadLazyDiff({ currentTarget: $anchor });
}
}
setViewType(view) {
this.view = Cookies.get('diff_view') || view;
}
addBinding() {
// Edit note link
this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this));
this.$wrapperEl.on('click', '.note-edit-cancel', this.cancelEdit);
// Reopen and close actions for Issue/MR combined with note form submit
this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment);
this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment);
this.$wrapperEl.on('keyup input', '.js-note-text', this.updateTargetButtons);
// resolve a discussion
this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment);
// remove a note (in general)
this.$wrapperEl.on('click', '.js-note-delete', this.removeNote);
// delete note attachment
this.$wrapperEl.on('click', '.js-note-attachment-delete', this.removeAttachment);
// update the file name when an attachment is selected
this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment);
// reply to diff/discussion notes
this.$wrapperEl.on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
// add diff note
this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote);
// add diff note for images
this.$wrapperEl.on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote);
// hide diff note form
this.$wrapperEl.on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
// toggle commit list
this.$wrapperEl.on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
this.$wrapperEl.on('click', '.js-toggle-lazy-diff', this.loadLazyDiff);
this.$wrapperEl.on(
'click',
'.js-toggle-lazy-diff-retry-button',
this.onClickRetryLazyLoad.bind(this),
);
// fetch notes when tab becomes visible
this.$wrapperEl.on('visibilitychange', this.visibilityChange);
// when issue status changes, we need to refresh data
this.$wrapperEl.on('issuable:change', this.refresh);
// ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs.
this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.addNote);
this.$wrapperEl.on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote);
this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.resetMainTargetForm);
this.$wrapperEl.on(
'ajax:complete',
'.js-main-target-form',
this.reenableTargetFormSubmitButton,
);
// when a key is clicked on the notes
this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText);
// When the URL fragment/hash has changed, `#note_xxx`
$(window).on('hashchange', this.onHashChange);
}
cleanBinding() {
this.$wrapperEl.off('click', '.js-note-edit');
this.$wrapperEl.off('click', '.note-edit-cancel');
this.$wrapperEl.off('click', '.js-note-delete');
this.$wrapperEl.off('click', '.js-note-attachment-delete');
this.$wrapperEl.off('click', '.js-discussion-reply-button');
this.$wrapperEl.off('click', '.js-add-diff-note-button');
this.$wrapperEl.off('click', '.js-add-image-diff-note-button');
// eslint-disable-next-line @gitlab/no-global-event-off
this.$wrapperEl.off('visibilitychange');
this.$wrapperEl.off('keyup input', '.js-note-text');
this.$wrapperEl.off('click', '.js-note-target-reopen');
this.$wrapperEl.off('click', '.js-note-target-close');
this.$wrapperEl.off('keydown', '.js-note-text');
this.$wrapperEl.off('click', '.js-comment-resolve-button');
this.$wrapperEl.off('click', '.system-note-commit-list-toggler');
this.$wrapperEl.off('click', '.js-toggle-lazy-diff');
this.$wrapperEl.off('click', '.js-toggle-lazy-diff-retry-button');
this.$wrapperEl.off('ajax:success', '.js-main-target-form');
this.$wrapperEl.off('ajax:success', '.js-discussion-note-form');
this.$wrapperEl.off('ajax:complete', '.js-main-target-form');
$(window).off('hashchange', this.onHashChange);
}
static initCommentTypeToggle(form) {
const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle');
const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu');
const noteTypeInput = form.querySelector('#note_type');
const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button');
const closeButton = form.querySelector('.js-note-target-close');
const reopenButton = form.querySelector('.js-note-target-reopen');
const commentTypeToggle = new CommentTypeToggle({
dropdownTrigger,
dropdownList,
noteTypeInput,
submitButton,
closeButton,
reopenButton,
});
commentTypeToggle.initDroplab();
}
keydownNoteText(e) {
let discussionNoteForm;
let editNote;
let myLastNote;
let myLastNoteEditBtn;
let newText;
let originalText;
if (isMetaKey(e)) {
return;
}
const $textarea = $(e.target);
// Edit previous note when UP arrow is hit
switch (e.which) {
case 38:
if ($textarea.val() !== '') {
return;
}
myLastNote = $(
`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`,
$textarea.closest('.note, .notes_holder, #notes'),
);
if (myLastNote.length) {
myLastNoteEditBtn = myLastNote.find('.js-note-edit');
return myLastNoteEditBtn.trigger('click', [true, myLastNote]);
}
break;
// Cancel creating diff note or editing any note when ESCAPE is hit
case 27:
discussionNoteForm = $textarea.closest('.js-discussion-note-form');
if (discussionNoteForm.length) {
if ($textarea.val() !== '') {
if (!window.confirm(__('Your comment will be discarded.'))) {
return;
}
}
this.removeDiscussionNoteForm(discussionNoteForm);
return;
}
editNote = $textarea.closest('.note');
if (editNote.length) {
originalText = $textarea.closest('form').data('originalNote');
newText = $textarea.val();
if (originalText !== newText) {
if (!window.confirm(__('Are you sure you want to discard this comment?'))) {
return;
}
}
return this.removeNoteEditForm(editNote);
}
}
}
initRefresh() {
if (Notes.interval) {
clearInterval(Notes.interval);
}
Notes.interval = setInterval(() => this.refresh(), this.pollingInterval);
}
refresh() {
if (!document.hidden) {
return this.getContent();
}
}
getContent() {
if (this.refreshing) {
return;
}
this.refreshing = true;
axios
.get(`${this.notes_url}?html=true`, {
headers: {
'X-Last-Fetched-At': this.last_fetched_at,
},
})
.then(({ data }) => {
const { notes } = data;
this.last_fetched_at = data.last_fetched_at;
this.setPollingInterval(data.notes.length);
$.each(notes, (i, note) => this.renderNote(note));
this.refreshing = false;
})
.catch(() => {
this.refreshing = false;
});
}
/**
* Increase @pollingInterval up to 120 seconds on every function call,
* if `shouldReset` has a truthy value, 'null' or 'undefined' the variable
* will reset to @basePollingInterval.
*
* Note: this function is used to gradually increase the polling interval
* if there aren't new notes coming from the server
*/
setPollingInterval(shouldReset) {
if (shouldReset == null) {
shouldReset = true;
}
const nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
if (shouldReset) {
this.pollingInterval = this.basePollingInterval;
} else if (this.pollingInterval < nthInterval) {
this.pollingInterval *= 2;
}
return this.initRefresh();
}
handleQuickActions(noteEntity) {
let votesBlock;
if (noteEntity.commands_changes) {
if ('merge' in noteEntity.commands_changes) {
Notes.checkMergeRequestStatus();
}
if ('emoji_award' in noteEntity.commands_changes) {
votesBlock = $('.js-awards-block').eq(0);
loadAwardsHandler()
.then(awardsHandler => {
awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award);
awardsHandler.scrollToAwards();
})
.catch(() => {
// ignore
});
}
}
}
setupNewNote($note) {
// Update datetime format on the recent note
localTimeAgo($note.find('.js-timeago'), false);
this.collapseLongCommitList();
this.taskList.init();
// This stops the note highlight, #note_xxx`, from being removed after real time update
// The `:target` selector does not re-evaluate after we replace element in the DOM
Notes.updateNoteTargetSelector($note);
this.$noteToCleanHighlight = $note;
}
onHashChange() {
if (this.$noteToCleanHighlight) {
Notes.updateNoteTargetSelector(this.$noteToCleanHighlight);
}
this.$noteToCleanHighlight = null;
}
static updateNoteTargetSelector($note) {
const hash = getLocationHash();
// Needs to be an explicit true/false for the jQuery `toggleClass(force)`
const addTargetClass = Boolean(hash && $note.filter(`#${hash}`).length > 0);
$note.toggleClass('target', addTargetClass);
}
/**
* Render note in main comments area.
*
* Note: for rendering inline notes use renderDiscussionNote
*/
renderNote(noteEntity, $form, $notesList = $('.main-notes-list')) {
if (noteEntity.discussion_html) {
return this.renderDiscussionNote(noteEntity, $form);
}
if (!noteEntity.valid) {
if (noteEntity.errors && noteEntity.errors.commands_only) {
if (noteEntity.commands_changes && Object.keys(noteEntity.commands_changes).length > 0) {
$notesList.find('.system-note.being-posted').remove();
}
this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline.get(0));
this.refresh();
}
return;
}
const $note = $notesList.find(`#note_${noteEntity.id}`);
if (Notes.isNewNote(noteEntity, this.note_ids)) {
if (isInMRPage()) {
return;
}
this.note_ids.push(noteEntity.id);
if ($notesList.length) {
$notesList.find('.system-note.being-posted').remove();
}
const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList);
this.setupNewNote($newNote);
this.refresh();
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.
const isEditing = $note.hasClass('is-editing');
const initialContent = normalizeNewlines(
$note
.find('.original-note-content')
.text()
.trim(),
);
const $textarea = $note.find('.js-note-text');
const currentContent = $textarea.val();
// There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
const sanitizedNoteNote = normalizeNewlines(noteEntity.note);
const isTextareaUntouched =
currentContent === initialContent || currentContent === sanitizedNoteNote;
if (isEditing && isTextareaUntouched) {
$textarea.val(noteEntity.note);
this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
} else if (isEditing && !isTextareaUntouched) {
this.putConflictEditWarningInPlace(noteEntity, $note);
this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
} else {
const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note);
this.setupNewNote($updatedNote);
}
}
}
isParallelView() {
return Cookies.get('diff_view') === 'parallel';
}
/**
* Render note in discussion area. To render inline notes use renderDiscussionNote.
*/
renderDiscussionNote(noteEntity, $form) {
let discussionContainer;
let row;
if (!Notes.isNewNote(noteEntity, this.note_ids)) {
return;
}
this.note_ids.push(noteEntity.id);
const form =
$form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`);
row =
form.length || !noteEntity.discussion_line_code
? form.closest('tr')
: $(`#${noteEntity.discussion_line_code}`);
if (noteEntity.on_image) {
row = form;
}
// is this the first note of discussion?
discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
if (!discussionContainer.length) {
discussionContainer = form.closest('.discussion').find('.notes');
}
if (discussionContainer.length === 0) {
if (noteEntity.diff_discussion_html) {
const $discussion = $(noteEntity.diff_discussion_html).renderGFM();
if (!this.isParallelView() || row.hasClass('js-temp-notes-holder') || noteEntity.on_image) {
// insert the note and the reply button after the temp row
row.after($discussion);
} else {
// Merge new discussion HTML in
const $notes = $discussion.find(
`.notes[data-discussion-id="${noteEntity.discussion_id}"]`,
);
const contentContainerClass = $notes
.closest('.notes-content')
.attr('class')
.split(' ')
.join('.');
row
.find(`.${contentContainerClass} .content`)
.append($notes.closest('.content').children());
}
} else {
Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
}
} else {
// append new note to all matching discussions
Notes.animateAppendNote(noteEntity.html, discussionContainer);
}
localTimeAgo($('.js-timeago'), false);
Notes.checkMergeRequestStatus();
return this.updateNotesCount(1);
}
getLineHolder(changesDiscussionContainer) {
return $(changesDiscussionContainer)
.closest('.notes_holder')
.prevAll('.line_holder')
.first()
.get(0);
}
/**
* Called in response the main target form has been successfully submitted.
*
* Removes any errors.
* Resets text and preview.
* Resets buttons.
*/
resetMainTargetForm(e) {
const form = $('.js-main-target-form');
// remove validation errors
form.find('.js-errors').remove();
// reset text and preview
form.find('.js-md-write-button').click();
form
.find('.js-note-text')
.val('')
.trigger('input');
form
.find('.js-note-text')
.data('autosave')
.reset();
const event = document.createEvent('Event');
event.initEvent('autosize:update', true, false);
form.find('.js-autosize')[0].dispatchEvent(event);
this.updateTargetButtons(e);
}
reenableTargetFormSubmitButton() {
const form = $('.js-main-target-form');
return form.find('.js-note-text').trigger('input');
}
/**
* Shows the main form and does some setup on it.
*
* Sets some hidden fields in the form.
*/
setupMainTargetNoteForm(enableGFM) {
// find the form
const form = $('.js-new-note-form');
// Set a global clone of the form for later cloning
this.formClone = form.clone();
// show the form
this.setupNoteForm(form, enableGFM);
// fix classes
form.removeClass('js-new-note-form');
form.addClass('js-main-target-form');
form.find('#note_line_code').remove();
form.find('#note_position').remove();
form.find('#note_type').val('');
form.find('#note_project_id').remove();
form.find('#in_reply_to_discussion_id').remove();
this.parentTimeline = form.parents('.timeline');
if (form.length) {
Notes.initCommentTypeToggle(form.get(0));
}
}
/**
* General note form setup.
*
* deactivates the submit button when text is empty
* hides the preview button when text is empty
* set up GFM auto complete
* show the form
*/
setupNoteForm(form, enableGFM = defaultAutocompleteConfig) {
this.glForm = new GLForm(form, enableGFM);
const textarea = form.find('.js-note-text');
const key = [
s__('NoteForm|Note'),
form.find('#note_noteable_type').val(),
form.find('#note_noteable_id').val(),
form.find('#note_commit_id').val(),
form.find('#note_type').val(),
form.find('#note_project_id').val(),
form.find('#in_reply_to_discussion_id').val(),
// LegacyDiffNote
form.find('#note_line_code').val(),
// DiffNote
form.find('#note_position').val(),
];
return new Autosave(textarea, key);
}
/**
* Called in response to the new note form being submitted
*
* Adds new note to list.
*/
addNote($form, note) {
return this.renderNote(note);
}
addNoteError($form) {
let formParentTimeline;
if ($form.hasClass('js-main-target-form')) {
formParentTimeline = $form.parents('.timeline');
} else if ($form.hasClass('js-discussion-note-form')) {
formParentTimeline = $form.closest('.discussion-notes').find('.notes');
}
return this.addFlash(
__(
'Your comment could not be submitted! Please check your network connection and try again.',
),
'alert',
formParentTimeline.get(0),
);
}
updateNoteError() {
// eslint-disable-next-line no-new
new Flash(
__('Your comment could not be updated! Please check your network connection and try again.'),
);
}
/**
* Called in response to the new note form being submitted
*
* Adds new note to list.
*/
addDiscussionNote($form, note, isNewDiffComment) {
if ($form.attr('data-resolve-all') != null) {
const discussionId = $form.data('discussionId');
const mergeRequestId = $form.data('noteableIid');
if (ResolveService != null) {
ResolveService.toggleResolveForDiscussion(mergeRequestId, discussionId);
}
}
this.renderNote(note, $form);
// cleanup after successfully creating a diff/discussion note
if (isNewDiffComment) {
this.removeDiscussionNoteForm($form);
}
}
/**
* Called in response to the edit note form being submitted
*
* Updates the current note field.
*/
updateNote(noteEntity, $targetNote) {
// Convert returned HTML to a jQuery object so we can modify it further
const $noteEntityEl = $(noteEntity.html);
this.revertNoteEditForm($targetNote);
$noteEntityEl.renderGFM();
// Find the note's `li` element by ID and replace it with the updated HTML
const $note_li = $(`.note-row-${noteEntity.id}`);
$note_li.replaceWith($noteEntityEl);
this.setupNewNote($noteEntityEl);
}
checkContentToAllowEditing($el) {
const initialContent = $el
.find('.original-note-content')
.text()
.trim();
const currentContent = $el.find('.js-note-text').val();
let isAllowed = true;
if (currentContent === initialContent) {
this.removeNoteEditForm($el);
} else {
const isWidgetVisible = isInViewport($el.get(0));
if (!isWidgetVisible) {
scrollToElement($el);
}
$el.find('.js-finish-edit-warning').show();
isAllowed = false;
}
return isAllowed;
}
/**
* Called in response to clicking the edit note link
*
* Replaces the note text with the note edit form
* Adds a data attribute to the form with the original content of the note for cancellations
*/
showEditForm(e) {
e.preventDefault();
const $target = $(e.target);
const $editForm = $(this.getEditFormSelector($target));
const $note = $target.closest('.note');
const $currentlyEditing = $('.note.is-editing:visible');
if ($currentlyEditing.length) {
const isEditAllowed = this.checkContentToAllowEditing($currentlyEditing);
if (!isEditAllowed) {
return;
}
}
$note.find('.js-note-attachment-delete').show();
$editForm.addClass('current-note-edit-form');
$note.addClass('is-editing');
this.putEditFormInPlace($target);
}
/**
* Called in response to clicking the edit note link
*
* Hides edit form and restores the original note text to the editor textarea.
*/
cancelEdit(e) {
e.preventDefault();
const $target = $(e.target);
const $note = $target.closest('.note');
const noteId = $note.attr('data-note-id');
this.revertNoteEditForm($target);
if (this.updatedNotesTrackingMap[noteId]) {
const $newNote = $(this.updatedNotesTrackingMap[noteId].html);
$note.replaceWith($newNote);
this.setupNewNote($newNote);
// Now that we have taken care of the update, clear it out
delete this.updatedNotesTrackingMap[noteId];
} else {
$note.find('.js-finish-edit-warning').hide();
this.removeNoteEditForm($note);
}
}
revertNoteEditForm($target) {
$target = $target || $('.note.is-editing:visible');
const selector = this.getEditFormSelector($target);
const $editForm = $(selector);
$editForm.insertBefore('.diffs');
$editForm.find('.js-comment-save-button').enable();
$editForm.find('.js-finish-edit-warning').hide();
}
getEditFormSelector($el) {
let selector = '.note-edit-form:not(.mr-note-edit-form)';
if ($el.parents('#diffs').length) {
selector = '.note-edit-form.mr-note-edit-form';
}
return selector;
}
removeNoteEditForm($note) {
const form = $note.find('.diffs .current-note-edit-form');
$note.removeClass('is-editing');
form.removeClass('current-note-edit-form');
form.find('.js-finish-edit-warning').hide();
// Replace markdown textarea text with original note text.
return form.find('.js-note-text').val(form.find('form.edit-note').data('originalNote'));
}
/**
* Called in response to deleting a note of any kind.
*
* Removes the actual note from view.
* Removes the whole discussion if the last note is being removed.
*/
removeNote(e) {
const $note = $(e.currentTarget).closest('.note');
const noteElId = $note.attr('id');
$(`.note[id="${noteElId}"]`).each((i, el) => {
// A same note appears in the "Discussion" and in the "Changes" tab, we have
// to remove all. Using $('.note[id='noteId']') ensure we get all the notes,
// where $('#noteId') would return only one.
const $note = $(el);
const $notes = $note.closest('.discussion-notes');
const discussionId = $('.notes', $notes).data('discussionId');
$note.remove();
// check if this is the last note for this line
if ($notes.find('.note').length === 0) {
const 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.checkMergeRequestStatus();
return this.updateNotesCount(-1);
}
/**
* Called in response to clicking the delete attachment link
*
* Removes the attachment wrapper view, including image tag if it exists
* Resets the note editing form
*/
removeAttachment() {
const $note = $(this).closest('.note');
$note.find('.note-attachment').remove();
$note.find('.note-body > .note-text').show();
$note.find('.note-header').show();
return $note.find('.diffs .current-note-edit-form').remove();
}
/**
* Called when clicking on the "reply" button for a diff line.
*
* Shows the note form below the notes.
*/
onReplyToDiscussionNote(e) {
this.replyToDiscussionNote(e.target);
}
replyToDiscussionNote(target) {
const form = this.cleanForm(this.formClone.clone());
const replyLink = $(target).closest('.js-discussion-reply-button');
// insert the form after the button
replyLink
.closest('.discussion-reply-holder')
.hide()
.after(form);
// show the form
return this.setupDiscussionNoteForm(replyLink, form);
}
/**
* Shows the diff or discussion form and does some setup on it.
*
* Sets some hidden fields in the form.
*
* Note: dataHolder must have the "discussionId" and "lineCode" data attributes set.
*/
setupDiscussionNoteForm(dataHolder, form) {
// set up note target
let diffFileData = dataHolder.closest('.text-file');
if (diffFileData.length === 0) {
diffFileData = dataHolder.closest('.image');
}
const discussionID = dataHolder.data('discussionId');
if (discussionID) {
form.attr('data-discussion-id', discussionID);
form.find('#in_reply_to_discussion_id').val(discussionID);
}
form.find('#note_project_id').val(dataHolder.data('discussionProjectId'));
form.attr('data-line-code', dataHolder.data('lineCode'));
form.find('#line_type').val(dataHolder.data('lineType'));
form.find('#note_noteable_type').val(diffFileData.data('noteableType'));
form.find('#note_noteable_id').val(diffFileData.data('noteableId'));
form.find('#note_commit_id').val(diffFileData.data('commitId'));
form.find('#note_type').val(dataHolder.data('noteType'));
// LegacyDiffNote
form.find('#note_line_code').val(dataHolder.data('lineCode'));
// DiffNote
form.find('#note_position').val(dataHolder.attr('data-position'));
form
.prepend(
``,
)
.append('')
.find('.js-close-discussion-note-form')
.show()
.removeClass('hide');
form.find('.js-note-target-close').remove();
form.find('.js-note-new-discussion').remove();
this.setupNoteForm(form);
form.removeClass('js-main-target-form').addClass('discussion-form js-discussion-note-form');
form.find('.js-note-text').focus();
form.find('.js-comment-resolve-button').attr('data-discussion-id', discussionID);
}
/**
* Called when clicking on the "add a comment" button on the side of a diff line.
*
* Inserts a temporary row for the form below the line.
* Sets up the form and shows it.
*/
onAddDiffNote(e) {
e.preventDefault();
const link = e.currentTarget || e.target;
const $link = $(link);
const showReplyInput = !$link.hasClass('js-diff-comment-avatar');
this.toggleDiffNote({
target: $link,
lineType: link.dataset.lineType,
showReplyInput,
currentUsername: gon.current_username,
currentUserAvatar: gon.current_user_avatar_url,
currentUserFullname: gon.current_user_fullname,
});
}
onAddImageDiffNote(e) {
const $link = $(e.currentTarget || e.target);
const $diffFile = $link.closest('.diff-file');
const clickEvent = new CustomEvent('click.imageDiff', {
detail: e,
});
$diffFile[0].dispatchEvent(clickEvent);
// Set up comment form
let newForm;
const $noteContainer = $link.closest('.diff-viewer').find('.note-container');
const $form = $noteContainer.find('> .discussion-form');
if ($form.length === 0) {
newForm = this.cleanForm(this.formClone.clone());
newForm.appendTo($noteContainer);
} else {
newForm = $form;
}
this.setupDiscussionNoteForm($link, newForm);
}
toggleDiffNote({ target, lineType, forceShow, showReplyInput = false }) {
let addForm;
let newForm;
let noteForm;
let replyButton;
let rowCssToAdd;
const $link = $(target);
const row = $link.closest('tr');
const nextRow = row.next();
let targetRow = row;
if (nextRow.is('.notes_holder')) {
targetRow = nextRow;
}
const hasNotes = nextRow.is('.notes_holder');
addForm = false;
let lineTypeSelector = '';
rowCssToAdd =
'
${formContent}