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