Render MR Notes with Vue with behind a cookie
This commit is contained in:
parent
0be4a77d00
commit
059ab73b8e
|
|
@ -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('/');
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
@ -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'));
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ const JumpToDiscussion = Vue.extend({
|
|||
}
|
||||
|
||||
$.scrollTo($target, {
|
||||
offset: 0
|
||||
offset: -150
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -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.'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import jQuery from 'jquery';
|
||||
import Cookies from 'js-cookie';
|
||||
import axios from './axios_utils';
|
||||
import { getLocationHash } from './url_utility';
|
||||
import { convertToCamelCase } from './text_utility';
|
||||
|
|
@ -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',
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -241,6 +241,10 @@ export default class MergeRequestTabs {
|
|||
return newState;
|
||||
}
|
||||
|
||||
getCurrentAction() {
|
||||
return this.currentAction;
|
||||
}
|
||||
|
||||
loadCommits(source) {
|
||||
if (this.commitsLoaded) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
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');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
@ -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();
|
||||
|
|
@ -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) {
|
||||
|
|
@ -399,6 +419,8 @@ export default class Notes {
|
|||
this.setupNewNote($updatedNote);
|
||||
}
|
||||
}
|
||||
|
||||
Notes.refreshVueNotes();
|
||||
}
|
||||
|
||||
isParallelView() {
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -818,6 +841,7 @@ export default class Notes {
|
|||
};
|
||||
})(this));
|
||||
|
||||
Notes.refreshVueNotes();
|
||||
Notes.checkMergeRequestStatus();
|
||||
return this.updateNotesCount(-1);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -29,6 +30,12 @@
|
|||
mixins: [
|
||||
issuableStateMixin,
|
||||
],
|
||||
props: {
|
||||
noteableType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
note: '',
|
||||
|
|
@ -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() {
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
@ -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 },
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -221,7 +252,6 @@ Please check your network connection and try again.`;
|
|||
this.$refs.markdownField.previewMarkdown = false;
|
||||
}
|
||||
|
||||
// reset autostave
|
||||
this.autosave.reset();
|
||||
},
|
||||
setNoteType(type) {
|
||||
|
|
@ -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],
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
<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>
|
||||
→
|
||||
<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>
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
<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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
|
|
@ -70,6 +101,8 @@
|
|||
this.emojiSmiley = emojiSmiley;
|
||||
this.editSvg = editSvg;
|
||||
this.ellipsisSvg = ellipsisSvg;
|
||||
this.resolveDiscussionSvg = resolveDiscussionSvg;
|
||||
this.resolvedDiscussionSvg = resolvedDiscussionSvg;
|
||||
},
|
||||
methods: {
|
||||
onEdit() {
|
||||
|
|
@ -78,6 +111,9 @@
|
|||
onDelete() {
|
||||
this.$emit('handleDelete');
|
||||
},
|
||||
onResolve() {
|
||||
this.$emit('handleResolve');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
this.initTaskList();
|
||||
|
||||
if (this.isEditing) {
|
||||
this.initAutoSave();
|
||||
this.initAutoSave(this.note.noteable_type);
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
|
||||
if (this.isEditing) {
|
||||
if (!this.autosave) {
|
||||
this.initAutoSave();
|
||||
this.initAutoSave(this.note.noteable_type);
|
||||
} else {
|
||||
this.setAutoSave();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<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',
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
},
|
||||
mixins: [
|
||||
issuableStateMixin,
|
||||
resolvable,
|
||||
],
|
||||
props: {
|
||||
noteBody: {
|
||||
|
|
@ -30,7 +32,7 @@
|
|||
required: false,
|
||||
default: 'Save comment',
|
||||
},
|
||||
discussion: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
|
|
@ -42,9 +44,11 @@
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
note: this.noteBody,
|
||||
updatedNoteBody: this.noteBody,
|
||||
conflictWhileEditing: false,
|
||||
isSubmitting: false,
|
||||
isResolving: false,
|
||||
resolveAsThread: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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', {
|
||||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -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..."
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
@ -53,7 +53,6 @@
|
|||
'setTargetNoteHash',
|
||||
]),
|
||||
handleToggle() {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
this.$emit('toggleHandler');
|
||||
},
|
||||
updateTargetNoteHash() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<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';
|
||||
|
|
@ -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,
|
||||
|
|
@ -23,8 +31,13 @@
|
|||
placeholderNote,
|
||||
placeholderSystemNote,
|
||||
},
|
||||
directives: {
|
||||
tooltip,
|
||||
},
|
||||
mixins: [
|
||||
autosave,
|
||||
noteable,
|
||||
resolvable,
|
||||
],
|
||||
props: {
|
||||
note: {
|
||||
|
|
@ -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;
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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 },
|
||||
},
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
import noteActions from './note_actions.vue';
|
||||
import noteBody from './note_body.vue';
|
||||
import eventHub from '../event_hub';
|
||||
import noteable from '../mixins/noteable';
|
||||
import resolvable from '../mixins/resolvable';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
|
@ -15,6 +17,10 @@
|
|||
noteActions,
|
||||
noteBody,
|
||||
},
|
||||
mixins: [
|
||||
noteable,
|
||||
resolvable,
|
||||
],
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
|
|
@ -26,6 +32,7 @@
|
|||
isEditing: false,
|
||||
isDeleting: false,
|
||||
isRequesting: false,
|
||||
isResolving: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -65,6 +72,7 @@
|
|||
...mapActions([
|
||||
'deleteNote',
|
||||
'updateNote',
|
||||
'toggleResolveNote',
|
||||
'scrollToNoteIfNeeded',
|
||||
]),
|
||||
editHandler() {
|
||||
|
|
@ -89,7 +97,7 @@
|
|||
const data = {
|
||||
endpoint: this.note.path,
|
||||
note: {
|
||||
target_type: 'issue',
|
||||
target_type: this.noteableType,
|
||||
target_id: this.note.noteable_id,
|
||||
note: { note: noteText },
|
||||
},
|
||||
|
|
@ -134,7 +142,7 @@
|
|||
// 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 = noteText;
|
||||
this.$refs.noteBody.$refs.noteForm.note.note = noteText;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -171,8 +179,13 @@
|
|||
:can-delete="note.current_user.can_edit"
|
||||
:can-report-as-abuse="canReportAsAbuse"
|
||||
:report-abuse-path="note.report_abuse_path"
|
||||
:resolvable="note.resolvable"
|
||||
:is-resolved="note.resolved"
|
||||
:is-resolving="isResolving"
|
||||
:resolved-by="note.resolved_by"
|
||||
@handleEdit="editHandler"
|
||||
@handleDelete="deleteHandler"
|
||||
@handleResolve="resolveHandler"
|
||||
/>
|
||||
</div>
|
||||
<note-body
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
|
||||
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
|
||||
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
|
||||
import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
|
||||
|
||||
export default {
|
||||
name: 'NotesApp',
|
||||
|
|
@ -48,7 +49,24 @@
|
|||
...mapGetters([
|
||||
'notes',
|
||||
'getNotesDataByProp',
|
||||
'discussionCount',
|
||||
]),
|
||||
noteableType() {
|
||||
// FIXME -- @fatihacet Get this from JSON data.
|
||||
const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants;
|
||||
|
||||
return this.noteableData.merge_params ? MERGE_REQUEST_NOTEABLE_TYPE : ISSUE_NOTEABLE_TYPE;
|
||||
},
|
||||
allNotes() {
|
||||
if (this.isLoading) {
|
||||
const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0;
|
||||
|
||||
return new Array(totalNotes).fill({
|
||||
isSkeletonNote: true,
|
||||
});
|
||||
}
|
||||
return this.notes;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.setNotesData(this.notesData);
|
||||
|
|
@ -67,6 +85,10 @@
|
|||
this.actionToggleAward({ awardName, noteId });
|
||||
});
|
||||
}
|
||||
document.addEventListener('refreshVueNotes', this.fetchNotes);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('refreshVueNotes', this.fetchNotes);
|
||||
},
|
||||
methods: {
|
||||
...mapActions({
|
||||
|
|
@ -81,6 +103,9 @@
|
|||
setTargetNoteHash: 'setTargetNoteHash',
|
||||
}),
|
||||
getComponentName(note) {
|
||||
if (note.isSkeletonNote) {
|
||||
return skeletonLoadingContainer;
|
||||
}
|
||||
if (note.isPlaceholderNote) {
|
||||
if (note.placeholderType === constants.SYSTEM_NOTE) {
|
||||
return placeholderSystemNote;
|
||||
|
|
@ -109,9 +134,14 @@
|
|||
});
|
||||
},
|
||||
initPolling() {
|
||||
if (this.isPollingInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
|
||||
|
||||
this.poll();
|
||||
this.isPollingInitialized = true;
|
||||
},
|
||||
checkLocationHash() {
|
||||
const hash = getLocationHash();
|
||||
|
|
@ -128,25 +158,20 @@
|
|||
|
||||
<template>
|
||||
<div id="notes">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="js-loading loading">
|
||||
<loading-icon />
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-if="!isLoading"
|
||||
id="notes-list"
|
||||
class="notes main-notes-list timeline">
|
||||
|
||||
<component
|
||||
v-for="note in notes"
|
||||
v-for="note in allNotes"
|
||||
:is="getComponentName(note)"
|
||||
:note="getComponentData(note)"
|
||||
:key="note.id"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<comment-form />
|
||||
<comment-form
|
||||
:noteable-type="noteableType"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const DISCUSSION_NOTE = 'DiscussionNote';
|
||||
export const DIFF_NOTE = 'DiffNote';
|
||||
export const DISCUSSION = 'discussion';
|
||||
export const NOTE = 'note';
|
||||
export const SYSTEM_NOTE = 'systemNote';
|
||||
|
|
@ -8,4 +9,7 @@ export const REOPENED = 'reopened';
|
|||
export const CLOSED = 'closed';
|
||||
export const EMOJI_THUMBSUP = 'thumbsup';
|
||||
export const EMOJI_THUMBSDOWN = 'thumbsdown';
|
||||
export const NOTEABLE_TYPE = 'Issue';
|
||||
export const ISSUE_NOTEABLE_TYPE = 'issue';
|
||||
export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request';
|
||||
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
|
||||
export const RESOLVE_NOTE_METHOD_NAME = 'post';
|
||||
|
|
|
|||
|
|
@ -20,17 +20,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
|
|||
return {
|
||||
noteableData: JSON.parse(notesDataset.noteableData),
|
||||
currentUserData,
|
||||
notesData: {
|
||||
lastFetchedAt: notesDataset.lastFetchedAt,
|
||||
discussionsPath: notesDataset.discussionsPath,
|
||||
newSessionPath: notesDataset.newSessionPath,
|
||||
registerPath: notesDataset.registerPath,
|
||||
notesPath: notesDataset.notesPath,
|
||||
markdownDocsPath: notesDataset.markdownDocsPath,
|
||||
quickActionsDocsPath: notesDataset.quickActionsDocsPath,
|
||||
closeIssuePath: notesDataset.closeIssuePath,
|
||||
reopenIssuePath: notesDataset.reopenIssuePath,
|
||||
},
|
||||
notesData: JSON.parse(notesDataset.notesData),
|
||||
};
|
||||
},
|
||||
render(createElement) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import Autosave from '../../autosave';
|
||||
import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
initAutoSave() {
|
||||
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue');
|
||||
initAutoSave(noteableType) {
|
||||
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', capitalizeFirstCharacter(noteableType), this.note.id]);
|
||||
},
|
||||
resetAutoSave() {
|
||||
this.autosave.reset();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import * as constants from '../constants';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
noteableType() {
|
||||
switch (this.note.noteable_type) {
|
||||
case 'MergeRequest':
|
||||
return constants.MERGE_REQUEST_NOTEABLE_TYPE;
|
||||
case 'Issue':
|
||||
return constants.ISSUE_NOTEABLE_TYPE;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import Flash from '~/flash';
|
||||
import { __ } from '~/locale';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
discussionResolved() {
|
||||
const { notes, resolved } = this.note;
|
||||
|
||||
if (notes) { // Decide resolved state using store. Only valid for discussions.
|
||||
return notes.every(note => note.resolved && !note.system);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
},
|
||||
resolveButtonTitle() {
|
||||
if (this.updatedNoteBody) {
|
||||
if (this.discussionResolved) {
|
||||
return __('Comment and unresolve discussion');
|
||||
}
|
||||
|
||||
return __('Comment and resolve discussion');
|
||||
}
|
||||
return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
resolveHandler(resolvedState = false) {
|
||||
this.isResolving = true;
|
||||
const endpoint = this.note.resolve_path || `${this.note.path}/resolve`;
|
||||
const isResolved = this.discussionResolved || resolvedState;
|
||||
const discussion = this.resolveAsThread;
|
||||
|
||||
this.toggleResolveNote({ endpoint, isResolved, discussion })
|
||||
.then(() => {
|
||||
this.isResolving = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.isResolving = false;
|
||||
const msg = __('Something went wrong while resolving this discussion. Please try again.');
|
||||
Flash(msg, 'alert', this.$el);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import Vue from 'vue';
|
||||
import VueResource from 'vue-resource';
|
||||
import * as constants from '../constants';
|
||||
|
||||
Vue.use(VueResource);
|
||||
|
||||
|
|
@ -19,6 +20,12 @@ export default {
|
|||
createNewNote(endpoint, data) {
|
||||
return Vue.http.post(endpoint, data, { emulateJSON: true });
|
||||
},
|
||||
toggleResolveNote(endpoint, isResolved) {
|
||||
const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants;
|
||||
const method = isResolved ? UNRESOLVE_NOTE_METHOD_NAME : RESOLVE_NOTE_METHOD_NAME;
|
||||
|
||||
return Vue.http[method](endpoint);
|
||||
},
|
||||
poll(data = {}) {
|
||||
const { endpoint, lastFetchedAt } = data;
|
||||
const options = {
|
||||
|
|
|
|||
|
|
@ -61,8 +61,17 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service
|
|||
export const removePlaceholderNotes = ({ commit }) =>
|
||||
commit(types.REMOVE_PLACEHOLDER_NOTES);
|
||||
|
||||
export const toggleResolveNote = ({ commit }, { endpoint, isResolved, discussion }) => service
|
||||
.toggleResolveNote(endpoint, isResolved)
|
||||
.then(res => res.json())
|
||||
.then((res) => {
|
||||
const mutationType = discussion ? types.UPDATE_DISCUSSION : types.UPDATE_NOTE;
|
||||
|
||||
commit(mutationType, res);
|
||||
});
|
||||
|
||||
export const closeIssue = ({ commit, dispatch, state }) => service
|
||||
.toggleIssueState(state.notesData.closeIssuePath)
|
||||
.toggleIssueState(state.notesData.closePath)
|
||||
.then(res => res.json())
|
||||
.then((data) => {
|
||||
commit(types.CLOSE_ISSUE);
|
||||
|
|
@ -70,7 +79,7 @@ export const closeIssue = ({ commit, dispatch, state }) => service
|
|||
});
|
||||
|
||||
export const reopenIssue = ({ commit, dispatch, state }) => service
|
||||
.toggleIssueState(state.notesData.reopenIssuePath)
|
||||
.toggleIssueState(state.notesData.reopenPath)
|
||||
.then(res => res.json())
|
||||
.then((data) => {
|
||||
commit(types.REOPEN_ISSUE);
|
||||
|
|
@ -80,7 +89,7 @@ export const reopenIssue = ({ commit, dispatch, state }) => service
|
|||
export const emitStateChangedEvent = ({ commit, getters }, data) => {
|
||||
const event = new CustomEvent('issuable_vue_app:change', { detail: {
|
||||
data,
|
||||
isClosed: getters.issueState === constants.CLOSED,
|
||||
isClosed: getters.openState === constants.CLOSED,
|
||||
} });
|
||||
|
||||
document.dispatchEvent(event);
|
||||
|
|
@ -174,7 +183,7 @@ const pollSuccessCallBack = (resp, commit, state, getters) => {
|
|||
resp.notes.forEach((note) => {
|
||||
if (notesById[note.id]) {
|
||||
commit(types.UPDATE_NOTE, note);
|
||||
} else if (note.type === constants.DISCUSSION_NOTE) {
|
||||
} else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) {
|
||||
const discussion = utils.findNoteObjectById(state.notes, note.discussion_id);
|
||||
|
||||
if (discussion) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop];
|
|||
|
||||
export const getNoteableData = state => state.noteableData;
|
||||
export const getNoteableDataByProp = state => prop => state.noteableData[prop];
|
||||
export const issueState = state => state.noteableData.state;
|
||||
export const openState = state => state.noteableData.state;
|
||||
|
||||
export const getUserData = state => state.userData || {};
|
||||
export const getUserDataByProp = state => prop => state.userData && state.userData[prop];
|
||||
|
|
@ -30,3 +30,37 @@ export const getCurrentUserLastNote = state => _.flatten(
|
|||
|
||||
export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes)
|
||||
.find(el => isLastNote(el, state));
|
||||
|
||||
export const discussionCount = (state) => {
|
||||
const discussions = state.notes.filter(n => !n.individual_note);
|
||||
|
||||
return discussions.length;
|
||||
};
|
||||
|
||||
export const unresolvedDiscussions = (state, getters) => {
|
||||
const resolvedMap = getters.resolvedDiscussionsById;
|
||||
|
||||
return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]);
|
||||
};
|
||||
|
||||
export const resolvedDiscussionsById = (state) => {
|
||||
const map = {};
|
||||
|
||||
state.notes.forEach((n) => {
|
||||
if (n.notes) {
|
||||
const resolved = n.notes.every(note => note.resolved && !note.system);
|
||||
|
||||
if (resolved) {
|
||||
map[n.id] = n;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
export const resolvedDiscussionCount = (state, getters) => {
|
||||
const resolvedMap = getters.resolvedDiscussionsById;
|
||||
|
||||
return Object.keys(resolvedMap).length;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
|
|||
export const TOGGLE_AWARD = 'TOGGLE_AWARD';
|
||||
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
|
||||
export const UPDATE_NOTE = 'UPDATE_NOTE';
|
||||
export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION';
|
||||
|
||||
// Issue
|
||||
export const CLOSE_ISSUE = 'CLOSE_ISSUE';
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
import * as utils from './utils';
|
||||
import * as types from './mutation_types';
|
||||
import * as constants from '../constants';
|
||||
import { isInMRPage } from '../../lib/utils/common_utils';
|
||||
|
||||
export default {
|
||||
[types.ADD_NEW_NOTE](state, note) {
|
||||
const { discussion_id, type } = note;
|
||||
const [exists] = state.notes.filter(n => n.id === note.discussion_id);
|
||||
const isDiscussion = (type === constants.DISCUSSION_NOTE);
|
||||
|
||||
if (!exists) {
|
||||
const noteData = {
|
||||
expanded: true,
|
||||
id: discussion_id,
|
||||
individual_note: !(type === constants.DISCUSSION_NOTE),
|
||||
individual_note: !isDiscussion,
|
||||
notes: [note],
|
||||
reply_id: discussion_id,
|
||||
};
|
||||
|
||||
if (isDiscussion && isInMRPage()) {
|
||||
noteData.resolvable = note.resolvable;
|
||||
noteData.resolved = false;
|
||||
noteData.resolve_path = note.resolve_path;
|
||||
noteData.resolve_with_issue_path = note.resolve_with_issue_path;
|
||||
}
|
||||
|
||||
state.notes.push(noteData);
|
||||
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -25,6 +35,7 @@ export default {
|
|||
|
||||
if (noteObj) {
|
||||
noteObj.notes.push(note);
|
||||
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -41,6 +52,8 @@ export default {
|
|||
state.notes.splice(state.notes.indexOf(noteObj), 1);
|
||||
}
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
|
||||
},
|
||||
|
||||
[types.REMOVE_PLACEHOLDER_NOTES](state) {
|
||||
|
|
@ -77,15 +90,19 @@ export default {
|
|||
const notes = [];
|
||||
|
||||
notesData.forEach((note) => {
|
||||
const nn = Object.assign({}, note);
|
||||
|
||||
// To support legacy notes, should be very rare case.
|
||||
if (note.individual_note && note.notes.length > 1) {
|
||||
note.notes.forEach((n) => {
|
||||
const nn = Object.assign({}, note);
|
||||
nn.notes = [n]; // override notes array to only have one item to mimick individual_note
|
||||
notes.push(nn);
|
||||
});
|
||||
} else {
|
||||
notes.push(note);
|
||||
const oldNote = utils.findNoteObjectById(state.notes, note.id);
|
||||
nn.expanded = oldNote ? oldNote.expanded : note.expanded;
|
||||
|
||||
notes.push(nn);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -134,6 +151,8 @@ export default {
|
|||
user: { id, name, username },
|
||||
});
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
|
||||
},
|
||||
|
||||
[types.TOGGLE_DISCUSSION](state, { discussionId }) {
|
||||
|
|
@ -151,6 +170,24 @@ export default {
|
|||
const comment = utils.findNoteObjectById(noteObj.notes, note.id);
|
||||
noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note);
|
||||
}
|
||||
|
||||
// document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
|
||||
},
|
||||
|
||||
[types.UPDATE_DISCUSSION](state, noteData) {
|
||||
const note = noteData;
|
||||
let index = 0;
|
||||
|
||||
state.notes.forEach((n, i) => {
|
||||
if (n.id === note.id) {
|
||||
index = i;
|
||||
}
|
||||
});
|
||||
|
||||
note.expanded = true; // override expand flag to prevent collapse
|
||||
state.notes.splice(index, 1, note);
|
||||
|
||||
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
|
||||
},
|
||||
|
||||
[types.CLOSE_ISSUE](state) {
|
||||
|
|
|
|||
|
|
@ -28,4 +28,3 @@ export const getQuickActionText = (note) => {
|
|||
export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
|
||||
|
||||
export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
initPipelines();
|
||||
|
||||
const mrShowNode = document.querySelector('.merge-request');
|
||||
|
||||
window.mergeRequest = new MergeRequest({
|
||||
action: mrShowNode.dataset.mrAction,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script>
|
||||
import tooltip from '../directives/tooltip';
|
||||
/**
|
||||
* Falls back to the code used in `copy_to_clipboard.js`
|
||||
*/
|
||||
import tooltip from '../directives/tooltip';
|
||||
|
||||
export default {
|
||||
name: 'ClipboardButton',
|
||||
|
|
|
|||
|
|
@ -11,14 +11,12 @@
|
|||
default: false,
|
||||
required: false,
|
||||
},
|
||||
|
||||
isConfidential: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
warningIcon() {
|
||||
if (this.isConfidential) return 'eye-slash';
|
||||
|
|
@ -26,7 +24,6 @@
|
|||
|
||||
return '';
|
||||
},
|
||||
|
||||
isLockedAndConfidential() {
|
||||
return this.isConfidential && this.isLocked;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<li class="timeline-entry note">
|
||||
<div class="timeline-entry-inner">
|
||||
<div class="timeline-icon">
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="note-header"></div>
|
||||
<div class="note-body">
|
||||
<skeleton-loading-container />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
skeletonLoadingContainer,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -723,7 +723,7 @@ ul.notes {
|
|||
.line-resolve-all {
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
padding: 5px 10px 6px;
|
||||
padding: 6px 10px;
|
||||
background-color: $gray-light;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $border-radius-default;
|
||||
|
|
|
|||
|
|
@ -77,6 +77,20 @@ module IssuableActions
|
|||
render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" }
|
||||
end
|
||||
|
||||
def discussions
|
||||
notes = issuable.notes
|
||||
.inc_relations_for_view
|
||||
.includes(:noteable)
|
||||
.fresh
|
||||
|
||||
notes = prepare_notes_for_rendering(notes)
|
||||
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
|
||||
|
||||
discussions = Discussion.build_collection(notes, issuable)
|
||||
|
||||
render json: DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user).represent(discussions, context: self)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def recaptcha_check_if_spammable(should_redirect = true, &block)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ module NotesActions
|
|||
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
|
||||
|
||||
notes_json[:notes] =
|
||||
if noteable.discussions_rendered_on_frontend?
|
||||
if use_note_serializer?
|
||||
note_serializer.represent(notes)
|
||||
else
|
||||
notes.map { |note| note_json(note) }
|
||||
|
|
@ -95,7 +95,7 @@ module NotesActions
|
|||
if note.persisted?
|
||||
attrs[:valid] = true
|
||||
|
||||
if noteable.discussions_rendered_on_frontend?
|
||||
if use_note_serializer?
|
||||
attrs.merge!(note_serializer.represent(note))
|
||||
else
|
||||
attrs.merge!(
|
||||
|
|
@ -233,4 +233,14 @@ module NotesActions
|
|||
the_project
|
||||
end
|
||||
end
|
||||
|
||||
def use_note_serializer?
|
||||
return false if params['html']
|
||||
|
||||
if noteable.is_a?(MergeRequest)
|
||||
cookies[:vue_mr_discussions] == 'true'
|
||||
else
|
||||
noteable.discussions_rendered_on_frontend?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
class Projects::DiscussionsController < Projects::ApplicationController
|
||||
include NotesHelper
|
||||
include RendersNotes
|
||||
|
||||
before_action :check_merge_requests_available!
|
||||
before_action :merge_request
|
||||
before_action :discussion
|
||||
|
|
@ -7,22 +10,45 @@ class Projects::DiscussionsController < Projects::ApplicationController
|
|||
def resolve
|
||||
Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion)
|
||||
|
||||
render json: {
|
||||
resolved_by: discussion.resolved_by.try(:name),
|
||||
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
|
||||
}
|
||||
render_discussion
|
||||
end
|
||||
|
||||
def unresolve
|
||||
discussion.unresolve!
|
||||
|
||||
render json: {
|
||||
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
|
||||
}
|
||||
render_discussion
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_discussion
|
||||
if serialize_notes?
|
||||
# TODO - It is not needed to serialize notes when resolving
|
||||
# or unresolving discussions. We should remove this behavior
|
||||
# passing a parameter to DiscussionEntity to return an empty array
|
||||
# for notes.
|
||||
# Check issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/42853
|
||||
prepare_notes_for_rendering(discussion.notes, merge_request)
|
||||
render_json_with_discussions_serializer
|
||||
else
|
||||
render_json_with_html
|
||||
end
|
||||
end
|
||||
|
||||
def render_json_with_discussions_serializer
|
||||
render json:
|
||||
DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user)
|
||||
.represent(discussion, context: self)
|
||||
end
|
||||
|
||||
# Legacy method used to render discussions notes when not using Vue on views.
|
||||
def render_json_with_html
|
||||
render json: {
|
||||
resolved_by: discussion.resolved_by.try(:name),
|
||||
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
|
||||
}
|
||||
end
|
||||
|
||||
def merge_request
|
||||
@merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id])
|
||||
end
|
||||
|
|
|
|||
|
|
@ -60,20 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
respond_with(@issue)
|
||||
end
|
||||
|
||||
def discussions
|
||||
notes = @issue.notes
|
||||
.inc_relations_for_view
|
||||
.includes(:noteable)
|
||||
.fresh
|
||||
|
||||
notes = prepare_notes_for_rendering(notes)
|
||||
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
|
||||
|
||||
discussions = Discussion.build_collection(notes, @issue)
|
||||
|
||||
render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions)
|
||||
end
|
||||
|
||||
def create
|
||||
create_params = issue_params.merge(spammable_params).merge(
|
||||
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
class Projects::NotesController < Projects::ApplicationController
|
||||
include NotesActions
|
||||
include NotesHelper
|
||||
include ToggleAwardEmoji
|
||||
|
||||
before_action :whitelist_query_limiting, only: [:create]
|
||||
|
|
@ -38,10 +39,14 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
|
||||
discussion = note.discussion
|
||||
|
||||
render json: {
|
||||
resolved_by: note.resolved_by.try(:name),
|
||||
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
|
||||
}
|
||||
if serialize_notes?
|
||||
render_json_with_notes_serializer
|
||||
else
|
||||
render json: {
|
||||
resolved_by: note.resolved_by.try(:name),
|
||||
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def unresolve
|
||||
|
|
@ -51,16 +56,27 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
|
||||
discussion = note.discussion
|
||||
|
||||
render json: {
|
||||
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
|
||||
}
|
||||
if serialize_notes?
|
||||
render_json_with_notes_serializer
|
||||
else
|
||||
render json: {
|
||||
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_json_with_notes_serializer
|
||||
Notes::RenderService.new(current_user).execute([note], project)
|
||||
|
||||
render json: note_serializer.represent(note)
|
||||
end
|
||||
|
||||
def note
|
||||
@note ||= @project.notes.find(params[:id])
|
||||
end
|
||||
|
||||
alias_method :awardable, :note
|
||||
|
||||
def finder_params
|
||||
|
|
|
|||
|
|
@ -151,7 +151,38 @@ module NotesHelper
|
|||
}
|
||||
end
|
||||
|
||||
def notes_data(issuable)
|
||||
discussions_path =
|
||||
if issuable.is_a?(Issue)
|
||||
discussions_project_issue_path(@project, issuable, format: :json)
|
||||
else
|
||||
discussions_project_merge_request_path(@project, issuable, format: :json)
|
||||
end
|
||||
|
||||
{
|
||||
discussionsPath: discussions_path,
|
||||
registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
|
||||
newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
|
||||
markdownDocsPath: help_page_path('user/markdown'),
|
||||
quickActionsDocsPath: help_page_path('user/project/quick_actions'),
|
||||
closePath: close_issuable_path(issuable),
|
||||
reopenPath: reopen_issuable_path(issuable),
|
||||
notesPath: notes_url,
|
||||
totalNotes: issuable.discussions.length,
|
||||
lastFetchedAt: Time.now
|
||||
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def discussion_resolved_intro(discussion)
|
||||
discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved'
|
||||
end
|
||||
|
||||
def has_vue_discussions_cookie?
|
||||
cookies[:vue_mr_discussions] == 'true'
|
||||
end
|
||||
|
||||
def serialize_notes?
|
||||
has_vue_discussions_cookie? && !params['html']
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ class Note < ActiveRecord::Base
|
|||
|
||||
def find_discussion(discussion_id)
|
||||
notes = where(discussion_id: discussion_id).fresh.to_a
|
||||
|
||||
return if notes.empty?
|
||||
|
||||
Discussion.build(notes)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
class DiffFileEntity < Grape::Entity
|
||||
include DiffHelper
|
||||
include SubmoduleHelper
|
||||
include BlobHelper
|
||||
include IconsHelper
|
||||
include ActionView::Helpers::TagHelper
|
||||
|
||||
expose :submodule?, as: :submodule
|
||||
|
||||
expose :submodule_link do |diff_file|
|
||||
submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository).first
|
||||
end
|
||||
|
||||
expose :blob_path do |diff_file|
|
||||
diff_file.blob.path
|
||||
end
|
||||
|
||||
expose :blob_icon do |diff_file|
|
||||
blob_icon(diff_file.b_mode, diff_file.file_path)
|
||||
end
|
||||
|
||||
expose :file_path
|
||||
expose :deleted_file?, as: :deleted_file
|
||||
expose :renamed_file?, as: :renamed_file
|
||||
expose :old_path
|
||||
expose :new_path
|
||||
expose :mode_changed?, as: :mode_changed
|
||||
expose :a_mode
|
||||
expose :b_mode
|
||||
expose :text?, as: :text
|
||||
|
||||
expose :old_path_html do |diff_file|
|
||||
old_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
|
||||
old_path
|
||||
end
|
||||
|
||||
expose :new_path_html do |diff_file|
|
||||
_, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
|
||||
new_path
|
||||
end
|
||||
end
|
||||
|
|
@ -7,4 +7,42 @@ class DiscussionEntity < Grape::Entity
|
|||
expose :notes, using: NoteEntity
|
||||
|
||||
expose :individual_note?, as: :individual_note
|
||||
expose :resolvable?, as: :resolvable
|
||||
expose :resolved?, as: :resolved
|
||||
expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion|
|
||||
resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id)
|
||||
end
|
||||
expose :resolve_with_issue_path do |discussion|
|
||||
new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id)
|
||||
end
|
||||
|
||||
expose :diff_file, using: DiffFileEntity, if: -> (d, _) { defined? d.diff_file }
|
||||
|
||||
expose :diff_discussion?, as: :diff_discussion
|
||||
|
||||
expose :truncated_diff_lines, if: -> (d, _) { (defined? d.diff_file) && d.diff_file.text? } do |discussion|
|
||||
options[:context].render_to_string(
|
||||
partial: "projects/diffs/line",
|
||||
collection: discussion.truncated_diff_lines,
|
||||
as: :line,
|
||||
locals: { diff_file: discussion.diff_file,
|
||||
discussion_expanded: true,
|
||||
plain: true },
|
||||
layout: false,
|
||||
formats: [:html]
|
||||
)
|
||||
end
|
||||
|
||||
expose :image_diff_html, if: -> (d, _) { defined? d.diff_file } do |discussion|
|
||||
diff_file = discussion.diff_file
|
||||
partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff'
|
||||
options[:context].render_to_string(
|
||||
partial: "projects/diffs/#{partial}",
|
||||
locals: { diff_file: diff_file,
|
||||
position: discussion.position.to_json,
|
||||
click_to_comment: false },
|
||||
layout: false,
|
||||
formats: [:html]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -115,6 +115,14 @@ class MergeRequestWidgetEntity < IssuableEntity
|
|||
expose :can_cherry_pick_on_current_merge_request do |merge_request|
|
||||
presenter(merge_request).can_cherry_pick_on_current_merge_request?
|
||||
end
|
||||
|
||||
expose :can_create_note do |issue|
|
||||
can?(request.current_user, :create_note, issue.project)
|
||||
end
|
||||
|
||||
expose :can_update do |issue|
|
||||
can?(request.current_user, :update_issue, issue)
|
||||
end
|
||||
end
|
||||
|
||||
# Paths
|
||||
|
|
@ -189,6 +197,10 @@ class MergeRequestWidgetEntity < IssuableEntity
|
|||
end
|
||||
end
|
||||
|
||||
expose :create_note_path do |merge_request|
|
||||
project_notes_path(merge_request.project, target_type: 'merge_request', target_id: merge_request.id)
|
||||
end
|
||||
|
||||
expose :commit_change_content_path do |merge_request|
|
||||
commit_change_content_project_merge_request_path(merge_request.project, merge_request)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ class NoteEntity < API::Entities::Note
|
|||
end
|
||||
end
|
||||
|
||||
expose :resolved?, as: :resolved
|
||||
expose :resolvable?, as: :resolvable
|
||||
expose :resolved_by, using: NoteUserEntity
|
||||
|
||||
expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note|
|
||||
SystemNoteHelper.system_note_icon_name(note)
|
||||
end
|
||||
|
|
@ -53,6 +57,14 @@ class NoteEntity < API::Entities::Note
|
|||
end
|
||||
end
|
||||
|
||||
expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
|
||||
resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id)
|
||||
end
|
||||
|
||||
expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
|
||||
new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id)
|
||||
end
|
||||
|
||||
expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
|
||||
expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
|
||||
delete_attachment_project_note_path(note.project, note)
|
||||
|
|
|
|||
|
|
@ -6,14 +6,6 @@
|
|||
= link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
|
||||
|
||||
%section.js-vue-notes-event
|
||||
#js-vue-notes{ data: { discussions_path: discussions_project_issue_path(@project, @issue, format: :json),
|
||||
register_path: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
|
||||
new_session_path: new_session_path(:user, redirect_to_referer: 'yes'),
|
||||
markdown_docs_path: help_page_path('user/markdown'),
|
||||
quick_actions_docs_path: help_page_path('user/project/quick_actions'),
|
||||
notes_path: notes_url,
|
||||
close_issue_path: issue_path(@issue, issue: { state_event: :close }, format: 'json'),
|
||||
reopen_issue_path: issue_path(@issue, issue: { state_event: :reopen }, format: 'json'),
|
||||
last_fetched_at: Time.now.to_i,
|
||||
noteable_data: serialize_issuable(@issue),
|
||||
current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } }
|
||||
#js-vue-notes{ data: { notes_data: notes_data(@issue),
|
||||
noteable_data: serialize_issuable(@issue),
|
||||
current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } }
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@
|
|||
|
||||
.content-block.emoji-block
|
||||
.row
|
||||
.col-sm-8.js-issue-note-awards
|
||||
.col-sm-8.js-noteable-awards
|
||||
= render 'award_emoji/awards_block', awardable: @issue, inline: true
|
||||
.col-sm-4.new-branch-col
|
||||
= render 'new_branch' unless @issue.confidential?
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
- @gfm_form = true
|
||||
- @content_class = "limit-container-width" unless fluid_layout
|
||||
- add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project)
|
||||
- breadcrumb_title @merge_request.to_reference
|
||||
|
|
@ -7,6 +8,9 @@
|
|||
- content_for :page_specific_javascripts do
|
||||
= webpack_bundle_tag('common_vue')
|
||||
|
||||
- if has_vue_discussions_cookie?
|
||||
= webpack_bundle_tag('mr_notes')
|
||||
|
||||
.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } }
|
||||
= render "projects/merge_requests/mr_title"
|
||||
|
||||
|
|
@ -23,7 +27,7 @@
|
|||
|
||||
#js-vue-mr-widget.mr-widget
|
||||
|
||||
.content-block.content-block-small.emoji-list-container
|
||||
.content-block.content-block-small.emoji-list-container.js-noteable-awards
|
||||
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true
|
||||
|
||||
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
|
||||
|
|
@ -51,28 +55,37 @@
|
|||
= tab_link_for @merge_request, :diffs do
|
||||
Changes
|
||||
%span.badge= @merge_request.diff_size
|
||||
#resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true }
|
||||
%resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
|
||||
%div
|
||||
.line-resolve-all{ "v-show" => "discussionCount > 0",
|
||||
":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
|
||||
%span.line-resolve-btn.is-disabled{ type: "button",
|
||||
":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
|
||||
%template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' }
|
||||
= render 'shared/icons/icon_status_success_solid.svg'
|
||||
%template{ 'v-else' => '' }
|
||||
= render 'shared/icons/icon_resolve_discussion.svg'
|
||||
%span.line-resolve-text
|
||||
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved
|
||||
= render "discussions/new_issue_for_all_discussions", merge_request: @merge_request
|
||||
= render "discussions/jump_to_next"
|
||||
|
||||
- if has_vue_discussions_cookie?
|
||||
#js-vue-discussion-counter
|
||||
- else
|
||||
#resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true }
|
||||
%resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
|
||||
%div
|
||||
.line-resolve-all{ "v-show" => "discussionCount > 0",
|
||||
":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
|
||||
%span.line-resolve-btn.is-disabled{ type: "button",
|
||||
":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
|
||||
%template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' }
|
||||
= render 'shared/icons/icon_status_success_solid.svg'
|
||||
%template{ 'v-else' => '' }
|
||||
= render 'shared/icons/icon_resolve_discussion.svg'
|
||||
%span.line-resolve-text
|
||||
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved
|
||||
= render "discussions/new_issue_for_all_discussions", merge_request: @merge_request
|
||||
= render "discussions/jump_to_next"
|
||||
|
||||
.tab-content#diff-notes-app
|
||||
#notes.notes.tab-pane.voting_notes
|
||||
.row
|
||||
%section.col-md-12
|
||||
.issuable-discussion
|
||||
%script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
|
||||
.issuable-discussion.js-vue-notes-event
|
||||
= render "projects/merge_requests/discussion"
|
||||
- if has_vue_discussions_cookie?
|
||||
#js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request),
|
||||
noteable_data: serialize_issuable(@merge_request),
|
||||
current_user_data: UserSerializer.new.represent(current_user).to_json} }
|
||||
|
||||
#commits.commits.tab-pane
|
||||
-# This tab is always loaded via AJAX
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
- issuable = @issue || @merge_request
|
||||
- discussion_locked = issuable&.discussion_locked?
|
||||
|
||||
%ul#notes-list.notes.main-notes-list.timeline
|
||||
= render "shared/notes/notes"
|
||||
- unless has_vue_discussions_cookie?
|
||||
%ul#notes-list.notes.main-notes-list.timeline
|
||||
= render "shared/notes/notes"
|
||||
|
||||
= render 'shared/notes/edit_form', project: @project
|
||||
|
||||
- if can_create_note?
|
||||
%ul.notes.notes-form.timeline
|
||||
%ul.notes.notes-form.timeline{ :class => ('hidden' if has_vue_discussions_cookie?) }
|
||||
%li.timeline-entry
|
||||
.timeline-entry-inner
|
||||
.flash-container.timeline-content
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ constraints(ProjectUrlConstrainer.new) do
|
|||
post :toggle_subscription
|
||||
post :remove_wip
|
||||
post :assign_related_issues
|
||||
get :discussions, format: :json
|
||||
post :rebase
|
||||
|
||||
scope constraints: { format: nil }, action: :show do
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ function generateEntries() {
|
|||
help: './help/help.js',
|
||||
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
|
||||
monitoring: './monitoring/monitoring_bundle.js',
|
||||
mr_notes: './mr_notes/index.js',
|
||||
notebook_viewer: './blob/notebook_viewer.js',
|
||||
pdf_viewer: './blob/pdf_viewer.js',
|
||||
pipelines_details: './pipelines/pipeline_details_bundle.js',
|
||||
|
|
@ -243,6 +244,7 @@ var config = {
|
|||
'groups',
|
||||
'merge_conflicts',
|
||||
'monitoring',
|
||||
'mr_notes',
|
||||
'notebook_viewer',
|
||||
'pdf_viewer',
|
||||
'pipelines',
|
||||
|
|
|
|||
|
|
@ -71,6 +71,19 @@ describe Projects::DiscussionsController do
|
|||
|
||||
expect(response).to have_gitlab_http_status(200)
|
||||
end
|
||||
|
||||
context "when vue_mr_discussions cookie is present" do
|
||||
before do
|
||||
allow(controller).to receive(:cookies).and_return(vue_mr_discussions: 'true')
|
||||
end
|
||||
|
||||
it "renders discussion with serializer" do
|
||||
expect_any_instance_of(DiscussionSerializer).to receive(:represent)
|
||||
.with(instance_of(Discussion), { context: instance_of(described_class) })
|
||||
|
||||
post :resolve, request_params
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -119,6 +132,19 @@ describe Projects::DiscussionsController do
|
|||
|
||||
expect(response).to have_gitlab_http_status(200)
|
||||
end
|
||||
|
||||
context "when vue_mr_discussions cookie is present" do
|
||||
before do
|
||||
allow(controller).to receive(:cookies).and_return({ vue_mr_discussions: 'true' })
|
||||
end
|
||||
|
||||
it "renders discussion with serializer" do
|
||||
expect_any_instance_of(DiscussionSerializer).to receive(:represent)
|
||||
.with(instance_of(Discussion), { context: instance_of(described_class) })
|
||||
|
||||
delete :unresolve, request_params
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -974,7 +974,7 @@ describe Projects::IssuesController do
|
|||
it 'returns discussion json' do
|
||||
get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
|
||||
|
||||
expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes individual_note])
|
||||
expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes diff_discussion individual_note resolvable resolve_with_issue_path resolved])
|
||||
end
|
||||
|
||||
context 'with cross-reference system note', :request_store do
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ describe 'Merge request > User posts notes', :js do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'deleting an attachment' do
|
||||
describe 'deleting attachment on legacy diff note' do
|
||||
before do
|
||||
find('.note').hover
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,9 @@
|
|||
"properties": {
|
||||
"can_remove_source_branch": { "type": "boolean" },
|
||||
"can_revert_on_current_merge_request": { "type": ["boolean", "null"] },
|
||||
"can_cherry_pick_on_current_merge_request": { "type": ["boolean", "null"] }
|
||||
"can_cherry_pick_on_current_merge_request": { "type": ["boolean", "null"] },
|
||||
"can_create_note": { "type": "boolean" },
|
||||
"can_update": { "type": "boolean" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
|
@ -103,6 +105,7 @@
|
|||
"merge_ongoing": { "type": "boolean" },
|
||||
"ff_only_enabled": { "type": ["boolean", false] },
|
||||
"should_be_rebased": { "type": "boolean" },
|
||||
"create_note_path": { "type": ["string", "null"] },
|
||||
"rebase_commit_sha": { "type": ["string", "null"] },
|
||||
"rebase_in_progress": { "type": "boolean" },
|
||||
"can_push_to_source_branch": { "type": "boolean" },
|
||||
|
|
|
|||
|
|
@ -3,28 +3,24 @@ import AccessorUtilities from '~/lib/utils/accessor';
|
|||
|
||||
describe('Autosave', () => {
|
||||
let autosave;
|
||||
const field = $('<textarea></textarea>');
|
||||
const key = 'key';
|
||||
|
||||
describe('class constructor', () => {
|
||||
const key = 'key';
|
||||
const field = jasmine.createSpyObj('field', ['data', 'on']);
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true);
|
||||
spyOn(Autosave.prototype, 'restore');
|
||||
|
||||
autosave = new Autosave(field, key);
|
||||
});
|
||||
|
||||
it('should set .isLocalStorageAvailable', () => {
|
||||
autosave = new Autosave(field, key);
|
||||
|
||||
expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
|
||||
expect(autosave.isLocalStorageAvailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore', () => {
|
||||
const key = 'key';
|
||||
const field = jasmine.createSpyObj('field', ['trigger']);
|
||||
|
||||
beforeEach(() => {
|
||||
autosave = {
|
||||
field,
|
||||
|
|
@ -49,24 +45,53 @@ describe('Autosave', () => {
|
|||
describe('if .isLocalStorageAvailable is `true`', () => {
|
||||
beforeEach(() => {
|
||||
autosave.isLocalStorageAvailable = true;
|
||||
|
||||
Autosave.prototype.restore.call(autosave);
|
||||
});
|
||||
|
||||
it('should call .getItem', () => {
|
||||
Autosave.prototype.restore.call(autosave);
|
||||
|
||||
expect(window.localStorage.getItem).toHaveBeenCalledWith(key);
|
||||
});
|
||||
|
||||
it('triggers jquery event', () => {
|
||||
spyOn(autosave.field, 'trigger').and.callThrough();
|
||||
|
||||
Autosave.prototype.restore.call(autosave);
|
||||
|
||||
expect(
|
||||
field.trigger,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers native event', (done) => {
|
||||
autosave.field.get(0).addEventListener('change', () => {
|
||||
done();
|
||||
});
|
||||
|
||||
Autosave.prototype.restore.call(autosave);
|
||||
});
|
||||
});
|
||||
|
||||
describe('if field gets deleted from DOM', () => {
|
||||
beforeEach(() => {
|
||||
autosave.field = $('.not-a-real-element');
|
||||
});
|
||||
|
||||
it('does not trigger event', () => {
|
||||
spyOn(field, 'trigger').and.callThrough();
|
||||
|
||||
expect(
|
||||
field.trigger,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
const field = jasmine.createSpyObj('field', ['val']);
|
||||
|
||||
beforeEach(() => {
|
||||
autosave = jasmine.createSpyObj('autosave', ['reset']);
|
||||
autosave.field = field;
|
||||
|
||||
field.val.and.returnValue('value');
|
||||
field.val('value');
|
||||
|
||||
spyOn(window.localStorage, 'setItem');
|
||||
});
|
||||
|
|
@ -97,8 +122,6 @@ describe('Autosave', () => {
|
|||
});
|
||||
|
||||
describe('reset', () => {
|
||||
const key = 'key';
|
||||
|
||||
beforeEach(() => {
|
||||
autosave = {
|
||||
key,
|
||||
|
|
|
|||
|
|
@ -70,8 +70,50 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
|
|||
render_merge_request(example.description, merge_request)
|
||||
end
|
||||
|
||||
it 'merge_requests/discussions.json' do |example|
|
||||
create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request)
|
||||
render_discussions_json(merge_request, example.description)
|
||||
end
|
||||
|
||||
it 'merge_requests/diff_discussion.json' do |example|
|
||||
create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request)
|
||||
render_discussions_json(merge_request, example.description)
|
||||
end
|
||||
|
||||
context 'with image diff' do
|
||||
let(:merge_request2) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project, title: "Added images") }
|
||||
let(:image_path) { "files/images/ee_repo_logo.png" }
|
||||
let(:image_position) do
|
||||
Gitlab::Diff::Position.new(
|
||||
old_path: image_path,
|
||||
new_path: image_path,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 1,
|
||||
y: 1,
|
||||
position_type: "image",
|
||||
diff_refs: merge_request2.diff_refs
|
||||
)
|
||||
end
|
||||
|
||||
it 'merge_requests/image_diff_discussion.json' do |example|
|
||||
create(:diff_note_on_merge_request, project: project, noteable: merge_request2, position: image_position)
|
||||
render_discussions_json(merge_request2, example.description)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_discussions_json(merge_request, fixture_file_name)
|
||||
get :discussions,
|
||||
namespace_id: project.namespace.to_param,
|
||||
project_id: project,
|
||||
id: merge_request.to_param,
|
||||
format: :json
|
||||
|
||||
store_frontend_fixture(response, fixture_file_name)
|
||||
end
|
||||
|
||||
def render_merge_request(fixture_file_name, merge_request)
|
||||
get :show,
|
||||
namespace_id: project.namespace.to_param,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import Vue from 'vue';
|
||||
import Autosize from 'autosize';
|
||||
import store from '~/notes/stores';
|
||||
import issueCommentForm from '~/notes/components/comment_form.vue';
|
||||
import CommentForm from '~/notes/components/comment_form.vue';
|
||||
import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data';
|
||||
import { keyboardDownEvent } from '../../issue_show/helpers';
|
||||
|
||||
describe('issue_comment_form component', () => {
|
||||
let vm;
|
||||
const Component = Vue.extend(issueCommentForm);
|
||||
const Component = Vue.extend(CommentForm);
|
||||
let mountComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
mountComponent = () => new Component({
|
||||
mountComponent = (noteableType = 'issue') => new Component({
|
||||
propsData: {
|
||||
noteableType,
|
||||
},
|
||||
store,
|
||||
}).$mount();
|
||||
});
|
||||
|
|
@ -136,6 +139,11 @@ describe('issue_comment_form component', () => {
|
|||
|
||||
expect(vm.editCurrentUserLastNote).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inits autosave', () => {
|
||||
expect(vm.autosave).toBeDefined();
|
||||
expect(vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('event enter', () => {
|
||||
|
|
@ -182,6 +190,15 @@ describe('issue_comment_form component', () => {
|
|||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates button text with noteable type', (done) => {
|
||||
vm.noteableType = 'merge_request';
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Close merge request');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue is confidential', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import Vue from 'vue';
|
||||
import DiffFileHeader from '~/notes/components/diff_file_header.vue';
|
||||
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
|
||||
const discussionFixture = 'merge_requests/diff_discussion.json';
|
||||
|
||||
describe('diff_file_header', () => {
|
||||
let vm;
|
||||
const diffDiscussionMock = getJSONFixture(discussionFixture)[0];
|
||||
const diffFile = convertObjectPropsToCamelCase(diffDiscussionMock.diff_file);
|
||||
const props = {
|
||||
diffFile,
|
||||
};
|
||||
const Component = Vue.extend(DiffFileHeader);
|
||||
const selectors = {
|
||||
get copyButton() {
|
||||
return vm.$el.querySelector('button[data-original-title="Copy file path to clipboard"]');
|
||||
},
|
||||
get fileName() {
|
||||
return vm.$el.querySelector('.file-title-name');
|
||||
},
|
||||
get titleWrapper() {
|
||||
return vm.$refs.titleWrapper;
|
||||
},
|
||||
};
|
||||
|
||||
describe('submodule', () => {
|
||||
beforeEach(() => {
|
||||
props.diffFile.submodule = true;
|
||||
props.diffFile.submoduleLink = '<a href="/bha">Submodule</a>';
|
||||
|
||||
vm = mountComponent(Component, props);
|
||||
});
|
||||
|
||||
it('shows submoduleLink', () => {
|
||||
expect(selectors.fileName.innerHTML).toBe(props.diffFile.submoduleLink);
|
||||
});
|
||||
|
||||
it('has button to copy blob path', () => {
|
||||
expect(selectors.copyButton).toExist();
|
||||
expect(selectors.copyButton.getAttribute('data-clipboard-text')).toBe(props.diffFile.submoduleLink);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changed file', () => {
|
||||
beforeEach(() => {
|
||||
props.diffFile.submodule = false;
|
||||
props.diffFile.discussionPath = 'some/discussion/id';
|
||||
|
||||
vm = mountComponent(Component, props);
|
||||
});
|
||||
|
||||
it('shows file type icon', () => {
|
||||
expect(vm.$el.innerHTML).toContain('fa-file-text-o');
|
||||
});
|
||||
|
||||
it('links to discussion path', () => {
|
||||
expect(selectors.titleWrapper).toExist();
|
||||
expect(selectors.titleWrapper.tagName).toBe('A');
|
||||
expect(selectors.titleWrapper.getAttribute('href')).toBe(props.diffFile.discussionPath);
|
||||
});
|
||||
|
||||
it('shows plain title if no link given', () => {
|
||||
props.diffFile.discussionPath = undefined;
|
||||
vm = mountComponent(Component, props);
|
||||
|
||||
expect(selectors.titleWrapper.tagName).not.toBe('A');
|
||||
expect(selectors.titleWrapper.href).toBeFalsy();
|
||||
});
|
||||
|
||||
it('has button to copy file path', () => {
|
||||
expect(selectors.copyButton).toExist();
|
||||
expect(selectors.copyButton.getAttribute('data-clipboard-text')).toBe(props.diffFile.filePath);
|
||||
});
|
||||
|
||||
it('shows file mode change', (done) => {
|
||||
vm.diffFile = {
|
||||
...props.diffFile,
|
||||
modeChanged: true,
|
||||
aMode: '100755',
|
||||
bMode: '100644',
|
||||
};
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(
|
||||
vm.$refs.fileMode.textContent.trim(),
|
||||
).toBe('100755 → 100644');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import Vue from 'vue';
|
||||
import DiffWithNote from '~/notes/components/diff_with_note.vue';
|
||||
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
|
||||
const discussionFixture = 'merge_requests/diff_discussion.json';
|
||||
const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json';
|
||||
|
||||
describe('diff_with_note', () => {
|
||||
let vm;
|
||||
const diffDiscussionMock = getJSONFixture(discussionFixture)[0];
|
||||
const diffDiscussion = convertObjectPropsToCamelCase(diffDiscussionMock);
|
||||
const Component = Vue.extend(DiffWithNote);
|
||||
const props = {
|
||||
discussion: diffDiscussion,
|
||||
};
|
||||
const selectors = {
|
||||
get container() {
|
||||
return vm.$refs.fileHolder;
|
||||
},
|
||||
get diffTable() {
|
||||
return this.container.querySelector('.diff-content table');
|
||||
},
|
||||
get diffRows() {
|
||||
return this.container.querySelectorAll('.diff-content .line_holder');
|
||||
},
|
||||
get noteRow() {
|
||||
return this.container.querySelector('.diff-content .notes_holder');
|
||||
},
|
||||
};
|
||||
|
||||
describe('text diff', () => {
|
||||
beforeEach(() => {
|
||||
vm = mountComponent(Component, props);
|
||||
});
|
||||
|
||||
it('shows text diff', () => {
|
||||
expect(selectors.container).toHaveClass('text-file');
|
||||
expect(selectors.diffTable).toExist();
|
||||
});
|
||||
|
||||
it('shows diff lines', () => {
|
||||
expect(selectors.diffRows.length).toBe(12);
|
||||
});
|
||||
|
||||
it('shows notes row', () => {
|
||||
expect(selectors.noteRow).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe('image diff', () => {
|
||||
beforeEach(() => {
|
||||
const imageDiffDiscussionMock = getJSONFixture(imageDiscussionFixture)[0];
|
||||
props.discussion = convertObjectPropsToCamelCase(imageDiffDiscussionMock);
|
||||
});
|
||||
|
||||
it('shows image diff', () => {
|
||||
vm = mountComponent(Component, props);
|
||||
|
||||
expect(selectors.container).toHaveClass('js-image-file');
|
||||
expect(selectors.diffTable).not.toExist();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -24,6 +24,7 @@ describe('note_app', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
jasmine.addMatchers(vueMatchers);
|
||||
$('body').attr('data-page', 'projects:merge_requests:show');
|
||||
|
||||
const IssueNotesApp = Vue.extend(notesApp);
|
||||
|
||||
|
|
@ -119,8 +120,8 @@ describe('note_app', () => {
|
|||
vm = mountComponent();
|
||||
});
|
||||
|
||||
it('should render loading icon', () => {
|
||||
expect(vm).toIncludeElement('.js-loading');
|
||||
it('renders skeleton notes', () => {
|
||||
expect(vm).toIncludeElement('.animation-container');
|
||||
});
|
||||
|
||||
it('should render form', () => {
|
||||
|
|
|
|||
|
|
@ -30,17 +30,26 @@ describe('issue_note_body component', () => {
|
|||
expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
|
||||
});
|
||||
|
||||
it('should be render form if user is editing', (done) => {
|
||||
vm.isEditing = true;
|
||||
it('should render awards list', () => {
|
||||
expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).not.toBeNull();
|
||||
expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.querySelector('textarea.js-task-list-field')).toBeDefined();
|
||||
done();
|
||||
describe('isEditing', () => {
|
||||
beforeEach((done) => {
|
||||
vm.isEditing = true;
|
||||
Vue.nextTick(done);
|
||||
});
|
||||
|
||||
it('renders edit form', () => {
|
||||
expect(vm.$el.querySelector('textarea.js-task-list-field')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('adds autosave', () => {
|
||||
const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`;
|
||||
|
||||
expect(vm.autosave).toExist();
|
||||
expect(vm.autosave.key).toEqual(autosaveKey);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render awards list', () => {
|
||||
expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).toBeDefined();
|
||||
expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ describe('note_header component', () => {
|
|||
createdAt: '2017-08-02T10:51:58.559Z',
|
||||
includeToggle: false,
|
||||
noteId: 1394,
|
||||
expanded: true,
|
||||
},
|
||||
}).$mount();
|
||||
});
|
||||
|
|
@ -68,6 +69,7 @@ describe('note_header component', () => {
|
|||
createdAt: '2017-08-02T10:51:58.559Z',
|
||||
includeToggle: true,
|
||||
noteId: 1395,
|
||||
expanded: true,
|
||||
},
|
||||
}).$mount();
|
||||
});
|
||||
|
|
@ -76,17 +78,35 @@ describe('note_header component', () => {
|
|||
expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should toggle the disucssion icon', (done) => {
|
||||
expect(
|
||||
vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-up'),
|
||||
).toEqual(true);
|
||||
it('emits toggle event on click', (done) => {
|
||||
spyOn(vm, '$emit');
|
||||
|
||||
vm.$el.querySelector('.js-vue-toggle-button').click();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$emit).toHaveBeenCalledWith('toggleHandler');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders up arrow when open', (done) => {
|
||||
vm.expanded = true;
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(
|
||||
vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-down'),
|
||||
).toEqual(true);
|
||||
vm.$el.querySelector('.js-vue-toggle-button i').classList,
|
||||
).toContain('fa-chevron-up');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders down arrow when closed', (done) => {
|
||||
vm.expanded = false;
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(
|
||||
vm.$el.querySelector('.js-vue-toggle-button i').classList,
|
||||
).toContain('fa-chevron-down');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ export const notesDataMock = {
|
|||
notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes',
|
||||
quickActionsDocsPath: '/help/user/project/quick_actions',
|
||||
registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane',
|
||||
closeIssuePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close',
|
||||
reopenIssuePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen',
|
||||
totalNotes: 1,
|
||||
closePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close',
|
||||
reopenPath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen',
|
||||
};
|
||||
|
||||
export const userDataMock = {
|
||||
|
|
|
|||
|
|
@ -56,9 +56,9 @@ describe('Getters Notes Store', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('issueState', () => {
|
||||
describe('openState', () => {
|
||||
it('should return the issue state', () => {
|
||||
expect(getters.issueState(state)).toEqual(noteableDataMock.state);
|
||||
expect(getters.openState(state)).toEqual(noteableDataMock.state);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import mutations from '~/notes/stores/mutations';
|
||||
import { note, discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data';
|
||||
|
||||
describe('Mutation Notes Store', () => {
|
||||
describe('Notes Store mutations', () => {
|
||||
describe('ADD_NEW_NOTE', () => {
|
||||
let state;
|
||||
let noteData;
|
||||
|
|
@ -103,7 +103,8 @@ describe('Mutation Notes Store', () => {
|
|||
};
|
||||
|
||||
mutations.SET_INITIAL_NOTES(state, [note]);
|
||||
expect(state.notes).toEqual([note]);
|
||||
expect(state.notes[0].id).toEqual(note.id);
|
||||
expect(state.notes.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
|
|||
|
||||
describe('Notes', function() {
|
||||
const FLASH_TYPE_ALERT = 'alert';
|
||||
const NOTES_POST_PATH = /(.*)\/notes\?html=true$/;
|
||||
var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw';
|
||||
preloadFixtures(commentsTemplate);
|
||||
|
||||
|
|
@ -154,7 +155,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
|
|||
$form.find('textarea.js-note-text').val(sampleComment);
|
||||
|
||||
mock = new MockAdapter(axios);
|
||||
mock.onPost(/(.*)\/notes$/).reply(200, noteEntity);
|
||||
mock.onPost(NOTES_POST_PATH).reply(200, noteEntity);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -506,11 +507,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
|
|||
let mock;
|
||||
|
||||
function mockNotesPost() {
|
||||
mock.onPost(/(.*)\/notes$/).reply(200, note);
|
||||
mock.onPost(NOTES_POST_PATH).reply(200, note);
|
||||
}
|
||||
|
||||
function mockNotesPostError() {
|
||||
mock.onPost(/(.*)\/notes$/).networkError();
|
||||
mock.onPost(NOTES_POST_PATH).networkError();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -631,7 +632,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
|
|||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
mock.onPost(/(.*)\/notes$/).reply(200, note);
|
||||
mock.onPost(NOTES_POST_PATH).reply(200, note);
|
||||
|
||||
this.notes = new Notes('', []);
|
||||
window.gon.current_username = 'root';
|
||||
|
|
@ -684,7 +685,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
|
|||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
mock.onPost(/(.*)\/notes$/).reply(200, note);
|
||||
mock.onPost(NOTES_POST_PATH).reply(200, note);
|
||||
|
||||
this.notes = new Notes('', []);
|
||||
window.gon.current_username = 'root';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe DiffFileEntity do
|
||||
include RepoHelpers
|
||||
|
||||
let(:project) { create(:project, :repository) }
|
||||
let(:repository) { project.repository }
|
||||
let(:commit) { project.commit(sample_commit.id) }
|
||||
let(:diff_refs) { commit.diff_refs }
|
||||
let(:diff) { commit.raw_diffs.first }
|
||||
let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) }
|
||||
let(:entity) { described_class.new(diff_file) }
|
||||
|
||||
subject { entity.as_json }
|
||||
|
||||
it 'exposes correct attributes' do
|
||||
expect(subject).to include(
|
||||
:submodule, :submodule_link, :file_path,
|
||||
:deleted_file, :old_path, :new_path, :mode_changed,
|
||||
:a_mode, :b_mode, :text, :old_path_html,
|
||||
:new_path_html
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe DiscussionEntity do
|
||||
include RepoHelpers
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:note) { create(:discussion_note_on_merge_request) }
|
||||
let(:discussion) { note.discussion }
|
||||
let(:request) { double('request') }
|
||||
let(:controller) { double('controller') }
|
||||
let(:entity) { described_class.new(discussion, request: request, context: controller) }
|
||||
|
||||
subject { entity.as_json }
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:render_to_string)
|
||||
allow(request).to receive(:current_user).and_return(user)
|
||||
allow(request).to receive(:noteable).and_return(note.noteable)
|
||||
end
|
||||
|
||||
it 'exposes correct attributes' do
|
||||
expect(subject).to include(
|
||||
:id, :expanded, :notes, :individual_note,
|
||||
:resolvable, :resolved, :resolve_path,
|
||||
:resolve_with_issue_path, :diff_discussion
|
||||
)
|
||||
end
|
||||
|
||||
context 'when diff file is present' do
|
||||
let(:note) { create(:diff_note_on_merge_request) }
|
||||
|
||||
it 'exposes diff file attributes' do
|
||||
expect(subject).to include(:diff_file, :truncated_diff_lines, :image_diff_html)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -48,4 +48,15 @@ describe NoteEntity do
|
|||
expect(subject).to include(:system_note_icon_name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when note is part of resolvable discussion' do
|
||||
before do
|
||||
allow(note).to receive(:part_of_discussion?).and_return(true)
|
||||
allow(note).to receive(:resolvable?).and_return(true)
|
||||
end
|
||||
|
||||
it 'exposes paths to resolve note' do
|
||||
expect(subject).to include(:resolve_path, :resolve_with_issue_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue