Skip to content
Snippets Groups Projects
Commit 059ab73b authored by Fatih Acet's avatar Fatih Acet Committed by Jacob Schatz
Browse files

Render MR Notes with Vue with behind a cookie

parent 0be4a77d
No related branches found
No related tags found
No related merge requests found
Showing
with 816 additions and 176 deletions
Loading
Loading
@@ -3,10 +3,10 @@
import AccessorUtilities from './lib/utils/accessor';
 
export default class Autosave {
constructor(field, key, resource) {
constructor(field, key) {
this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.resource = resource;
if (key.join != null) {
key = key.join('/');
}
Loading
Loading
@@ -17,31 +17,27 @@ export default class Autosave {
}
 
restore() {
var text;
if (!this.isLocalStorageAvailable) return;
if (!this.field.length) return;
 
text = window.localStorage.getItem(this.key);
const text = window.localStorage.getItem(this.key);
 
if ((text != null ? text.length : void 0) > 0) {
this.field.val(text);
}
if (!this.resource && this.resource !== 'issue') {
this.field.trigger('input');
} else {
// v-model does not update with jQuery trigger
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0);
if (field) {
field.dispatchEvent(event);
}
}
this.field.trigger('input');
// v-model does not update with jQuery trigger
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0);
field.dispatchEvent(event);
}
 
save() {
var text;
text = this.field.val();
if (!this.field.length) return;
const text = this.field.val();
 
if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) {
return window.localStorage.setItem(this.key, text);
Loading
Loading
Loading
Loading
@@ -2,7 +2,7 @@
import _ from 'underscore';
import Cookies from 'js-cookie';
import { __ } from './locale';
import { isInIssuePage, updateTooltipTitle } from './lib/utils/common_utils';
import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils';
import flash from './flash';
import axios from './lib/utils/axios_utils';
 
Loading
Loading
@@ -239,9 +239,9 @@ class AwardsHandler {
}
 
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length;
const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length;
 
if (isInIssuePage() && !isMainAwardsBlock) {
if (this.isInVueNoteablePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', '');
 
this.hideMenuElement($('.emoji-menu'));
Loading
Loading
@@ -293,8 +293,16 @@ class AwardsHandler {
}
}
 
isVueMRDiscussions() {
return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
}
isInVueNoteablePage() {
return isInIssuePage() || this.isVueMRDiscussions();
}
getVotesBlock() {
if (isInIssuePage()) {
if (this.isInVueNoteablePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
 
if ($el.length) {
Loading
Loading
Loading
Loading
@@ -197,7 +197,7 @@ const JumpToDiscussion = Vue.extend({
}
 
$.scrollTo($target, {
offset: 0
offset: -150
});
}
},
Loading
Loading
Loading
Loading
@@ -87,6 +87,7 @@ const ResolveBtn = Vue.extend({
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus();
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
 
this.updateTooltip();
})
Loading
Loading
Loading
Loading
@@ -14,6 +14,7 @@ import './components/resolve_count';
import './components/resolve_discussion_btn';
import './components/diff_note_avatars';
import './components/new_issue_for_discussion';
import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils';
 
export default () => {
const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box');
Loading
Loading
@@ -67,12 +68,14 @@ export default () => {
 
gl.diffNotesCompileComponents();
 
new Vue({
el: '#resolve-count-app',
components: {
'resolve-count': ResolveCount
},
});
if (!hasVueMRDiscussionsCookie()) {
new Vue({
el: '#resolve-count-app',
components: {
'resolve-count': ResolveCount
},
});
}
 
$(window).trigger('resize.nav');
};
Loading
Loading
@@ -8,8 +8,8 @@ window.gl = window.gl || {};
 
class ResolveServiceClass {
constructor(root) {
this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`);
this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`);
this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`);
this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`);
}
 
resolve(noteId) {
Loading
Loading
@@ -45,6 +45,7 @@ class ResolveServiceClass {
 
if (gl.mrWidget) gl.mrWidget.checkStatus();
discussion.updateHeadline(data);
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
})
.catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.'));
}
Loading
Loading
import jQuery from 'jquery';
import Cookies from 'js-cookie';
import axios from './axios_utils';
import { getLocationHash } from './url_utility';
import { convertToCamelCase } from './text_utility';
Loading
Loading
@@ -22,13 +24,18 @@ export const getGroupSlug = () => {
return null;
};
 
export const isInIssuePage = () => {
const page = getPagePath(1);
const action = getPagePath(2);
export const checkPageAndAction = (page, action) => {
const pagePath = getPagePath(1);
const actionPath = getPagePath(2);
 
return page === 'issues' && action === 'show';
return pagePath === page && actionPath === action;
};
 
export const isInIssuePage = () => checkPageAndAction('issues', 'show');
export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
export const isInNoteablePage = () => isInIssuePage() || isInMRPage();
export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions');
export const ajaxGet = url => axios.get(url, {
params: { format: 'js' },
responseType: 'text',
Loading
Loading
@@ -133,7 +140,11 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
// 3) Middle-click or Mouse Wheel Click (e.which is 2)
export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2;
 
export const scrollToElement = ($el) => {
export const scrollToElement = (element) => {
let $el = element;
if (!(element instanceof jQuery)) {
$el = $(element);
}
const top = $el.offset().top;
const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
Loading
Loading
Loading
Loading
@@ -65,6 +65,20 @@ export function capitalizeFirstCharacter(text) {
return `${text[0].toUpperCase()}${text.slice(1)}`;
}
 
export function camelCase(str) {
return str.replace(/_+([a-z])/gi, ($1, $2) => $2.toUpperCase());
}
export function camelCaseKeys(obj = {}) {
return Object.keys(obj).reduce((acc, key) => {
const camelKey = camelCase(key);
return {
...acc,
[camelKey]: obj[key],
};
}, {});
}
/**
* Replaces all html tags from a string with the given replacement.
*
Loading
Loading
Loading
Loading
@@ -241,6 +241,10 @@ export default class MergeRequestTabs {
return newState;
}
 
getCurrentAction() {
return this.currentAction;
}
loadCommits(source) {
if (this.commitsLoaded) {
return;
Loading
Loading
import Vue from 'vue';
import notesApp from '../notes/components/notes_app.vue';
import discussionCounter from '../notes/components/discussion_counter.vue';
import store from '../notes/stores';
document.addEventListener('DOMContentLoaded', () => {
new Vue({ // eslint-disable-line
el: '#js-vue-mr-discussions',
components: {
notesApp,
},
data() {
const notesDataset = document.getElementById('js-vue-mr-discussions').dataset;
return {
noteableData: JSON.parse(notesDataset.noteableData),
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
};
},
render(createElement) {
return createElement('notes-app', {
props: {
noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
},
});
},
});
new Vue({ // eslint-disable-line
el: '#js-vue-discussion-counter',
components: {
discussionCounter,
},
store,
render(createElement) {
return createElement('discussion-counter');
},
});
});
Loading
Loading
@@ -24,7 +24,7 @@ import GLForm from './gl_form';
import loadAwardsHandler from './awards_handler';
import Autosave from './autosave';
import TaskList from './task_list';
import { isInViewport, getPagePath, scrollToElement, isMetaKey } from './lib/utils/common_utils';
import { isInViewport, getPagePath, scrollToElement, isMetaKey, hasVueMRDiscussionsCookie } from './lib/utils/common_utils';
import imageDiffHelper from './image_diff/helpers/index';
import { localTimeAgo } from './lib/utils/datetime_utility';
 
Loading
Loading
@@ -44,6 +44,10 @@ export default class Notes {
}
}
 
static getInstance() {
return this.instance;
}
constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) {
this.updateTargetButtons = this.updateTargetButtons.bind(this);
this.updateComment = this.updateComment.bind(this);
Loading
Loading
@@ -102,67 +106,77 @@ export default class Notes {
}
 
addBinding() {
this.$wrapperEl = hasVueMRDiscussionsCookie() ? $(document).find('.diffs') : $(document);
// Edit note link
$(document).on('click', '.js-note-edit', this.showEditForm.bind(this));
$(document).on('click', '.note-edit-cancel', this.cancelEdit);
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
$(document).on('click', '.js-comment-submit-button', this.postComment);
$(document).on('click', '.js-comment-save-button', this.updateComment);
$(document).on('keyup input', '.js-note-text', this.updateTargetButtons);
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
$(document).on('click', '.js-comment-resolve-button', this.postComment);
this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment);
// remove a note (in general)
$(document).on('click', '.js-note-delete', this.removeNote);
this.$wrapperEl.on('click', '.js-note-delete', this.removeNote);
// delete note attachment
$(document).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
$(document).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
$(document).on('change', '.js-note-attachment-input', this.updateFormAttachment);
this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment);
// reply to diff/discussion notes
$(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
this.$wrapperEl.on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
// add diff note
$(document).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
$(document).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
$(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
this.$wrapperEl.on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
// toggle commit list
$(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
this.$wrapperEl.on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
// fetch notes when tab becomes visible
$(document).on('visibilitychange', this.visibilityChange);
this.$wrapperEl.on('visibilitychange', this.visibilityChange);
// when issue status changes, we need to refresh data
$(document).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.
$(document).on('ajax:success', '.js-main-target-form', this.addNote);
$(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote);
$(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm);
$(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton);
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
$(document).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`
return $(window).on('hashchange', this.onHashChange);
$(window).on('hashchange', this.onHashChange);
this.boundGetContent = this.getContent.bind(this);
document.addEventListener('refreshLegacyNotes', this.boundGetContent);
this.eventsBound = true;
}
 
cleanBinding() {
$(document).off('click', '.js-note-edit');
$(document).off('click', '.note-edit-cancel');
$(document).off('click', '.js-note-delete');
$(document).off('click', '.js-note-attachment-delete');
$(document).off('click', '.js-discussion-reply-button');
$(document).off('click', '.js-add-diff-note-button');
$(document).off('click', '.js-add-image-diff-note-button');
$(document).off('visibilitychange');
$(document).off('keyup input', '.js-note-text');
$(document).off('click', '.js-note-target-reopen');
$(document).off('click', '.js-note-target-close');
$(document).off('click', '.js-note-discard');
$(document).off('keydown', '.js-note-text');
$(document).off('click', '.js-comment-resolve-button');
$(document).off('click', '.system-note-commit-list-toggler');
$(document).off('ajax:success', '.js-main-target-form');
$(document).off('ajax:success', '.js-discussion-note-form');
$(document).off('ajax:complete', '.js-main-target-form');
if (!this.eventsBound) {
return;
}
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');
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('click', '.js-note-discard');
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('ajax:success', '.js-main-target-form');
this.$wrapperEl.off('ajax:success', '.js-discussion-note-form');
this.$wrapperEl.off('ajax:complete', '.js-main-target-form');
document.removeEventListener('refreshLegacyNotes', this.boundGetContent);
$(window).off('hashchange', this.onHashChange);
}
 
Loading
Loading
@@ -252,8 +266,10 @@ export default class Notes {
if (this.refreshing) {
return;
}
this.refreshing = true;
axios.get(this.notes_url, {
axios.get(`${this.notes_url}?html=true`, {
headers: {
'X-Last-Fetched-At': this.last_fetched_at,
},
Loading
Loading
@@ -350,7 +366,7 @@ export default class Notes {
}
 
if (!noteEntity.valid) {
if (noteEntity.errors.commands_only) {
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();
Loading
Loading
@@ -363,6 +379,10 @@ export default class Notes {
 
const $note = $notesList.find(`#note_${noteEntity.id}`);
if (Notes.isNewNote(noteEntity, this.note_ids)) {
if (hasVueMRDiscussionsCookie()) {
return;
}
this.note_ids.push(noteEntity.id);
 
if ($notesList.length) {
Loading
Loading
@@ -399,6 +419,8 @@ export default class Notes {
this.setupNewNote($updatedNote);
}
}
Notes.refreshVueNotes();
}
 
isParallelView() {
Loading
Loading
@@ -406,12 +428,11 @@ export default class Notes {
}
 
/**
* Render note in discussion area.
*
* Note: for rendering inline notes use renderDiscussionNote
* Render note in discussion area. To render inline notes use renderDiscussionNote.
*/
renderDiscussionNote(noteEntity, $form) {
var discussionContainer, form, row, lineType, diffAvatarContainer;
if (!Notes.isNewNote(noteEntity, this.note_ids)) {
return;
}
Loading
Loading
@@ -452,7 +473,9 @@ export default class Notes {
// Init discussion on 'Discussion' page if it is merge request page
const page = $('body').attr('data-page');
if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) {
Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
if (!hasVueMRDiscussionsCookie()) {
Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
}
}
} else {
// append new note to all matching discussions
Loading
Loading
@@ -634,7 +657,6 @@ export default class Notes {
var $noteEntityEl, $note_li;
// Convert returned HTML to a jQuery object so we can modify it further
$noteEntityEl = $(noteEntity.html);
$noteEntityEl.addClass('fade-in-full');
this.revertNoteEditForm($targetNote);
$noteEntityEl.renderGFM();
// Find the note's `li` element by ID and replace it with the updated HTML
Loading
Loading
@@ -730,7 +752,7 @@ export default class Notes {
var selector = this.getEditFormSelector($target);
var $editForm = $(selector);
 
$editForm.insertBefore('.notes-form');
$editForm.insertBefore('.diffs');
$editForm.find('.js-comment-save-button').enable();
$editForm.find('.js-finish-edit-warning').hide();
}
Loading
Loading
@@ -746,7 +768,8 @@ export default class Notes {
}
 
removeNoteEditForm($note) {
var form = $note.find('.current-note-edit-form');
var 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();
Loading
Loading
@@ -818,6 +841,7 @@ export default class Notes {
};
})(this));
 
Notes.refreshVueNotes();
Notes.checkMergeRequestStatus();
return this.updateNotesCount(-1);
}
Loading
Loading
@@ -1157,7 +1181,7 @@ export default class Notes {
this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
 
$editForm.find('form')
.attr('action', postUrl)
.attr('action', `${postUrl}?html=true`)
.attr('data-remote', 'true');
$editForm.find('.js-form-target-id').val(targetId);
$editForm.find('.js-form-target-type').val(targetType);
Loading
Loading
@@ -1280,6 +1304,10 @@ export default class Notes {
return $updatedNote;
}
 
static refreshVueNotes() {
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
}
/**
* Get data from Form attributes to use for saving/submitting comment.
*/
Loading
Loading
@@ -1481,7 +1509,7 @@ export default class Notes {
 
/* eslint-disable promise/catch-or-return */
// Make request to submit comment on server
axios.post(formAction, formData)
axios.post(`${formAction}?html=true`, formData)
.then((res) => {
const note = res.data;
 
Loading
Loading
@@ -1546,6 +1574,8 @@ export default class Notes {
if ($notesContainer.length) {
$notesContainer.append('<div class="flash-container" style="display: none;"></div>');
}
Notes.refreshVueNotes();
} else if (isMainForm) { // Check if this was main thread comment
// Show final note element on UI and perform form and action buttons cleanup
this.addNote($form, note);
Loading
Loading
@@ -1627,7 +1657,7 @@ export default class Notes {
 
/* eslint-disable promise/catch-or-return */
// Make request to update comment on server
axios.post(formAction, formData)
axios.post(`${formAction}?html=true`, formData)
.then(({ data }) => {
// Submission successful! render final note element
this.updateNote(data, $editingNote);
Loading
Loading
Loading
Loading
@@ -2,10 +2,11 @@
import { mapActions, mapGetters } from 'vuex';
import _ from 'underscore';
import Autosize from 'autosize';
import { __ } from '~/locale';
import { __, sprintf } from '~/locale';
import Flash from '../../flash';
import Autosave from '../../autosave';
import TaskList from '../../task_list';
import { capitalizeFirstCharacter, convertToCamelCase } from '../../lib/utils/text_utility';
import * as constants from '../constants';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
Loading
Loading
@@ -29,6 +30,12 @@
mixins: [
issuableStateMixin,
],
props: {
noteableType: {
type: String,
required: true,
},
},
data() {
return {
note: '',
Loading
Loading
@@ -43,37 +50,51 @@
'getUserData',
'getNoteableData',
'getNotesData',
'issueState',
'openState',
]),
noteableDisplayName() {
return this.noteableType.replace(/_/g, ' ');
},
isLoggedIn() {
return this.getUserData.id;
},
commentButtonTitle() {
return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
},
isIssueOpen() {
return this.issueState === constants.OPENED || this.issueState === constants.REOPENED;
isOpen() {
return this.openState === constants.OPENED || this.openState === constants.REOPENED;
},
canCreateNote() {
return this.getNoteableData.current_user.can_create_note;
},
issueActionButtonTitle() {
if (this.note.length) {
const actionText = this.isIssueOpen ? 'close' : 'reopen';
const openOrClose = this.isOpen ? 'close' : 'reopen';
 
return this.noteType === constants.COMMENT ?
`Comment & ${actionText} issue` :
`Start discussion & ${actionText} issue`;
if (this.note.length) {
return sprintf(
__('%{actionText} & %{openOrClose} %{noteable}'),
{
actionText: this.commentButtonTitle,
openOrClose,
noteable: this.noteableDisplayName,
},
);
}
 
return this.isIssueOpen ? 'Close issue' : 'Reopen issue';
return sprintf(
__('%{openOrClose} %{noteable}'),
{
openOrClose: capitalizeFirstCharacter(openOrClose),
noteable: this.noteableDisplayName,
},
);
},
actionButtonClassNames() {
return {
'btn-reopen': !this.isIssueOpen,
'btn-close': this.isIssueOpen,
'js-note-target-close': this.isIssueOpen,
'js-note-target-reopen': !this.isIssueOpen,
'btn-reopen': !this.isOpen,
'btn-close': this.isOpen,
'js-note-target-close': this.isOpen,
'js-note-target-reopen': !this.isOpen,
};
},
markdownDocsPath() {
Loading
Loading
@@ -138,7 +159,7 @@
flashContainer: this.$el,
data: {
note: {
noteable_type: constants.NOTEABLE_TYPE,
noteable_type: this.noteableType,
noteable_id: this.getNoteableData.id,
note: this.note,
},
Loading
Loading
@@ -193,19 +214,29 @@ Please check your network connection and try again.`;
this.isSubmitting = false;
},
toggleIssueState() {
if (this.isIssueOpen) {
if (this.isOpen) {
this.closeIssue()
.then(() => this.enableButton())
.catch(() => {
this.enableButton();
Flash(__('Something went wrong while closing the issue. Please try again later'));
Flash(
sprintf(
__('Something went wrong while closing the %{issuable}. Please try again later'),
{ issuable: this.noteableDisplayName },
),
);
});
} else {
this.reopenIssue()
.then(() => this.enableButton())
.catch(() => {
this.enableButton();
Flash(__('Something went wrong while reopening the issue. Please try again later'));
Flash(
sprintf(
__('Something went wrong while reopening the %{issuable}. Please try again later'),
{ issuable: this.noteableDisplayName },
),
);
});
}
},
Loading
Loading
@@ -221,7 +252,6 @@ Please check your network connection and try again.`;
this.$refs.markdownField.previewMarkdown = false;
}
 
// reset autostave
this.autosave.reset();
},
setNoteType(type) {
Loading
Loading
@@ -240,10 +270,11 @@ Please check your network connection and try again.`;
},
initAutoSave() {
if (this.isLoggedIn) {
const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
this.autosave = new Autosave(
$(this.$refs.textarea),
['Note', 'Issue', this.getNoteableData.id],
'issue',
['Note', noteableType, this.getNoteableData.id],
);
}
},
Loading
Loading
@@ -331,7 +362,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
:disabled="isSubmitButtonDisabled"
class="btn btn-create comment-btn js-comment-button js-comment-submit-button"
type="submit">
{{ commentButtonTitle }}
{{ __(commentButtonTitle) }}
</button>
<button
:disabled="isSubmitButtonDisabled"
Loading
Loading
@@ -359,7 +390,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
<div class="description">
<strong>Comment</strong>
<p>
Add a general comment to this issue.
Add a general comment to this {{ noteableDisplayName }}.
</p>
</div>
</button>
Loading
Loading
<script>
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
ClipboardButton,
Icon,
},
props: {
diffFile: {
type: Object,
required: true,
},
},
computed: {
titleTag() {
return this.diffFile.discussionPath ? 'a' : 'span';
},
},
};
</script>
<template>
<div class="file-header-content">
<div
v-if="diffFile.submodule"
>
<span>
<icon name="archive" />
<strong
v-html="diffFile.submoduleLink"
class="file-title-name"
></strong>
<clipboard-button
title="Copy file path to clipboard"
:text="diffFile.submoduleLink"
/>
</span>
</div>
<template v-else>
<component
ref="titleWrapper"
:is="titleTag"
:href="diffFile.discussionPath"
>
<span v-html="diffFile.blobIcon"></span>
<span v-if="diffFile.renamedFile">
<strong
class="file-title-name has-tooltip"
:title="diffFile.oldPath"
data-container="body"
>
{{ diffFile.oldPath }}
</strong>
&rarr;
<strong
class="file-title-name has-tooltip"
:title="diffFile.newPath"
data-container="body"
>
{{ diffFile.newPath }}
</strong>
</span>
<strong
v-else
class="file-title-name has-tooltip"
:title="diffFile.oldPath"
data-container="body"
>
{{ diffFile.filePath }}
<span v-if="diffFile.deletedFile">
deleted
</span>
</strong>
</component>
<clipboard-button
title="Copy file path to clipboard"
:text="diffFile.filePath"
/>
<small
v-if="diffFile.modeChanged"
ref="fileMode"
>
{{ diffFile.aMode }} → {{ diffFile.bMode }}
</small>
</template>
</div>
</template>
<script>
import syntaxHighlight from '~/syntax_highlight';
import imageDiffHelper from '~/image_diff/helpers/index';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import DiffFileHeader from './diff_file_header.vue';
export default {
components: {
DiffFileHeader,
},
props: {
discussion: {
type: Object,
required: true,
},
},
computed: {
isImageDiff() {
return !this.diffFile.text;
},
diffFileClass() {
const { text } = this.diffFile;
return text ? 'text-file' : 'js-image-file';
},
diffRows() {
return $(this.discussion.truncatedDiffLines);
},
diffFile() {
return convertObjectPropsToCamelCase(this.discussion.diffFile);
},
imageDiffHtml() {
return this.discussion.imageDiffHtml;
},
},
mounted() {
if (this.isImageDiff) {
const canCreateNote = false;
const renderCommentBadge = true;
imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge);
} else {
const fileHolder = $(this.$refs.fileHolder);
this.$nextTick(() => {
syntaxHighlight(fileHolder);
});
}
},
methods: {
rowTag(html) {
return html.outerHTML ? 'tr' : 'template';
},
},
};
</script>
<template>
<div
ref="fileHolder"
class="diff-file file-holder"
:class="diffFileClass"
>
<div class="js-file-title file-title file-title-flex-parent">
<diff-file-header
:diff-file="diffFile"
/>
</div>
<div
v-if="diffFile.text"
class="diff-content code js-syntax-highlight"
>
<table>
<component
:is="rowTag(html)"
:class="html.className"
v-for="(html, index) in diffRows"
v-html="html.outerHTML"
:key="index"
/>
<tr class="notes_holder">
<td
class="notes_line"
colspan="2"
></td>
<td class="notes_content">
<slot></slot>
</td>
</tr>
</table>
</div>
<div
v-else
>
<div v-html="imageDiffHtml"></div>
<slot></slot>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import resolveSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedSvg from 'icons/_icon_status_success_solid.svg';
import mrIssueSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionSvg from 'icons/_next_discussion.svg';
import { pluralize } from '../../lib/utils/text_utility';
import { scrollToElement } from '../../lib/utils/common_utils';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
computed: {
...mapGetters([
'getUserData',
'getNoteableData',
'discussionCount',
'unresolvedDiscussions',
'resolvedDiscussionCount',
]),
isLoggedIn() {
return this.getUserData.id;
},
hasNextButton() {
return this.isLoggedIn && !this.allResolved;
},
countText() {
return pluralize('discussion', this.discussionCount);
},
allResolved() {
return this.resolvedDiscussionCount === this.discussionCount;
},
resolveAllDiscussionsIssuePath() {
return this.getNoteableData.create_issue_to_resolve_discussions_path;
},
firstUnresolvedDiscussionId() {
const item = this.unresolvedDiscussions[0] || {};
return item.id;
},
},
created() {
this.resolveSvg = resolveSvg;
this.resolvedSvg = resolvedSvg;
this.mrIssueSvg = mrIssueSvg;
this.nextDiscussionSvg = nextDiscussionSvg;
},
methods: {
jumpToFirstDiscussion() {
const el = document.querySelector(`[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`);
const activeTab = window.mrTabs.currentAction;
if (activeTab === 'commits' || activeTab === 'pipelines') {
window.mrTabs.activateTab('show');
}
if (el) {
scrollToElement(el);
}
},
},
};
</script>
<template>
<div class="line-resolve-all-container prepend-top-10">
<div>
<div
v-if="discussionCount > 0"
:class="{ 'has-next-btn': hasNextButton }"
class="line-resolve-all">
<span
:class="{ 'is-active': allResolved }"
class="line-resolve-btn is-disabled"
type="button">
<span
v-if="allResolved"
v-html="resolvedSvg"
></span>
<span
v-else
v-html="resolveSvg"
></span>
</span>
<span class=".line-resolve-text">
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved
</span>
</div>
<div
v-if="resolveAllDiscussionsIssuePath && !allResolved"
class="btn-group"
role="group">
<a
:href="resolveAllDiscussionsIssuePath"
v-tooltip
title="Resolve all discussions in new issue"
data-container="body"
class="new-issue-for-discussion btn btn-default discussion-create-issue-btn">
<span v-html="mrIssueSvg"></span>
</a>
</div>
<div
v-if="isLoggedIn && !allResolved"
class="btn-group"
role="group">
<button
@click="jumpToFirstDiscussion"
v-tooltip
title="Jump to first unresolved discussion"
data-container="body"
class="btn btn-default discussion-next-btn">
<span v-html="nextDiscussionSvg"></span>
</button>
</div>
</div>
</div>
</template>
Loading
Loading
@@ -4,6 +4,8 @@
import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg';
import editSvg from 'icons/_icon_pencil.svg';
import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg';
import ellipsisSvg from 'icons/_ellipsis_v.svg';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
Loading
Loading
@@ -42,6 +44,26 @@
type: Boolean,
required: true,
},
resolvable: {
type: Boolean,
required: false,
default: false,
},
isResolved: {
type: Boolean,
required: false,
default: false,
},
isResolving: {
type: Boolean,
required: false,
default: false,
},
resolvedBy: {
type: Object,
required: false,
default: () => ({}),
},
canReportAsAbuse: {
type: Boolean,
required: true,
Loading
Loading
@@ -63,6 +85,15 @@
currentUserId() {
return this.getUserDataByProp('id');
},
resolveButtonTitle() {
let title = 'Mark as resolved';
if (this.resolvedBy) {
title = `Resolved by ${this.resolvedBy.name}`;
}
return title;
},
},
created() {
this.emojiSmiling = emojiSmiling;
Loading
Loading
@@ -70,6 +101,8 @@
this.emojiSmiley = emojiSmiley;
this.editSvg = editSvg;
this.ellipsisSvg = ellipsisSvg;
this.resolveDiscussionSvg = resolveDiscussionSvg;
this.resolvedDiscussionSvg = resolvedDiscussionSvg;
},
methods: {
onEdit() {
Loading
Loading
@@ -78,6 +111,9 @@
onDelete() {
this.$emit('handleDelete');
},
onResolve() {
this.$emit('handleResolve');
},
},
};
</script>
Loading
Loading
@@ -89,6 +125,31 @@
class="note-role user-access-role">
{{ accessLevel }}
</span>
<div
v-if="resolvable"
class="note-actions-item">
<button
v-tooltip
@click="onResolve"
:class="{ 'is-disabled': !resolvable, 'is-active': isResolved }"
:title="resolveButtonTitle"
:aria-label="resolveButtonTitle"
type="button"
class="line-resolve-btn note-action-button">
<template v-if="!isResolving">
<div
v-if="isResolved"
v-html="resolvedDiscussionSvg"></div>
<div
v-else
v-html="resolveDiscussionSvg"></div>
</template>
<loading-icon
v-else
:inline="true"
/>
</button>
</div>
<div
v-if="canAddAwardEmoji"
class="note-actions-item">
Loading
Loading
Loading
Loading
@@ -41,7 +41,7 @@
this.initTaskList();
 
if (this.isEditing) {
this.initAutoSave();
this.initAutoSave(this.note.noteable_type);
}
},
updated() {
Loading
Loading
@@ -50,7 +50,7 @@
 
if (this.isEditing) {
if (!this.autosave) {
this.initAutoSave();
this.initAutoSave(this.note.noteable_type);
} else {
this.setAutoSave();
}
Loading
Loading
<script>
import { mapGetters } from 'vuex';
import { mapGetters, mapActions } from 'vuex';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
 
export default {
name: 'IssueNoteForm',
Loading
Loading
@@ -13,6 +14,7 @@
},
mixins: [
issuableStateMixin,
resolvable,
],
props: {
noteBody: {
Loading
Loading
@@ -30,7 +32,7 @@
required: false,
default: 'Save comment',
},
discussion: {
note: {
type: Object,
required: false,
default: () => ({}),
Loading
Loading
@@ -42,9 +44,11 @@
},
data() {
return {
note: this.noteBody,
updatedNoteBody: this.noteBody,
conflictWhileEditing: false,
isSubmitting: false,
isResolving: false,
resolveAsThread: true,
};
},
computed: {
Loading
Loading
@@ -71,13 +75,13 @@
return this.getUserDataByProp('id');
},
isDisabled() {
return !this.note.length || this.isSubmitting;
return !this.updatedNoteBody.length || this.isSubmitting;
},
},
watch: {
noteBody() {
if (this.note === this.noteBody) {
this.note = this.noteBody;
if (this.updatedNoteBody === this.noteBody) {
this.updatedNoteBody = this.noteBody;
} else {
this.conflictWhileEditing = true;
}
Loading
Loading
@@ -87,16 +91,24 @@
this.$refs.textarea.focus();
},
methods: {
handleUpdate() {
...mapActions([
'toggleResolveNote',
]),
handleUpdate(shouldResolve) {
const beforeSubmitDiscussionState = this.discussionResolved;
this.isSubmitting = true;
 
this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => {
this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
this.isSubmitting = false;
if (shouldResolve) {
this.resolveHandler(beforeSubmitDiscussionState);
}
});
},
editMyLastNote() {
if (this.note === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
if (this.updatedNoteBody === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.updatedNoteBody);
 
if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', {
Loading
Loading
@@ -107,7 +119,7 @@
},
cancelHandler(shouldConfirm = false) {
// Sends information about confirm message and if the textarea has changed
this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note);
this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.updatedNoteBody);
},
},
};
Loading
Loading
@@ -150,7 +162,7 @@
js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
:data-supports-quick-actions="!isEditing"
aria-label="Description"
v-model="note"
v-model="updatedNoteBody"
ref="textarea"
slot="textarea"
placeholder="Write a comment or drag your files here..."
Loading
Loading
@@ -168,6 +180,13 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
class="js-vue-issue-save btn btn-save">
{{ saveButtonTitle }}
</button>
<button
v-if="note.resolvable"
@click.prevent="handleUpdate(true)"
class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
>
{{ resolveButtonTitle }}
</button>
<button
@click="cancelHandler()"
class="btn btn-cancel note-edit-cancel"
Loading
Loading
Loading
Loading
@@ -34,15 +34,15 @@
required: false,
default: false,
},
},
data() {
return {
isExpanded: true,
};
expanded: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
toggleChevronClass() {
return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down';
return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down';
},
noteTimestampLink() {
return `#note_${this.noteId}`;
Loading
Loading
@@ -53,7 +53,6 @@
'setTargetNoteHash',
]),
handleToggle() {
this.isExpanded = !this.isExpanded;
this.$emit('toggleHandler');
},
updateTargetNoteHash() {
Loading
Loading
<script>
import { mapActions, mapGetters } from 'vuex';
import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionsSvg from 'icons/_next_discussion.svg';
import Flash from '../../flash';
import { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
Loading
Loading
@@ -8,13 +10,19 @@
import noteSignedOutWidget from './note_signed_out_widget.vue';
import noteEditedText from './note_edited_text.vue';
import noteForm from './note_form.vue';
import diffWithNote from './diff_with_note.vue';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
import tooltip from '../../vue_shared/directives/tooltip';
import { scrollToElement } from '../../lib/utils/common_utils';
 
export default {
components: {
noteableNote,
diffWithNote,
userAvatarLink,
noteHeader,
noteSignedOutWidget,
Loading
Loading
@@ -23,8 +31,13 @@
placeholderNote,
placeholderSystemNote,
},
directives: {
tooltip,
},
mixins: [
autosave,
noteable,
resolvable,
],
props: {
note: {
Loading
Loading
@@ -35,14 +48,25 @@
data() {
return {
isReplying: false,
isResolving: false,
resolveAsThread: true,
};
},
computed: {
...mapGetters([
'getNoteableData',
'discussionCount',
'resolvedDiscussionCount',
'unresolvedDiscussions',
]),
discussion() {
return this.note.notes[0];
return {
...this.note.notes[0],
truncatedDiffLines: this.note.truncated_diff_lines,
diffFile: this.note.diff_file,
diffDiscussion: this.note.diff_discussion,
imageDiffHtml: this.note.image_diff_html,
};
},
author() {
return this.discussion.author;
Loading
Loading
@@ -71,26 +95,40 @@
 
return null;
},
hasUnresolvedDiscussion() {
return this.unresolvedDiscussions.length > 0;
},
wrapperComponent() {
return (this.discussion.diffDiscussion && this.discussion.diffFile) ? diffWithNote : 'div';
},
wrapperClass() {
return this.isDiffDiscussion ? '' : 'panel panel-default';
},
},
mounted() {
if (this.isReplying) {
this.initAutoSave();
this.initAutoSave(this.discussion.noteable_type);
}
},
updated() {
if (this.isReplying) {
if (!this.autosave) {
this.initAutoSave();
this.initAutoSave(this.discussion.noteable_type);
} else {
this.setAutoSave();
}
}
},
created() {
this.resolveDiscussionsSvg = resolveDiscussionsSvg;
this.nextDiscussionsSvg = nextDiscussionsSvg;
},
methods: {
...mapActions([
'saveNote',
'toggleDiscussion',
'removePlaceholderNotes',
'toggleResolveNote',
]),
componentName(note) {
if (note.isPlaceholderNote) {
Loading
Loading
@@ -103,7 +141,7 @@
return noteableNote;
},
componentData(note) {
return note.isPlaceholderNote ? note.notes[0] : note;
return note.isPlaceholderNote ? this.note.notes[0] : note;
},
toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.note.id });
Loading
Loading
@@ -128,7 +166,7 @@
flashContainer: this.$el,
data: {
in_reply_to_discussion_id: this.note.reply_id,
target_type: 'issue',
target_type: this.noteableType,
target_id: this.discussion.noteable_id,
note: { note: noteText },
},
Loading
Loading
@@ -152,12 +190,27 @@ Please check your network connection and try again.`;
});
});
},
jumpToDiscussion() {
const unresolvedIds = this.unresolvedDiscussions.map(d => d.id);
const index = unresolvedIds.indexOf(this.note.id);
if (index >= 0 && index !== unresolvedIds.length) {
const nextId = unresolvedIds[index + 1];
const el = document.querySelector(`[data-discussion-id="${nextId}"]`);
if (el) {
scrollToElement(el);
}
}
},
},
};
</script>
 
<template>
<li class="note note-discussion timeline-entry">
<li
:data-discussion-id="note.id"
class="note note-discussion timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
Loading
Loading
@@ -175,6 +228,7 @@ Please check your network connection and try again.`;
:created-at="discussion.created_at"
:note-id="discussion.id"
:include-toggle="true"
:expanded="note.expanded"
@toggleHandler="toggleDiscussionHandler"
action-text="started a discussion"
class="discussion"
Loading
Loading
@@ -187,43 +241,103 @@ Please check your network connection and try again.`;
class-name="discussion-headline-light js-discussion-headline"
/>
</div>
</div>
<div
v-if="note.expanded"
class="discussion-body">
<div class="panel panel-default">
<div class="discussion-notes">
<ul class="notes">
<component
v-for="note in note.notes"
:is="componentName(note)"
:note="componentData(note)"
:key="note.id"
/>
</ul>
<div
:class="{ 'is-replying': isReplying }"
class="discussion-reply-holder">
<button
v-if="canReply && !isReplying"
@click="showReplyForm"
type="button"
class="js-vue-discussion-reply btn btn-text-field"
title="Add a reply">
Reply...
</button>
<note-form
v-if="isReplying"
save-button-title="Comment"
:discussion="note"
:is-editing="false"
@handleFormUpdate="saveReply"
@cancelFormEdition="cancelReplyForm"
ref="noteForm"
/>
<note-signed-out-widget v-if="!canReply" />
<div
v-if="note.expanded"
class="discussion-body">
<component
:is="wrapperComponent"
:discussion="discussion"
:class="wrapperClass"
>
<div class="discussion-notes">
<ul class="notes">
<component
v-for="note in note.notes"
:is="componentName(note)"
:note="componentData(note)"
:key="note.id"
/>
</ul>
<div
:class="{ 'is-replying': isReplying }"
class="discussion-reply-holder">
<template v-if="!isReplying && canReply">
<div
class="btn-group-justified discussion-with-resolve-btn"
role="group">
<div
class="btn-group"
role="group">
<button
@click="showReplyForm"
type="button"
class="js-vue-discussion-reply btn btn-text-field"
title="Add a reply">Reply...</button>
</div>
<div
v-if="note.resolvable"
class="btn-group"
role="group">
<button
@click="resolveHandler()"
type="button"
class="btn btn-default"
>
<i
v-if="isResolving"
aria-hidden="true"
class="fa fa-spinner fa-spin"
></i>
{{ resolveButtonTitle }}
</button>
</div>
<div
class="btn-group discussion-actions"
role="group">
<div
v-if="note.resolvable && !discussionResolved"
class="btn-group"
role="group">
<a
:href="note.resolve_with_issue_path"
v-tooltip
class="new-issue-for-discussion btn
btn-default discussion-create-issue-btn"
title="Resolve this discussion in a new issue"
data-container="body"
>
<span v-html="resolveDiscussionsSvg"></span>
</a>
</div>
<div
v-if="hasUnresolvedDiscussion"
class="btn-group"
role="group">
<button
@click="jumpToDiscussion"
v-tooltip
class="btn btn-default discussion-next-btn"
title="Jump to next unresolved discussion"
data-container="body"
>
<span v-html="nextDiscussionsSvg"></span>
</button>
</div>
</div>
</div>
</template>
<note-form
v-if="isReplying"
save-button-title="Comment"
:note="note"
:is-editing="false"
@handleFormUpdate="saveReply"
@cancelFormEdition="cancelReplyForm"
ref="noteForm" />
<note-signed-out-widget v-if="!canReply" />
</div>
</div>
</div>
</component>
</div>
</div>
</div>
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment