Commit 1f233e33 authored by Jacob Schatz's avatar Jacob Schatz

Merge branch 'acet-mr-notes-index' into 'master'

Render MR Notes with Vue with behind a cookie

See merge request gitlab-org/gitlab-ce!15732
parents 0be4a77d 059ab73b
...@@ -3,10 +3,10 @@ ...@@ -3,10 +3,10 @@
import AccessorUtilities from './lib/utils/accessor'; import AccessorUtilities from './lib/utils/accessor';
export default class Autosave { export default class Autosave {
constructor(field, key, resource) { constructor(field, key) {
this.field = field; this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.resource = resource;
if (key.join != null) { if (key.join != null) {
key = key.join('/'); key = key.join('/');
} }
...@@ -17,31 +17,27 @@ export default class Autosave { ...@@ -17,31 +17,27 @@ export default class Autosave {
} }
restore() { restore() {
var text;
if (!this.isLocalStorageAvailable) return; 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) { if ((text != null ? text.length : void 0) > 0) {
this.field.val(text); this.field.val(text);
} }
if (!this.resource && this.resource !== 'issue') {
this.field.trigger('input'); this.field.trigger('input');
} else {
// v-model does not update with jQuery trigger // v-model does not update with jQuery trigger
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137 // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false }); const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0); const field = this.field.get(0);
if (field) {
field.dispatchEvent(event); field.dispatchEvent(event);
} }
}
}
save() { save() {
var text; if (!this.field.length) return;
text = this.field.val();
const text = this.field.val();
if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) { if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) {
return window.localStorage.setItem(this.key, text); return window.localStorage.setItem(this.key, text);
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import _ from 'underscore'; import _ from 'underscore';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { __ } from './locale'; 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 flash from './flash';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
...@@ -239,9 +239,9 @@ class AwardsHandler { ...@@ -239,9 +239,9 @@ class AwardsHandler {
} }
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { 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_', ''); const id = votesBlock.attr('id').replace('note_', '');
this.hideMenuElement($('.emoji-menu')); this.hideMenuElement($('.emoji-menu'));
...@@ -293,8 +293,16 @@ class AwardsHandler { ...@@ -293,8 +293,16 @@ class AwardsHandler {
} }
} }
isVueMRDiscussions() {
return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
}
isInVueNoteablePage() {
return isInIssuePage() || this.isVueMRDiscussions();
}
getVotesBlock() { getVotesBlock() {
if (isInIssuePage()) { if (this.isInVueNoteablePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
if ($el.length) { if ($el.length) {
......
...@@ -197,7 +197,7 @@ const JumpToDiscussion = Vue.extend({ ...@@ -197,7 +197,7 @@ const JumpToDiscussion = Vue.extend({
} }
$.scrollTo($target, { $.scrollTo($target, {
offset: 0 offset: -150
}); });
} }
}, },
......
...@@ -87,6 +87,7 @@ const ResolveBtn = Vue.extend({ ...@@ -87,6 +87,7 @@ const ResolveBtn = Vue.extend({
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
this.discussion.updateHeadline(data); this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus(); gl.mrWidget.checkStatus();
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
this.updateTooltip(); this.updateTooltip();
}) })
......
...@@ -14,6 +14,7 @@ import './components/resolve_count'; ...@@ -14,6 +14,7 @@ import './components/resolve_count';
import './components/resolve_discussion_btn'; import './components/resolve_discussion_btn';
import './components/diff_note_avatars'; import './components/diff_note_avatars';
import './components/new_issue_for_discussion'; import './components/new_issue_for_discussion';
import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils';
export default () => { export default () => {
const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box'); const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box');
...@@ -67,12 +68,14 @@ export default () => { ...@@ -67,12 +68,14 @@ export default () => {
gl.diffNotesCompileComponents(); gl.diffNotesCompileComponents();
if (!hasVueMRDiscussionsCookie()) {
new Vue({ new Vue({
el: '#resolve-count-app', el: '#resolve-count-app',
components: { components: {
'resolve-count': ResolveCount 'resolve-count': ResolveCount
}, },
}); });
}
$(window).trigger('resize.nav'); $(window).trigger('resize.nav');
}; };
...@@ -8,8 +8,8 @@ window.gl = window.gl || {}; ...@@ -8,8 +8,8 @@ window.gl = window.gl || {};
class ResolveServiceClass { class ResolveServiceClass {
constructor(root) { constructor(root) {
this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`); this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`);
this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`); this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`);
} }
resolve(noteId) { resolve(noteId) {
...@@ -45,6 +45,7 @@ class ResolveServiceClass { ...@@ -45,6 +45,7 @@ class ResolveServiceClass {
if (gl.mrWidget) gl.mrWidget.checkStatus(); if (gl.mrWidget) gl.mrWidget.checkStatus();
discussion.updateHeadline(data); discussion.updateHeadline(data);
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
}) })
.catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.')); .catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.'));
} }
......
import jQuery from 'jquery';
import Cookies from 'js-cookie';
import axios from './axios_utils'; import axios from './axios_utils';
import { getLocationHash } from './url_utility'; import { getLocationHash } from './url_utility';
import { convertToCamelCase } from './text_utility'; import { convertToCamelCase } from './text_utility';
...@@ -22,13 +24,18 @@ export const getGroupSlug = () => { ...@@ -22,13 +24,18 @@ export const getGroupSlug = () => {
return null; return null;
}; };
export const isInIssuePage = () => { export const checkPageAndAction = (page, action) => {
const page = getPagePath(1); const pagePath = getPagePath(1);
const action = getPagePath(2); 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, { export const ajaxGet = url => axios.get(url, {
params: { format: 'js' }, params: { format: 'js' },
responseType: 'text', responseType: 'text',
...@@ -133,7 +140,11 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; ...@@ -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) // 3) Middle-click or Mouse Wheel Click (e.which is 2)
export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 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 top = $el.offset().top;
const mrTabsHeight = $('.merge-request-tabs').height() || 0; const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0; const headerHeight = $('.navbar-gitlab').height() || 0;
......
...@@ -65,6 +65,20 @@ export function capitalizeFirstCharacter(text) { ...@@ -65,6 +65,20 @@ export function capitalizeFirstCharacter(text) {
return `${text[0].toUpperCase()}${text.slice(1)}`; 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. * Replaces all html tags from a string with the given replacement.
* *
......
...@@ -241,6 +241,10 @@ export default class MergeRequestTabs { ...@@ -241,6 +241,10 @@ export default class MergeRequestTabs {
return newState; return newState;
} }
getCurrentAction() {
return this.currentAction;
}
loadCommits(source) { loadCommits(source) {
if (this.commitsLoaded) { if (this.commitsLoaded) {
return; return;
......
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');
},
});
});
This diff is collapsed.
...@@ -2,10 +2,11 @@ ...@@ -2,10 +2,11 @@
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import _ from 'underscore'; import _ from 'underscore';
import Autosize from 'autosize'; import Autosize from 'autosize';
import { __ } from '~/locale'; import { __, sprintf } from '~/locale';
import Flash from '../../flash'; import Flash from '../../flash';
import Autosave from '../../autosave'; import Autosave from '../../autosave';
import TaskList from '../../task_list'; import TaskList from '../../task_list';
import { capitalizeFirstCharacter, convertToCamelCase } from '../../lib/utils/text_utility';
import * as constants from '../constants'; import * as constants from '../constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
...@@ -29,6 +30,12 @@ ...@@ -29,6 +30,12 @@
mixins: [ mixins: [
issuableStateMixin, issuableStateMixin,
], ],
props: {
noteableType: {
type: String,
required: true,
},
},
data() { data() {
return { return {
note: '', note: '',
...@@ -43,37 +50,51 @@ ...@@ -43,37 +50,51 @@
'getUserData', 'getUserData',
'getNoteableData', 'getNoteableData',
'getNotesData', 'getNotesData',
'issueState', 'openState',
]), ]),
noteableDisplayName() {
return this.noteableType.replace(/_/g, ' ');
},
isLoggedIn() { isLoggedIn() {
return this.getUserData.id; return this.getUserData.id;
}, },
commentButtonTitle() { commentButtonTitle() {
return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
}, },
isIssueOpen() { isOpen() {
return this.issueState === constants.OPENED || this.issueState === constants.REOPENED; return this.openState === constants.OPENED || this.openState === constants.REOPENED;
}, },
canCreateNote() { canCreateNote() {
return this.getNoteableData.current_user.can_create_note; return this.getNoteableData.current_user.can_create_note;
}, },
issueActionButtonTitle() { issueActionButtonTitle() {
if (this.note.length) { const openOrClose = this.isOpen ? 'close' : 'reopen';
const actionText = this.isIssueOpen ? 'close' : 'reopen';
return this.noteType === constants.COMMENT ? if (this.note.length) {
`Comment & ${actionText} issue` : return sprintf(
`Start discussion & ${actionText} issue`; __('%{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() { actionButtonClassNames() {
return { return {
'btn-reopen': !this.isIssueOpen, 'btn-reopen': !this.isOpen,
'btn-close': this.isIssueOpen, 'btn-close': this.isOpen,
'js-note-target-close': this.isIssueOpen, 'js-note-target-close': this.isOpen,
'js-note-target-reopen': !this.isIssueOpen, 'js-note-target-reopen': !this.isOpen,
}; };
}, },
markdownDocsPath() { markdownDocsPath() {
...@@ -138,7 +159,7 @@ ...@@ -138,7 +159,7 @@
flashContainer: this.$el, flashContainer: this.$el,
data: { data: {
note: { note: {
noteable_type: constants.NOTEABLE_TYPE, noteable_type: this.noteableType,
noteable_id: this.getNoteableData.id, noteable_id: this.getNoteableData.id,
note: this.note, note: this.note,
}, },
...@@ -193,19 +214,29 @@ Please check your network connection and try again.`; ...@@ -193,19 +214,29 @@ Please check your network connection and try again.`;
this.isSubmitting = false; this.isSubmitting = false;
}, },
toggleIssueState() { toggleIssueState() {
if (this.isIssueOpen) { if (this.isOpen) {
this.closeIssue() this.closeIssue()
.then(() => this.enableButton()) .then(() => this.enableButton())
.catch(() => { .catch(() => {
this.enableButton(); 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 { } else {
this.reopenIssue() this.reopenIssue()
.then(() => this.enableButton()) .then(() => this.enableButton())
.catch(() => { .catch(() => {
this.enableButton(); 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.`; ...@@ -221,7 +252,6 @@ Please check your network connection and try again.`;
this.$refs.markdownField.previewMarkdown = false; this.$refs.markdownField.previewMarkdown = false;
} }
// reset autostave
this.autosave.reset(); this.autosave.reset();
}, },
setNoteType(type) { setNoteType(type) {
...@@ -240,10 +270,11 @@ Please check your network connection and try again.`; ...@@ -240,10 +270,11 @@ Please check your network connection and try again.`;
}, },
initAutoSave() { initAutoSave() {
if (this.isLoggedIn) { if (this.isLoggedIn) {
const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
this.autosave = new Autosave( this.autosave = new Autosave(
$(this.$refs.textarea), $(this.$refs.textarea),
['Note', 'Issue', this.getNoteableData.id], ['Note', noteableType, this.getNoteableData.id],
'issue',
); );
} }
}, },
...@@ -331,7 +362,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" ...@@ -331,7 +362,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
:disabled="isSubmitButtonDisabled" :disabled="isSubmitButtonDisabled"
class="btn btn-create comment-btn js-comment-button js-comment-submit-button" class="btn btn-create comment-btn js-comment-button js-comment-submit-button"
type="submit"> type="submit">
{{ commentButtonTitle }} {{ __(commentButtonTitle) }}
</button> </button>
<button <button
:disabled="isSubmitButtonDisabled" :disabled="isSubmitButtonDisabled"
...@@ -359,7 +390,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" ...@@ -359,7 +390,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
<div class="description"> <div class="description">
<strong>Comment</strong> <strong>Comment</strong>
<p> <p>
Add a general comment to this issue. Add a general comment to this {{ noteableDisplayName }}.
</p> </p>
</div> </div>
</button> </button>
......
<script>
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
ClipboardButton,
Icon,
},
props: {
diffFile: {
type: Object,
required: true,
},
},
computed: {
titleTag() {
return this.diffFile.discussionPath ? 'a' : 'span';
},
},
};
</script>
<template>
<div class="file-header-content">
<div
v-if="diffFile.submodule"
>
<span>
<icon name="archive" />
<strong
v-html="diffFile.submoduleLink"
class="file-title-name"
></strong>
<clipboard-button
title="Copy file path to clipboard"
:text="diffFile.submoduleLink"
/>
</span>
</div>
<template v-else>
<component
ref="titleWrapper"
:is="titleTag"
:href="diffFile.discussionPath"
>
<span v-html="diffFile.blobIcon"></span>
<span v-if="diffFile.renamedFile">
<strong
class="file-title-name has-tooltip"
:title="diffFile.oldPath"
data-container="body"
>
{{ diffFile.oldPath }}
</strong>
&rarr;
<strong
class="file-title-name has-tooltip"
:title="diffFile.newPath"
data-container="body"
>
{{ diffFile.newPath }}
</strong>
</span>
<strong
v-else
class="file-title-name has-tooltip"
:title="diffFile.oldPath"
data-container="body"
>
{{ diffFile.filePath }}
<span v-if="diffFile.deletedFile">
deleted
</span>
</strong>
</component>
<clipboard-button
title="Copy file path to clipboard"
:text="diffFile.filePath"
/>
<small
v-if="diffFile.modeChanged"
ref="fileMode"
>
{{ diffFile.aMode }}{{ diffFile.bMode }}
</small>
</template>
</div>
</template>
<script>
import syntaxHighlight from '~/syntax_highlight';
import imageDiffHelper from '~/image_diff/helpers/index';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import DiffFileHeader from './diff_file_header.vue';
export default {
components: {
DiffFileHeader,
},
props: {
discussion: {
type: Object,
required: true,
},
},
computed: {
isImageDiff() {
return !this.diffFile.text;
},
diffFileClass() {
const { text } = this.diffFile;
return text ? 'text-file' : 'js-image-file';
},
diffRows() {
return $(this.discussion.truncatedDiffLines);
},
diffFile() {
return convertObjectPropsToCamelCase(this.discussion.diffFile);
},
imageDiffHtml() {
return this.discussion.imageDiffHtml;
},
},
mounted() {
if (this.isImageDiff) {
const canCreateNote = false;
const renderCommentBadge = true;
imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge);
} else {
const fileHolder = $(this.$refs.fileHolder);
this.$nextTick(() => {
syntaxHighlight(fileHolder);
});
}
},
methods: {
rowTag(html) {
return html.outerHTML ? 'tr' : 'template';
},
},
};
</script>
<template>
<div
ref="fileHolder"
class="diff-file file-holder"
:class="diffFileClass"
>
<div class="js-file-title file-title file-title-flex-parent">
<diff-file-header
:diff-file="diffFile"
/>
</div>
<div
v-if="diffFile.text"
class="diff-content code js-syntax-highlight"
>
<table>
<component
:is="rowTag(html)"
:class="html.className"
v-for="(html, index) in diffRows"
v-html="html.outerHTML"
:key="index"
/>
<tr class="notes_holder">
<td
class="notes_line"
colspan="2"
></td>
<td class="notes_content">
<slot></slot>
</td>
</tr>
</table>
</div>
<div
v-else
>
<div v-html="imageDiffHtml"></div>
<slot></slot>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import resolveSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedSvg from 'icons/_icon_status_success_solid.svg';
import mrIssueSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionSvg from 'icons/_next_discussion.svg';
import { pluralize } from '../../lib/utils/text_utility';
import { scrollToElement } from '../../lib/utils/common_utils';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
computed: {
...mapGetters([
'getUserData',
'getNoteableData',
'discussionCount',
'unresolvedDiscussions',
'resolvedDiscussionCount',
]),
isLoggedIn() {
return this.getUserData.id;
},
hasNextButton() {
return this.isLoggedIn && !this.allResolved;
},
countText() {
return pluralize('discussion', this.discussionCount);
},
allResolved() {
return this.resolvedDiscussionCount === this.discussionCount;
},
resolveAllDiscussionsIssuePath() {
return this.getNoteableData.create_issue_to_resolve_discussions_path;
},
firstUnresolvedDiscussionId() {
const item = this.unresolvedDiscussions[0] || {};
return item.id;
},
},
created() {
this.resolveSvg = resolveSvg;
this.resolvedSvg = resolvedSvg;
this.mrIssueSvg = mrIssueSvg;
this.nextDiscussionSvg = nextDiscussionSvg;
},
methods: {
jumpToFirstDiscussion() {
const el = document.querySelector(`[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`);
const activeTab = window.mrTabs.currentAction;
if (activeTab === 'commits' || activeTab === 'pipelines') {
window.mrTabs.activateTab('show');
}
if (el) {
scrollToElement(el);
}
},
},
};
</script>
<template>
<div class="line-resolve-all-container prepend-top-10">
<div>
<div
v-if="discussionCount > 0"
:class="{ 'has-next-btn': hasNextButton }"
class="line-resolve-all">
<span
:class="{ 'is-active': allResolved }"
class="line-resolve-btn is-disabled"
type="button">
<span
v-if="allResolved"
v-html="resolvedSvg"
></span>
<span
v-else
v-html="resolveSvg"
></span>
</span>
<span class=".line-resolve-text">
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved
</span>
</div>
<div
v-if="resolveAllDiscussionsIssuePath && !allResolved"
class="btn-group"
role="group">
<a
:href="resolveAllDiscussionsIssuePath"
v-tooltip
title="Resolve all discussions in new issue"
data-container="body"
class="new-issue-for-discussion btn btn-default discussion-create-issue-btn">
<span v-html="mrIssueSvg"></span>
</a>
</div>
<div
v-if="isLoggedIn && !allResolved"
class="btn-group"
role="group">
<button
@click="jumpToFirstDiscussion"
v-tooltip
title="Jump to first unresolved discussion"
data-container="body"
class="btn btn-default discussion-next-btn">
<span v-html="nextDiscussionSvg"></span>
</button>
</div>
</div>
</div>
</template>
...@@ -4,6 +4,8 @@ ...@@ -4,6 +4,8 @@
import emojiSmile from 'icons/_emoji_smile.svg'; import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg'; import emojiSmiley from 'icons/_emoji_smiley.svg';
import editSvg from 'icons/_icon_pencil.svg'; import editSvg from 'icons/_icon_pencil.svg';
import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg';
import ellipsisSvg from 'icons/_ellipsis_v.svg'; import ellipsisSvg from 'icons/_ellipsis_v.svg';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
...@@ -42,6 +44,26 @@ ...@@ -42,6 +44,26 @@
type: Boolean, type: Boolean,
required: true, 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: { canReportAsAbuse: {
type: Boolean, type: Boolean,
required: true, required: true,
...@@ -63,6 +85,15 @@ ...@@ -63,6 +85,15 @@
currentUserId() { currentUserId() {
return this.getUserDataByProp('id'); return this.getUserDataByProp('id');
}, },
resolveButtonTitle() {
let title = 'Mark as resolved';
if (this.resolvedBy) {
title = `Resolved by ${this.resolvedBy.name}`;
}
return title;
},
}, },
created() { created() {
this.emojiSmiling = emojiSmiling; this.emojiSmiling = emojiSmiling;
...@@ -70,6 +101,8 @@ ...@@ -70,6 +101,8 @@
this.emojiSmiley = emojiSmiley; this.emojiSmiley = emojiSmiley;
this.editSvg = editSvg; this.editSvg = editSvg;
this.ellipsisSvg = ellipsisSvg; this.ellipsisSvg = ellipsisSvg;
this.resolveDiscussionSvg = resolveDiscussionSvg;
this.resolvedDiscussionSvg = resolvedDiscussionSvg;
}, },
methods: { methods: {
onEdit() { onEdit() {
...@@ -78,6 +111,9 @@ ...@@ -78,6 +111,9 @@
onDelete() { onDelete() {
this.$emit('handleDelete'); this.$emit('handleDelete');
}, },
onResolve() {
this.$emit('handleResolve');
},
}, },
}; };
</script> </script>
...@@ -89,6 +125,31 @@ ...@@ -89,6 +125,31 @@
class="note-role user-access-role"> class="note-role user-access-role">
{{ accessLevel }} {{ accessLevel }}
</span> </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 <div
v-if="canAddAwardEmoji" v-if="canAddAwardEmoji"
class="note-actions-item"> class="note-actions-item">
......
...@@ -41,7 +41,7 @@ ...@@ -41,7 +41,7 @@
this.initTaskList(); this.initTaskList();
if (this.isEditing) { if (this.isEditing) {
this.initAutoSave(); this.initAutoSave(this.note.noteable_type);
} }
}, },
updated() { updated() {
...@@ -50,7 +50,7 @@ ...@@ -50,7 +50,7 @@
if (this.isEditing) { if (this.isEditing) {
if (!this.autosave) { if (!this.autosave) {
this.initAutoSave(); this.initAutoSave(this.note.noteable_type);
} else { } else {
this.setAutoSave(); this.setAutoSave();
} }
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state'; import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
export default { export default {
name: 'IssueNoteForm', name: 'IssueNoteForm',
...@@ -13,6 +14,7 @@ ...@@ -13,6 +14,7 @@
}, },
mixins: [ mixins: [
issuableStateMixin, issuableStateMixin,
resolvable,
], ],
props: { props: {
noteBody: { noteBody: {
...@@ -30,7 +32,7 @@ ...@@ -30,7 +32,7 @@
required: false, required: false,
default: 'Save comment', default: 'Save comment',
}, },
discussion: { note: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), default: () => ({}),
...@@ -42,9 +44,11 @@ ...@@ -42,9 +44,11 @@
}, },
data() { data() {
return { return {
note: this.noteBody, updatedNoteBody: this.noteBody,
conflictWhileEditing: false, conflictWhileEditing: false,
isSubmitting: false, isSubmitting: false,
isResolving: false,
resolveAsThread: true,
}; };
}, },
computed: { computed: {
...@@ -71,13 +75,13 @@ ...@@ -71,13 +75,13 @@
return this.getUserDataByProp('id'); return this.getUserDataByProp('id');
}, },
isDisabled() { isDisabled() {
return !this.note.length || this.isSubmitting; return !this.updatedNoteBody.length || this.isSubmitting;
}, },
}, },
watch: { watch: {
noteBody() { noteBody() {
if (this.note === this.noteBody) { if (this.updatedNoteBody === this.noteBody) {
this.note = this.noteBody; this.updatedNoteBody = this.noteBody;
} else { } else {
this.conflictWhileEditing = true; this.conflictWhileEditing = true;
} }
...@@ -87,16 +91,24 @@ ...@@ -87,16 +91,24 @@
this.$refs.textarea.focus(); this.$refs.textarea.focus();
}, },
methods: { methods: {
handleUpdate() { ...mapActions([
'toggleResolveNote',
]),
handleUpdate(shouldResolve) {
const beforeSubmitDiscussionState = this.discussionResolved;
this.isSubmitting = true; this.isSubmitting = true;
this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => { this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
this.isSubmitting = false; this.isSubmitting = false;
if (shouldResolve) {
this.resolveHandler(beforeSubmitDiscussionState);
}
}); });
}, },
editMyLastNote() { editMyLastNote() {
if (this.note === '') { if (this.updatedNoteBody === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion); const lastNoteInDiscussion = this.getDiscussionLastNote(this.updatedNoteBody);
if (lastNoteInDiscussion) { if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', { eventHub.$emit('enterEditMode', {
...@@ -107,7 +119,7 @@ ...@@ -107,7 +119,7 @@
}, },
cancelHandler(shouldConfirm = false) { cancelHandler(shouldConfirm = false) {
// Sends information about confirm message and if the textarea has changed // 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 @@ ...@@ -150,7 +162,7 @@
js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
:data-supports-quick-actions="!isEditing" :data-supports-quick-actions="!isEditing"
aria-label="Description" aria-label="Description"
v-model="note" v-model="updatedNoteBody"
ref="textarea" ref="textarea"
slot="textarea" slot="textarea"
placeholder="Write a comment or drag your files here..." 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" ...@@ -168,6 +180,13 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
class="js-vue-issue-save btn btn-save"> class="js-vue-issue-save btn btn-save">
{{ saveButtonTitle }} {{ saveButtonTitle }}
</button> </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 <button
@click="cancelHandler()" @click="cancelHandler()"
class="btn btn-cancel note-edit-cancel" class="btn btn-cancel note-edit-cancel"
......
...@@ -34,15 +34,15 @@ ...@@ -34,15 +34,15 @@
required: false, required: false,
default: false, default: false,
}, },
expanded: {
type: Boolean,
required: false,
default: true,
}, },
data() {
return {
isExpanded: true,
};
}, },
computed: { computed: {
toggleChevronClass() { toggleChevronClass() {
return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down'; return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down';
}, },
noteTimestampLink() { noteTimestampLink() {
return `#note_${this.noteId}`; return `#note_${this.noteId}`;
...@@ -53,7 +53,6 @@ ...@@ -53,7 +53,6 @@
'setTargetNoteHash', 'setTargetNoteHash',
]), ]),
handleToggle() { handleToggle() {
this.isExpanded = !this.isExpanded;
this.$emit('toggleHandler'); this.$emit('toggleHandler');
}, },
updateTargetNoteHash() { updateTargetNoteHash() {
......
<script> <script>
import { mapActions, mapGetters } from 'vuex'; 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 Flash from '../../flash';
import { SYSTEM_NOTE } from '../constants'; import { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
...@@ -8,13 +10,19 @@ ...@@ -8,13 +10,19 @@
import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue';
import noteEditedText from './note_edited_text.vue'; import noteEditedText from './note_edited_text.vue';
import noteForm from './note_form.vue'; import noteForm from './note_form.vue';
import diffWithNote from './diff_with_note.vue';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import autosave from '../mixins/autosave'; import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
import tooltip from '../../vue_shared/directives/tooltip';
import { scrollToElement } from '../../lib/utils/common_utils';
export default { export default {
components: { components: {
noteableNote, noteableNote,
diffWithNote,
userAvatarLink, userAvatarLink,
noteHeader, noteHeader,
noteSignedOutWidget, noteSignedOutWidget,
...@@ -23,8 +31,13 @@ ...@@ -23,8 +31,13 @@
placeholderNote, placeholderNote,
placeholderSystemNote, placeholderSystemNote,
}, },
directives: {
tooltip,
},
mixins: [ mixins: [
autosave, autosave,
noteable,
resolvable,
], ],
props: { props: {
note: { note: {
...@@ -35,14 +48,25 @@ ...@@ -35,14 +48,25 @@
data() { data() {
return { return {
isReplying: false, isReplying: false,
isResolving: false,
resolveAsThread: true,
}; };
}, },
computed: { computed: {
...mapGetters([ ...mapGetters([
'getNoteableData', 'getNoteableData',
'discussionCount',
'resolvedDiscussionCount',
'unresolvedDiscussions',
]), ]),
discussion() { 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() { author() {
return this.discussion.author; return this.discussion.author;
...@@ -71,26 +95,40 @@ ...@@ -71,26 +95,40 @@
return null; 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() { mounted() {
if (this.isReplying) { if (this.isReplying) {
this.initAutoSave(); this.initAutoSave(this.discussion.noteable_type);
} }
}, },
updated() { updated() {
if (this.isReplying) { if (this.isReplying) {
if (!this.autosave) { if (!this.autosave) {
this.initAutoSave(); this.initAutoSave(this.discussion.noteable_type);
} else { } else {
this.setAutoSave(); this.setAutoSave();
} }
} }
}, },
created() {
this.resolveDiscussionsSvg = resolveDiscussionsSvg;
this.nextDiscussionsSvg = nextDiscussionsSvg;
},
methods: { methods: {
...mapActions([ ...mapActions([
'saveNote', 'saveNote',
'toggleDiscussion', 'toggleDiscussion',
'removePlaceholderNotes', 'removePlaceholderNotes',
'toggleResolveNote',
]), ]),
componentName(note) { componentName(note) {
if (note.isPlaceholderNote) { if (note.isPlaceholderNote) {
...@@ -103,7 +141,7 @@ ...@@ -103,7 +141,7 @@
return noteableNote; return noteableNote;
}, },
componentData(note) { componentData(note) {
return note.isPlaceholderNote ? note.notes[0] : note; return note.isPlaceholderNote ? this.note.notes[0] : note;
}, },
toggleDiscussionHandler() { toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.note.id }); this.toggleDiscussion({ discussionId: this.note.id });
...@@ -128,7 +166,7 @@ ...@@ -128,7 +166,7 @@
flashContainer: this.$el, flashContainer: this.$el,
data: { data: {
in_reply_to_discussion_id: this.note.reply_id, in_reply_to_discussion_id: this.note.reply_id,
target_type: 'issue', target_type: this.noteableType,
target_id: this.discussion.noteable_id, target_id: this.discussion.noteable_id,
note: { note: noteText }, note: { note: noteText },
}, },
...@@ -152,12 +190,27 @@ Please check your network connection and try again.`; ...@@ -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> </script>
<template> <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-entry-inner">
<div class="timeline-icon"> <div class="timeline-icon">
<user-avatar-link <user-avatar-link
...@@ -175,6 +228,7 @@ Please check your network connection and try again.`; ...@@ -175,6 +228,7 @@ Please check your network connection and try again.`;
:created-at="discussion.created_at" :created-at="discussion.created_at"
:note-id="discussion.id" :note-id="discussion.id"
:include-toggle="true" :include-toggle="true"
:expanded="note.expanded"
@toggleHandler="toggleDiscussionHandler" @toggleHandler="toggleDiscussionHandler"
action-text="started a discussion" action-text="started a discussion"
class="discussion" class="discussion"
...@@ -187,11 +241,14 @@ Please check your network connection and try again.`; ...@@ -187,11 +241,14 @@ Please check your network connection and try again.`;
class-name="discussion-headline-light js-discussion-headline" class-name="discussion-headline-light js-discussion-headline"
/> />
</div> </div>
</div>
<div <div
v-if="note.expanded" v-if="note.expanded"
class="discussion-body"> class="discussion-body">
<div class="panel panel-default"> <component
:is="wrapperComponent"
:discussion="discussion"
:class="wrapperClass"
>
<div class="discussion-notes"> <div class="discussion-notes">
<ul class="notes"> <ul class="notes">
<component <component
...@@ -204,26 +261,83 @@ Please check your network connection and try again.`; ...@@ -204,26 +261,83 @@ Please check your network connection and try again.`;
<div <div
:class="{ 'is-replying': isReplying }" :class="{ 'is-replying': isReplying }"
class="discussion-reply-holder"> 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 <button
v-if="canReply && !isReplying"
@click="showReplyForm" @click="showReplyForm"
type="button" type="button"
class="js-vue-discussion-reply btn btn-text-field" class="js-vue-discussion-reply btn btn-text-field"
title="Add a reply"> title="Add a reply">Reply...</button>
Reply... </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> </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 <note-form
v-if="isReplying" v-if="isReplying"
save-button-title="Comment" save-button-title="Comment"
:discussion="note" :note="note"
:is-editing="false" :is-editing="false"
@handleFormUpdate="saveReply" @handleFormUpdate="saveReply"
@cancelFormEdition="cancelReplyForm" @cancelFormEdition="cancelReplyForm"
ref="noteForm" ref="noteForm" />
/>
<note-signed-out-widget v-if="!canReply" /> <note-signed-out-widget v-if="!canReply" />
</div> </div>
</div> </div>
</component>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
import noteActions from './note_actions.vue'; import noteActions from './note_actions.vue';
import noteBody from './note_body.vue'; import noteBody from './note_body.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
export default { export default {
components: { components: {
...@@ -15,6 +17,10 @@ ...@@ -15,6 +17,10 @@
noteActions, noteActions,
noteBody, noteBody,
}, },
mixins: [
noteable,
resolvable,
],
props: { props: {
note: { note: {
type: Object, type: Object,
...@@ -26,6 +32,7 @@ ...@@ -26,6 +32,7 @@
isEditing: false, isEditing: false,
isDeleting: false, isDeleting: false,
isRequesting: false, isRequesting: false,
isResolving: false,
}; };
}, },
computed: { computed: {
...@@ -65,6 +72,7 @@ ...@@ -65,6 +72,7 @@
...mapActions([ ...mapActions([
'deleteNote', 'deleteNote',
'updateNote', 'updateNote',
'toggleResolveNote',
'scrollToNoteIfNeeded', 'scrollToNoteIfNeeded',
]), ]),
editHandler() { editHandler() {
...@@ -89,7 +97,7 @@ ...@@ -89,7 +97,7 @@
const data = { const data = {
endpoint: this.note.path, endpoint: this.note.path,
note: { note: {
target_type: 'issue', target_type: this.noteableType,
target_id: this.note.noteable_id, target_id: this.note.noteable_id,
note: { note: noteText }, note: { note: noteText },
}, },
...@@ -134,7 +142,7 @@ ...@@ -134,7 +142,7 @@
// we need to do this to prevent noteForm inconsistent content warning // 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 is something we intentionally do so we need to recover the content
this.note.note = noteText; this.note.note = noteText;
this.$refs.noteBody.$refs.noteForm.note = noteText; this.$refs.noteBody.$refs.noteForm.note.note = noteText;
}, },
}, },
}; };
...@@ -171,8 +179,13 @@ ...@@ -171,8 +179,13 @@
:can-delete="note.current_user.can_edit" :can-delete="note.current_user.can_edit"
:can-report-as-abuse="canReportAsAbuse" :can-report-as-abuse="canReportAsAbuse"
:report-abuse-path="note.report_abuse_path" :report-abuse-path="note.report_abuse_path"
:resolvable="note.resolvable"
:is-resolved="note.resolved"
:is-resolving="isResolving"
:resolved-by="note.resolved_by"
@handleEdit="editHandler" @handleEdit="editHandler"
@handleDelete="deleteHandler" @handleDelete="deleteHandler"
@handleResolve="resolveHandler"
/> />
</div> </div>
<note-body <note-body
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
export default { export default {
name: 'NotesApp', name: 'NotesApp',
...@@ -48,7 +49,24 @@ ...@@ -48,7 +49,24 @@
...mapGetters([ ...mapGetters([
'notes', 'notes',
'getNotesDataByProp', '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() { created() {
this.setNotesData(this.notesData); this.setNotesData(this.notesData);
...@@ -67,6 +85,10 @@ ...@@ -67,6 +85,10 @@
this.actionToggleAward({ awardName, noteId }); this.actionToggleAward({ awardName, noteId });
}); });
} }
document.addEventListener('refreshVueNotes', this.fetchNotes);
},
beforeDestroy() {
document.removeEventListener('refreshVueNotes', this.fetchNotes);
}, },
methods: { methods: {
...mapActions({ ...mapActions({
...@@ -81,6 +103,9 @@ ...@@ -81,6 +103,9 @@
setTargetNoteHash: 'setTargetNoteHash', setTargetNoteHash: 'setTargetNoteHash',
}), }),
getComponentName(note) { getComponentName(note) {
if (note.isSkeletonNote) {
return skeletonLoadingContainer;
}
if (note.isPlaceholderNote) { if (note.isPlaceholderNote) {
if (note.placeholderType === constants.SYSTEM_NOTE) { if (note.placeholderType === constants.SYSTEM_NOTE) {
return placeholderSystemNote; return placeholderSystemNote;
...@@ -109,9 +134,14 @@ ...@@ -109,9 +134,14 @@
}); });
}, },
initPolling() { initPolling() {
if (this.isPollingInitialized) {
return;
}
this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
this.poll(); this.poll();
this.isPollingInitialized = true;
}, },
checkLocationHash() { checkLocationHash() {
const hash = getLocationHash(); const hash = getLocationHash();
...@@ -128,25 +158,20 @@ ...@@ -128,25 +158,20 @@
<template> <template>
<div id="notes"> <div id="notes">
<div
v-if="isLoading"
class="js-loading loading">
<loading-icon />
</div>
<ul <ul
v-if="!isLoading"
id="notes-list" id="notes-list"
class="notes main-notes-list timeline"> class="notes main-notes-list timeline">
<component <component
v-for="note in notes" v-for="note in allNotes"
:is="getComponentName(note)" :is="getComponentName(note)"
:note="getComponentData(note)" :note="getComponentData(note)"
:key="note.id" :key="note.id"
/> />
</ul> </ul>
<comment-form /> <comment-form
:noteable-type="noteableType"
/>
</div> </div>
</template> </template>
export const DISCUSSION_NOTE = 'DiscussionNote'; export const DISCUSSION_NOTE = 'DiscussionNote';
export const DIFF_NOTE = 'DiffNote';
export const DISCUSSION = 'discussion'; export const DISCUSSION = 'discussion';
export const NOTE = 'note'; export const NOTE = 'note';
export const SYSTEM_NOTE = 'systemNote'; export const SYSTEM_NOTE = 'systemNote';
...@@ -8,4 +9,7 @@ export const REOPENED = 'reopened'; ...@@ -8,4 +9,7 @@ export const REOPENED = 'reopened';
export const CLOSED = 'closed'; export const CLOSED = 'closed';
export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown'; 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({ ...@@ -20,17 +20,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
return { return {
noteableData: JSON.parse(notesDataset.noteableData), noteableData: JSON.parse(notesDataset.noteableData),
currentUserData, currentUserData,
notesData: { notesData: JSON.parse(notesDataset.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,
},
}; };
}, },
render(createElement) { render(createElement) {
......
import Autosave from '../../autosave'; import Autosave from '../../autosave';
import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
export default { export default {
methods: { methods: {
initAutoSave() { initAutoSave(noteableType) {
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue'); this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', capitalizeFirstCharacter(noteableType), this.note.id]);
}, },
resetAutoSave() { resetAutoSave() {
this.autosave.reset(); this.autosave.reset();
......
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 '';
}
},
},
};
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);
});
},
},
};
import Vue from 'vue'; import Vue from 'vue';
import VueResource from 'vue-resource'; import VueResource from 'vue-resource';
import * as constants from '../constants';
Vue.use(VueResource); Vue.use(VueResource);
...@@ -19,6 +20,12 @@ export default { ...@@ -19,6 +20,12 @@ export default {
createNewNote(endpoint, data) { createNewNote(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true }); 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 = {}) { poll(data = {}) {
const { endpoint, lastFetchedAt } = data; const { endpoint, lastFetchedAt } = data;
const options = { const options = {
......
...@@ -61,8 +61,17 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service ...@@ -61,8 +61,17 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service
export const removePlaceholderNotes = ({ commit }) => export const removePlaceholderNotes = ({ commit }) =>
commit(types.REMOVE_PLACEHOLDER_NOTES); commit(types.REMOVE_PLACEHOLDER_NOTES);
export const toggleResolveNote = ({ commit }, { endpoint, isResolved, discussion }) => service
.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 export const closeIssue = ({ commit, dispatch, state }) => service
.toggleIssueState(state.notesData.closeIssuePath) .toggleIssueState(state.notesData.closePath)
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then((data) => {
commit(types.CLOSE_ISSUE); commit(types.CLOSE_ISSUE);
...@@ -70,7 +79,7 @@ export const closeIssue = ({ commit, dispatch, state }) => service ...@@ -70,7 +79,7 @@ export const closeIssue = ({ commit, dispatch, state }) => service
}); });
export const reopenIssue = ({ commit, dispatch, state }) => service export const reopenIssue = ({ commit, dispatch, state }) => service
.toggleIssueState(state.notesData.reopenIssuePath) .toggleIssueState(state.notesData.reopenPath)
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then((data) => {
commit(types.REOPEN_ISSUE); commit(types.REOPEN_ISSUE);
...@@ -80,7 +89,7 @@ export const reopenIssue = ({ commit, dispatch, state }) => service ...@@ -80,7 +89,7 @@ export const reopenIssue = ({ commit, dispatch, state }) => service
export const emitStateChangedEvent = ({ commit, getters }, data) => { export const emitStateChangedEvent = ({ commit, getters }, data) => {
const event = new CustomEvent('issuable_vue_app:change', { detail: { const event = new CustomEvent('issuable_vue_app:change', { detail: {
data, data,
isClosed: getters.issueState === constants.CLOSED, isClosed: getters.openState === constants.CLOSED,
} }); } });
document.dispatchEvent(event); document.dispatchEvent(event);
...@@ -174,7 +183,7 @@ const pollSuccessCallBack = (resp, commit, state, getters) => { ...@@ -174,7 +183,7 @@ const pollSuccessCallBack = (resp, commit, state, getters) => {
resp.notes.forEach((note) => { resp.notes.forEach((note) => {
if (notesById[note.id]) { if (notesById[note.id]) {
commit(types.UPDATE_NOTE, note); commit(types.UPDATE_NOTE, note);
} else if (note.type === constants.DISCUSSION_NOTE) { } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) {
const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); const discussion = utils.findNoteObjectById(state.notes, note.discussion_id);
if (discussion) { if (discussion) {
......
...@@ -8,7 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop]; ...@@ -8,7 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop];
export const getNoteableData = state => state.noteableData; export const getNoteableData = state => state.noteableData;
export const getNoteableDataByProp = state => prop => state.noteableData[prop]; 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 getUserData = state => state.userData || {};
export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; export const getUserDataByProp = state => prop => state.userData && state.userData[prop];
...@@ -30,3 +30,37 @@ export const getCurrentUserLastNote = state => _.flatten( ...@@ -30,3 +30,37 @@ export const getCurrentUserLastNote = state => _.flatten(
export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes) export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes)
.find(el => isLastNote(el, state)); .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'; ...@@ -12,6 +12,7 @@ export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
export const TOGGLE_AWARD = 'TOGGLE_AWARD'; export const TOGGLE_AWARD = 'TOGGLE_AWARD';
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
export const UPDATE_NOTE = 'UPDATE_NOTE'; export const UPDATE_NOTE = 'UPDATE_NOTE';
export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION';
// Issue // Issue
export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const CLOSE_ISSUE = 'CLOSE_ISSUE';
......
import * as utils from './utils'; import * as utils from './utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
import * as constants from '../constants'; import * as constants from '../constants';
import { isInMRPage } from '../../lib/utils/common_utils';
export default { export default {
[types.ADD_NEW_NOTE](state, note) { [types.ADD_NEW_NOTE](state, note) {
const { discussion_id, type } = note; const { discussion_id, type } = note;
const [exists] = state.notes.filter(n => n.id === note.discussion_id); const [exists] = state.notes.filter(n => n.id === note.discussion_id);
const isDiscussion = (type === constants.DISCUSSION_NOTE);
if (!exists) { if (!exists) {
const noteData = { const noteData = {
expanded: true, expanded: true,
id: discussion_id, id: discussion_id,
individual_note: !(type === constants.DISCUSSION_NOTE), individual_note: !isDiscussion,
notes: [note], notes: [note],
reply_id: discussion_id, 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); state.notes.push(noteData);
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
} }
}, },
...@@ -25,6 +35,7 @@ export default { ...@@ -25,6 +35,7 @@ export default {
if (noteObj) { if (noteObj) {
noteObj.notes.push(note); noteObj.notes.push(note);
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
} }
}, },
...@@ -41,6 +52,8 @@ export default { ...@@ -41,6 +52,8 @@ export default {
state.notes.splice(state.notes.indexOf(noteObj), 1); state.notes.splice(state.notes.indexOf(noteObj), 1);
} }
} }
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}, },
[types.REMOVE_PLACEHOLDER_NOTES](state) { [types.REMOVE_PLACEHOLDER_NOTES](state) {
...@@ -77,15 +90,19 @@ export default { ...@@ -77,15 +90,19 @@ export default {
const notes = []; const notes = [];
notesData.forEach((note) => { notesData.forEach((note) => {
const nn = Object.assign({}, note);
// To support legacy notes, should be very rare case. // To support legacy notes, should be very rare case.
if (note.individual_note && note.notes.length > 1) { if (note.individual_note && note.notes.length > 1) {
note.notes.forEach((n) => { note.notes.forEach((n) => {
const nn = Object.assign({}, note);
nn.notes = [n]; // override notes array to only have one item to mimick individual_note nn.notes = [n]; // override notes array to only have one item to mimick individual_note
notes.push(nn); notes.push(nn);
}); });
} else { } 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 { ...@@ -134,6 +151,8 @@ export default {
user: { id, name, username }, user: { id, name, username },
}); });
} }
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}, },
[types.TOGGLE_DISCUSSION](state, { discussionId }) { [types.TOGGLE_DISCUSSION](state, { discussionId }) {
...@@ -151,6 +170,24 @@ export default { ...@@ -151,6 +170,24 @@ export default {
const comment = utils.findNoteObjectById(noteObj.notes, note.id); const comment = utils.findNoteObjectById(noteObj.notes, note.id);
noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); 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) { [types.CLOSE_ISSUE](state) {
......
...@@ -28,4 +28,3 @@ export const getQuickActionText = (note) => { ...@@ -28,4 +28,3 @@ export const getQuickActionText = (note) => {
export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
...@@ -22,7 +22,6 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -22,7 +22,6 @@ document.addEventListener('DOMContentLoaded', () => {
initPipelines(); initPipelines();
const mrShowNode = document.querySelector('.merge-request'); const mrShowNode = document.querySelector('.merge-request');
window.mergeRequest = new MergeRequest({ window.mergeRequest = new MergeRequest({
action: mrShowNode.dataset.mrAction, action: mrShowNode.dataset.mrAction,
}); });
......
<script> <script>
import tooltip from '../directives/tooltip';
/** /**
* Falls back to the code used in `copy_to_clipboard.js` * Falls back to the code used in `copy_to_clipboard.js`
*/ */
import tooltip from '../directives/tooltip';
export default { export default {
name: 'ClipboardButton', name: 'ClipboardButton',
......
...@@ -11,14 +11,12 @@ ...@@ -11,14 +11,12 @@
default: false, default: false,
required: false, required: false,
}, },
isConfidential: { isConfidential: {
type: Boolean, type: Boolean,
default: false, default: false,
required: false, required: false,
}, },
}, },
computed: { computed: {
warningIcon() { warningIcon() {
if (this.isConfidential) return 'eye-slash'; if (this.isConfidential) return 'eye-slash';
...@@ -26,7 +24,6 @@ ...@@ -26,7 +24,6 @@
return ''; return '';
}, },
isLockedAndConfidential() { isLockedAndConfidential() {
return this.isConfidential && this.isLocked; return this.isConfidential && this.isLocked;
}, },
......
<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 { ...@@ -723,7 +723,7 @@ ul.notes {
.line-resolve-all { .line-resolve-all {
vertical-align: middle; vertical-align: middle;
display: inline-block; display: inline-block;
padding: 5px 10px 6px; padding: 6px 10px;
background-color: $gray-light; background-color: $gray-light;
border: 1px solid $border-color; border: 1px solid $border-color;
border-radius: $border-radius-default; border-radius: $border-radius-default;
......
...@@ -77,6 +77,20 @@ module IssuableActions ...@@ -77,6 +77,20 @@ module IssuableActions
render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" } render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" }
end 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 private
def recaptcha_check_if_spammable(should_redirect = true, &block) def recaptcha_check_if_spammable(should_redirect = true, &block)
......
...@@ -22,7 +22,7 @@ module NotesActions ...@@ -22,7 +22,7 @@ module NotesActions
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
notes_json[:notes] = notes_json[:notes] =
if noteable.discussions_rendered_on_frontend? if use_note_serializer?
note_serializer.represent(notes) note_serializer.represent(notes)
else else
notes.map { |note| note_json(note) } notes.map { |note| note_json(note) }
...@@ -95,7 +95,7 @@ module NotesActions ...@@ -95,7 +95,7 @@ module NotesActions
if note.persisted? if note.persisted?
attrs[:valid] = true attrs[:valid] = true
if noteable.discussions_rendered_on_frontend? if use_note_serializer?
attrs.merge!(note_serializer.represent(note)) attrs.merge!(note_serializer.represent(note))
else else
attrs.merge!( attrs.merge!(
...@@ -233,4 +233,14 @@ module NotesActions ...@@ -233,4 +233,14 @@ module NotesActions
the_project the_project
end end
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 end
class Projects::DiscussionsController < Projects::ApplicationController class Projects::DiscussionsController < Projects::ApplicationController
include NotesHelper
include RendersNotes
before_action :check_merge_requests_available! before_action :check_merge_requests_available!
before_action :merge_request before_action :merge_request
before_action :discussion before_action :discussion
...@@ -7,22 +10,45 @@ class Projects::DiscussionsController < Projects::ApplicationController ...@@ -7,22 +10,45 @@ class Projects::DiscussionsController < Projects::ApplicationController
def resolve def resolve
Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion) Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion)
render json: { render_discussion
resolved_by: discussion.resolved_by.try(:name),
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
}
end end
def unresolve def unresolve
discussion.unresolve! discussion.unresolve!
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: { render json: {
resolved_by: discussion.resolved_by.try(:name),
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion) discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
} }
end end
private
def merge_request def merge_request
@merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id]) @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id])
end end
......
...@@ -60,20 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -60,20 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController
respond_with(@issue) respond_with(@issue)
end 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 def create
create_params = issue_params.merge(spammable_params).merge( create_params = issue_params.merge(spammable_params).merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
......
class Projects::NotesController < Projects::ApplicationController class Projects::NotesController < Projects::ApplicationController
include NotesActions include NotesActions
include NotesHelper
include ToggleAwardEmoji include ToggleAwardEmoji
before_action :whitelist_query_limiting, only: [:create] before_action :whitelist_query_limiting, only: [:create]
...@@ -38,11 +39,15 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -38,11 +39,15 @@ class Projects::NotesController < Projects::ApplicationController
discussion = note.discussion discussion = note.discussion
if serialize_notes?
render_json_with_notes_serializer
else
render json: { render json: {
resolved_by: note.resolved_by.try(:name), resolved_by: note.resolved_by.try(:name),
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
} }
end end
end
def unresolve def unresolve
return render_404 unless note.resolvable? return render_404 unless note.resolvable?
...@@ -51,16 +56,27 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -51,16 +56,27 @@ class Projects::NotesController < Projects::ApplicationController
discussion = note.discussion discussion = note.discussion
if serialize_notes?
render_json_with_notes_serializer
else
render json: { render json: {
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
} }
end end
end
private private
def render_json_with_notes_serializer
Notes::RenderService.new(current_user).execute([note], project)
render json: note_serializer.represent(note)
end
def note def note
@note ||= @project.notes.find(params[:id]) @note ||= @project.notes.find(params[:id])
end end
alias_method :awardable, :note alias_method :awardable, :note
def finder_params def finder_params
......
...@@ -151,7 +151,38 @@ module NotesHelper ...@@ -151,7 +151,38 @@ module NotesHelper
} }
end 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) def discussion_resolved_intro(discussion)
discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved' discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved'
end end
def has_vue_discussions_cookie?
cookies[:vue_mr_discussions] == 'true'
end
def serialize_notes?
has_vue_discussions_cookie? && !params['html']
end
end end
...@@ -133,6 +133,7 @@ class Note < ActiveRecord::Base ...@@ -133,6 +133,7 @@ class Note < ActiveRecord::Base
def find_discussion(discussion_id) def find_discussion(discussion_id)
notes = where(discussion_id: discussion_id).fresh.to_a notes = where(discussion_id: discussion_id).fresh.to_a
return if notes.empty? return if notes.empty?
Discussion.build(notes) Discussion.build(notes)
......
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 ...@@ -7,4 +7,42 @@ class DiscussionEntity < Grape::Entity
expose :notes, using: NoteEntity expose :notes, using: NoteEntity
expose :individual_note?, as: :individual_note 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 end
...@@ -115,6 +115,14 @@ class MergeRequestWidgetEntity < IssuableEntity ...@@ -115,6 +115,14 @@ class MergeRequestWidgetEntity < IssuableEntity
expose :can_cherry_pick_on_current_merge_request do |merge_request| expose :can_cherry_pick_on_current_merge_request do |merge_request|
presenter(merge_request).can_cherry_pick_on_current_merge_request? presenter(merge_request).can_cherry_pick_on_current_merge_request?
end 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 end
# Paths # Paths
...@@ -189,6 +197,10 @@ class MergeRequestWidgetEntity < IssuableEntity ...@@ -189,6 +197,10 @@ class MergeRequestWidgetEntity < IssuableEntity
end end
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| expose :commit_change_content_path do |merge_request|
commit_change_content_project_merge_request_path(merge_request.project, merge_request) commit_change_content_project_merge_request_path(merge_request.project, merge_request)
end end
......
...@@ -23,6 +23,10 @@ class NoteEntity < API::Entities::Note ...@@ -23,6 +23,10 @@ class NoteEntity < API::Entities::Note
end end
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| expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note|
SystemNoteHelper.system_note_icon_name(note) SystemNoteHelper.system_note_icon_name(note)
end end
...@@ -53,6 +57,14 @@ class NoteEntity < API::Entities::Note ...@@ -53,6 +57,14 @@ class NoteEntity < API::Entities::Note
end end
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 :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note| expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
delete_attachment_project_note_path(note.project, note) delete_attachment_project_note_path(note.project, note)
......
...@@ -6,14 +6,6 @@ ...@@ -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' = 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 %section.js-vue-notes-event
#js-vue-notes{ data: { discussions_path: discussions_project_issue_path(@project, @issue, format: :json), #js-vue-notes{ data: { notes_data: notes_data(@issue),
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), noteable_data: serialize_issuable(@issue),
current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } } current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } }
...@@ -73,7 +73,7 @@ ...@@ -73,7 +73,7 @@
.content-block.emoji-block .content-block.emoji-block
.row .row
.col-sm-8.js-issue-note-awards .col-sm-8.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @issue, inline: true = render 'award_emoji/awards_block', awardable: @issue, inline: true
.col-sm-4.new-branch-col .col-sm-4.new-branch-col
= render 'new_branch' unless @issue.confidential? = render 'new_branch' unless @issue.confidential?
......
- @gfm_form = true
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
- add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project) - add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project)
- breadcrumb_title @merge_request.to_reference - breadcrumb_title @merge_request.to_reference
...@@ -7,6 +8,9 @@ ...@@ -7,6 +8,9 @@
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue') = 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) } } .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" = render "projects/merge_requests/mr_title"
...@@ -23,7 +27,7 @@ ...@@ -23,7 +27,7 @@
#js-vue-mr-widget.mr-widget #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 = render 'award_emoji/awards_block', awardable: @merge_request, inline: true
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
...@@ -51,6 +55,10 @@ ...@@ -51,6 +55,10 @@
= tab_link_for @merge_request, :diffs do = tab_link_for @merge_request, :diffs do
Changes Changes
%span.badge= @merge_request.diff_size %span.badge= @merge_request.diff_size
- 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-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true }
%resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
%div %div
...@@ -71,8 +79,13 @@ ...@@ -71,8 +79,13 @@
#notes.notes.tab-pane.voting_notes #notes.notes.tab-pane.voting_notes
.row .row
%section.col-md-12 %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" = 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 #commits.commits.tab-pane
-# This tab is always loaded via AJAX -# This tab is always loaded via AJAX
......
- issuable = @issue || @merge_request - issuable = @issue || @merge_request
- discussion_locked = issuable&.discussion_locked? - discussion_locked = issuable&.discussion_locked?
%ul#notes-list.notes.main-notes-list.timeline - unless has_vue_discussions_cookie?
%ul#notes-list.notes.main-notes-list.timeline
= render "shared/notes/notes" = render "shared/notes/notes"
= render 'shared/notes/edit_form', project: @project = render 'shared/notes/edit_form', project: @project
- if can_create_note? - if can_create_note?
%ul.notes.notes-form.timeline %ul.notes.notes-form.timeline{ :class => ('hidden' if has_vue_discussions_cookie?) }
%li.timeline-entry %li.timeline-entry
.timeline-entry-inner .timeline-entry-inner
.flash-container.timeline-content .flash-container.timeline-content
......
...@@ -103,6 +103,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -103,6 +103,7 @@ constraints(ProjectUrlConstrainer.new) do
post :toggle_subscription post :toggle_subscription
post :remove_wip post :remove_wip
post :assign_related_issues post :assign_related_issues
get :discussions, format: :json
post :rebase post :rebase
scope constraints: { format: nil }, action: :show do scope constraints: { format: nil }, action: :show do
......
...@@ -52,6 +52,7 @@ function generateEntries() { ...@@ -52,6 +52,7 @@ function generateEntries() {
help: './help/help.js', help: './help/help.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
monitoring: './monitoring/monitoring_bundle.js', monitoring: './monitoring/monitoring_bundle.js',
mr_notes: './mr_notes/index.js',
notebook_viewer: './blob/notebook_viewer.js', notebook_viewer: './blob/notebook_viewer.js',
pdf_viewer: './blob/pdf_viewer.js', pdf_viewer: './blob/pdf_viewer.js',
pipelines_details: './pipelines/pipeline_details_bundle.js', pipelines_details: './pipelines/pipeline_details_bundle.js',
...@@ -243,6 +244,7 @@ var config = { ...@@ -243,6 +244,7 @@ var config = {
'groups', 'groups',
'merge_conflicts', 'merge_conflicts',
'monitoring', 'monitoring',
'mr_notes',
'notebook_viewer', 'notebook_viewer',
'pdf_viewer', 'pdf_viewer',
'pipelines', 'pipelines',
......
...@@ -71,6 +71,19 @@ describe Projects::DiscussionsController do ...@@ -71,6 +71,19 @@ describe Projects::DiscussionsController do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
end 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 end
end end
...@@ -119,6 +132,19 @@ describe Projects::DiscussionsController do ...@@ -119,6 +132,19 @@ describe Projects::DiscussionsController do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
end 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 end
end end
......
...@@ -974,7 +974,7 @@ describe Projects::IssuesController do ...@@ -974,7 +974,7 @@ describe Projects::IssuesController do
it 'returns discussion json' do it 'returns discussion json' do
get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid 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 end
context 'with cross-reference system note', :request_store do context 'with cross-reference system note', :request_store do
......
...@@ -144,7 +144,7 @@ describe 'Merge request > User posts notes', :js do ...@@ -144,7 +144,7 @@ describe 'Merge request > User posts notes', :js do
end end
end end
describe 'deleting an attachment' do describe 'deleting attachment on legacy diff note' do
before do before do
find('.note').hover find('.note').hover
......
...@@ -75,7 +75,9 @@ ...@@ -75,7 +75,9 @@
"properties": { "properties": {
"can_remove_source_branch": { "type": "boolean" }, "can_remove_source_branch": { "type": "boolean" },
"can_revert_on_current_merge_request": { "type": ["boolean", "null"] }, "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 "additionalProperties": false
}, },
...@@ -103,6 +105,7 @@ ...@@ -103,6 +105,7 @@
"merge_ongoing": { "type": "boolean" }, "merge_ongoing": { "type": "boolean" },
"ff_only_enabled": { "type": ["boolean", false] }, "ff_only_enabled": { "type": ["boolean", false] },
"should_be_rebased": { "type": "boolean" }, "should_be_rebased": { "type": "boolean" },
"create_note_path": { "type": ["string", "null"] },
"rebase_commit_sha": { "type": ["string", "null"] }, "rebase_commit_sha": { "type": ["string", "null"] },
"rebase_in_progress": { "type": "boolean" }, "rebase_in_progress": { "type": "boolean" },
"can_push_to_source_branch": { "type": "boolean" }, "can_push_to_source_branch": { "type": "boolean" },
......
...@@ -3,28 +3,24 @@ import AccessorUtilities from '~/lib/utils/accessor'; ...@@ -3,28 +3,24 @@ import AccessorUtilities from '~/lib/utils/accessor';
describe('Autosave', () => { describe('Autosave', () => {
let autosave; let autosave;
const field = $('<textarea></textarea>');
describe('class constructor', () => {
const key = 'key'; const key = 'key';
const field = jasmine.createSpyObj('field', ['data', 'on']);
describe('class constructor', () => {
beforeEach(() => { beforeEach(() => {
spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true);
spyOn(Autosave.prototype, 'restore'); spyOn(Autosave.prototype, 'restore');
autosave = new Autosave(field, key);
}); });
it('should set .isLocalStorageAvailable', () => { it('should set .isLocalStorageAvailable', () => {
autosave = new Autosave(field, key);
expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
expect(autosave.isLocalStorageAvailable).toBe(true); expect(autosave.isLocalStorageAvailable).toBe(true);
}); });
}); });
describe('restore', () => { describe('restore', () => {
const key = 'key';
const field = jasmine.createSpyObj('field', ['trigger']);
beforeEach(() => { beforeEach(() => {
autosave = { autosave = {
field, field,
...@@ -49,24 +45,53 @@ describe('Autosave', () => { ...@@ -49,24 +45,53 @@ describe('Autosave', () => {
describe('if .isLocalStorageAvailable is `true`', () => { describe('if .isLocalStorageAvailable is `true`', () => {
beforeEach(() => { beforeEach(() => {
autosave.isLocalStorageAvailable = true; autosave.isLocalStorageAvailable = true;
Autosave.prototype.restore.call(autosave);
}); });
it('should call .getItem', () => { it('should call .getItem', () => {
Autosave.prototype.restore.call(autosave);
expect(window.localStorage.getItem).toHaveBeenCalledWith(key); 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('save', () => { describe('if field gets deleted from DOM', () => {
const field = jasmine.createSpyObj('field', ['val']); beforeEach(() => {
autosave.field = $('.not-a-real-element');
});
it('does not trigger event', () => {
spyOn(field, 'trigger').and.callThrough();
expect(
field.trigger,
).not.toHaveBeenCalled();
});
});
});
describe('save', () => {
beforeEach(() => { beforeEach(() => {
autosave = jasmine.createSpyObj('autosave', ['reset']); autosave = jasmine.createSpyObj('autosave', ['reset']);
autosave.field = field; autosave.field = field;
field.val('value');
field.val.and.returnValue('value');
spyOn(window.localStorage, 'setItem'); spyOn(window.localStorage, 'setItem');
}); });
...@@ -97,8 +122,6 @@ describe('Autosave', () => { ...@@ -97,8 +122,6 @@ describe('Autosave', () => {
}); });
describe('reset', () => { describe('reset', () => {
const key = 'key';
beforeEach(() => { beforeEach(() => {
autosave = { autosave = {
key, key,
......
...@@ -70,8 +70,50 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont ...@@ -70,8 +70,50 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
render_merge_request(example.description, merge_request) render_merge_request(example.description, merge_request)
end 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 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) def render_merge_request(fixture_file_name, merge_request)
get :show, get :show,
namespace_id: project.namespace.to_param, namespace_id: project.namespace.to_param,
......
import Vue from 'vue'; import Vue from 'vue';
import Autosize from 'autosize'; import Autosize from 'autosize';
import store from '~/notes/stores'; 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 { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data';
import { keyboardDownEvent } from '../../issue_show/helpers'; import { keyboardDownEvent } from '../../issue_show/helpers';
describe('issue_comment_form component', () => { describe('issue_comment_form component', () => {
let vm; let vm;
const Component = Vue.extend(issueCommentForm); const Component = Vue.extend(CommentForm);
let mountComponent; let mountComponent;
beforeEach(() => { beforeEach(() => {
mountComponent = () => new Component({ mountComponent = (noteableType = 'issue') => new Component({
propsData: {
noteableType,
},
store, store,
}).$mount(); }).$mount();
}); });
...@@ -136,6 +139,11 @@ describe('issue_comment_form component', () => { ...@@ -136,6 +139,11 @@ describe('issue_comment_form component', () => {
expect(vm.editCurrentUserLastNote).toHaveBeenCalled(); expect(vm.editCurrentUserLastNote).toHaveBeenCalled();
}); });
it('inits autosave', () => {
expect(vm.autosave).toBeDefined();
expect(vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`);
});
}); });
describe('event enter', () => { describe('event enter', () => {
...@@ -182,6 +190,15 @@ describe('issue_comment_form component', () => { ...@@ -182,6 +190,15 @@ describe('issue_comment_form component', () => {
done(); 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', () => { describe('issue is confidential', () => {
......
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();
});
});
});
});
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', () => { ...@@ -24,6 +24,7 @@ describe('note_app', () => {
beforeEach(() => { beforeEach(() => {
jasmine.addMatchers(vueMatchers); jasmine.addMatchers(vueMatchers);
$('body').attr('data-page', 'projects:merge_requests:show');
const IssueNotesApp = Vue.extend(notesApp); const IssueNotesApp = Vue.extend(notesApp);
...@@ -119,8 +120,8 @@ describe('note_app', () => { ...@@ -119,8 +120,8 @@ describe('note_app', () => {
vm = mountComponent(); vm = mountComponent();
}); });
it('should render loading icon', () => { it('renders skeleton notes', () => {
expect(vm).toIncludeElement('.js-loading'); expect(vm).toIncludeElement('.animation-container');
}); });
it('should render form', () => { it('should render form', () => {
......
...@@ -30,17 +30,26 @@ describe('issue_note_body component', () => { ...@@ -30,17 +30,26 @@ describe('issue_note_body component', () => {
expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html); expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
}); });
it('should be render form if user is editing', (done) => { it('should render awards list', () => {
vm.isEditing = true; 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(() => { describe('isEditing', () => {
expect(vm.$el.querySelector('textarea.js-task-list-field')).toBeDefined(); beforeEach((done) => {
done(); vm.isEditing = true;
Vue.nextTick(done);
}); });
it('renders edit form', () => {
expect(vm.$el.querySelector('textarea.js-task-list-field')).not.toBeNull();
}); });
it('should render awards list', () => { it('adds autosave', () => {
expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).toBeDefined(); const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`;
expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).toBeDefined();
expect(vm.autosave).toExist();
expect(vm.autosave.key).toEqual(autosaveKey);
});
}); });
}); });
...@@ -32,6 +32,7 @@ describe('note_header component', () => { ...@@ -32,6 +32,7 @@ describe('note_header component', () => {
createdAt: '2017-08-02T10:51:58.559Z', createdAt: '2017-08-02T10:51:58.559Z',
includeToggle: false, includeToggle: false,
noteId: 1394, noteId: 1394,
expanded: true,
}, },
}).$mount(); }).$mount();
}); });
...@@ -68,6 +69,7 @@ describe('note_header component', () => { ...@@ -68,6 +69,7 @@ describe('note_header component', () => {
createdAt: '2017-08-02T10:51:58.559Z', createdAt: '2017-08-02T10:51:58.559Z',
includeToggle: true, includeToggle: true,
noteId: 1395, noteId: 1395,
expanded: true,
}, },
}).$mount(); }).$mount();
}); });
...@@ -76,17 +78,35 @@ describe('note_header component', () => { ...@@ -76,17 +78,35 @@ describe('note_header component', () => {
expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined(); expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined();
}); });
it('should toggle the disucssion icon', (done) => { it('emits toggle event on click', (done) => {
expect( spyOn(vm, '$emit');
vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-up'),
).toEqual(true);
vm.$el.querySelector('.js-vue-toggle-button').click(); 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,
).toContain('fa-chevron-up');
done();
});
});
it('renders down arrow when closed', (done) => {
vm.expanded = false;
Vue.nextTick(() => { Vue.nextTick(() => {
expect( expect(
vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-down'), vm.$el.querySelector('.js-vue-toggle-button i').classList,
).toEqual(true); ).toContain('fa-chevron-down');
done(); done();
}); });
}); });
......
...@@ -7,8 +7,9 @@ export const notesDataMock = { ...@@ -7,8 +7,9 @@ export const notesDataMock = {
notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes', notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes',
quickActionsDocsPath: '/help/user/project/quick_actions', quickActionsDocsPath: '/help/user/project/quick_actions',
registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane', registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane',
closeIssuePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close', totalNotes: 1,
reopenIssuePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen', 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 = { export const userDataMock = {
......
...@@ -56,9 +56,9 @@ describe('Getters Notes Store', () => { ...@@ -56,9 +56,9 @@ describe('Getters Notes Store', () => {
}); });
}); });
describe('issueState', () => { describe('openState', () => {
it('should return the issue state', () => { it('should return the issue state', () => {
expect(getters.issueState(state)).toEqual(noteableDataMock.state); expect(getters.openState(state)).toEqual(noteableDataMock.state);
}); });
}); });
}); });
import mutations from '~/notes/stores/mutations'; import mutations from '~/notes/stores/mutations';
import { note, discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data'; import { note, discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data';
describe('Mutation Notes Store', () => { describe('Notes Store mutations', () => {
describe('ADD_NEW_NOTE', () => { describe('ADD_NEW_NOTE', () => {
let state; let state;
let noteData; let noteData;
...@@ -103,7 +103,8 @@ describe('Mutation Notes Store', () => { ...@@ -103,7 +103,8 @@ describe('Mutation Notes Store', () => {
}; };
mutations.SET_INITIAL_NOTES(state, [note]); 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'; ...@@ -34,6 +34,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
describe('Notes', function() { describe('Notes', function() {
const FLASH_TYPE_ALERT = 'alert'; const FLASH_TYPE_ALERT = 'alert';
const NOTES_POST_PATH = /(.*)\/notes\?html=true$/;
var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw'; var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw';
preloadFixtures(commentsTemplate); preloadFixtures(commentsTemplate);
...@@ -154,7 +155,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; ...@@ -154,7 +155,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
$form.find('textarea.js-note-text').val(sampleComment); $form.find('textarea.js-note-text').val(sampleComment);
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onPost(/(.*)\/notes$/).reply(200, noteEntity); mock.onPost(NOTES_POST_PATH).reply(200, noteEntity);
}); });
afterEach(() => { afterEach(() => {
...@@ -506,11 +507,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; ...@@ -506,11 +507,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
let mock; let mock;
function mockNotesPost() { function mockNotesPost() {
mock.onPost(/(.*)\/notes$/).reply(200, note); mock.onPost(NOTES_POST_PATH).reply(200, note);
} }
function mockNotesPostError() { function mockNotesPostError() {
mock.onPost(/(.*)\/notes$/).networkError(); mock.onPost(NOTES_POST_PATH).networkError();
} }
beforeEach(() => { beforeEach(() => {
...@@ -631,7 +632,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; ...@@ -631,7 +632,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onPost(/(.*)\/notes$/).reply(200, note); mock.onPost(NOTES_POST_PATH).reply(200, note);
this.notes = new Notes('', []); this.notes = new Notes('', []);
window.gon.current_username = 'root'; window.gon.current_username = 'root';
...@@ -684,7 +685,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; ...@@ -684,7 +685,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onPost(/(.*)\/notes$/).reply(200, note); mock.onPost(NOTES_POST_PATH).reply(200, note);
this.notes = new Notes('', []); this.notes = new Notes('', []);
window.gon.current_username = 'root'; window.gon.current_username = 'root';
......
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
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 ...@@ -48,4 +48,15 @@ describe NoteEntity do
expect(subject).to include(:system_note_icon_name) expect(subject).to include(:system_note_icon_name)
end end
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 end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment