summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/notes/components
diff options
context:
space:
mode:
authorFatih Acet <acetfatih@gmail.com>2018-02-28 00:10:43 +0000
committerJacob Schatz <jschatz@gitlab.com>2018-02-28 00:10:43 +0000
commit059ab73b8eae3a546d0a19fe99ef0c52df5fac01 (patch)
treeb24d4162072c0099147fcdb4f19f95511689ff6e /app/assets/javascripts/notes/components
parent0be4a77d0012613f960c4177f53101c46de2899c (diff)
downloadgitlab-ce-059ab73b8eae3a546d0a19fe99ef0c52df5fac01.tar.gz
Render MR Notes with Vue with behind a cookie
Diffstat (limited to 'app/assets/javascripts/notes/components')
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue77
-rw-r--r--app/assets/javascripts/notes/components/diff_file_header.vue92
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue96
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue119
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue61
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue43
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue13
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue198
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue17
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue43
11 files changed, 666 insertions, 97 deletions
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index df796050e0d..b85c1a6ad72 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -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>
diff --git a/app/assets/javascripts/notes/components/diff_file_header.vue b/app/assets/javascripts/notes/components/diff_file_header.vue
new file mode 100644
index 00000000000..fe5baa3537f
--- /dev/null
+++ b/app/assets/javascripts/notes/components/diff_file_header.vue
@@ -0,0 +1,92 @@
+<script>
+ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+ import Icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ ClipboardButton,
+ Icon,
+ },
+ props: {
+ diffFile: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ titleTag() {
+ return this.diffFile.discussionPath ? 'a' : 'span';
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="file-header-content">
+ <div
+ v-if="diffFile.submodule"
+ >
+ <span>
+ <icon name="archive" />
+ <strong
+ v-html="diffFile.submoduleLink"
+ class="file-title-name"
+ ></strong>
+ <clipboard-button
+ title="Copy file path to clipboard"
+ :text="diffFile.submoduleLink"
+ />
+ </span>
+ </div>
+ <template v-else>
+ <component
+ ref="titleWrapper"
+ :is="titleTag"
+ :href="diffFile.discussionPath"
+ >
+ <span v-html="diffFile.blobIcon"></span>
+ <span v-if="diffFile.renamedFile">
+ <strong
+ class="file-title-name has-tooltip"
+ :title="diffFile.oldPath"
+ data-container="body"
+ >
+ {{ diffFile.oldPath }}
+ </strong>
+ &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>
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
new file mode 100644
index 00000000000..75a32709ad5
--- /dev/null
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -0,0 +1,96 @@
+<script>
+ import syntaxHighlight from '~/syntax_highlight';
+ import imageDiffHelper from '~/image_diff/helpers/index';
+ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+ import DiffFileHeader from './diff_file_header.vue';
+
+ export default {
+ components: {
+ DiffFileHeader,
+ },
+ props: {
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ isImageDiff() {
+ return !this.diffFile.text;
+ },
+ diffFileClass() {
+ const { text } = this.diffFile;
+ return text ? 'text-file' : 'js-image-file';
+ },
+ diffRows() {
+ return $(this.discussion.truncatedDiffLines);
+ },
+ diffFile() {
+ return convertObjectPropsToCamelCase(this.discussion.diffFile);
+ },
+ imageDiffHtml() {
+ return this.discussion.imageDiffHtml;
+ },
+ },
+ mounted() {
+ if (this.isImageDiff) {
+ const canCreateNote = false;
+ const renderCommentBadge = true;
+ imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge);
+ } else {
+ const fileHolder = $(this.$refs.fileHolder);
+ this.$nextTick(() => {
+ syntaxHighlight(fileHolder);
+ });
+ }
+ },
+ methods: {
+ rowTag(html) {
+ return html.outerHTML ? 'tr' : 'template';
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ ref="fileHolder"
+ class="diff-file file-holder"
+ :class="diffFileClass"
+ >
+ <div class="js-file-title file-title file-title-flex-parent">
+ <diff-file-header
+ :diff-file="diffFile"
+ />
+ </div>
+ <div
+ v-if="diffFile.text"
+ class="diff-content code js-syntax-highlight"
+ >
+ <table>
+ <component
+ :is="rowTag(html)"
+ :class="html.className"
+ v-for="(html, index) in diffRows"
+ v-html="html.outerHTML"
+ :key="index"
+ />
+ <tr class="notes_holder">
+ <td
+ class="notes_line"
+ colspan="2"
+ ></td>
+ <td class="notes_content">
+ <slot></slot>
+ </td>
+ </tr>
+ </table>
+ </div>
+ <div
+ v-else
+ >
+ <div v-html="imageDiffHtml"></div>
+ <slot></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
new file mode 100644
index 00000000000..0158f58b569
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -0,0 +1,119 @@
+<script>
+ import { mapGetters } from 'vuex';
+ import resolveSvg from 'icons/_icon_resolve_discussion.svg';
+ import resolvedSvg from 'icons/_icon_status_success_solid.svg';
+ import mrIssueSvg from 'icons/_icon_mr_issue.svg';
+ import nextDiscussionSvg from 'icons/_next_discussion.svg';
+ import { pluralize } from '../../lib/utils/text_utility';
+ import { scrollToElement } from '../../lib/utils/common_utils';
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ computed: {
+ ...mapGetters([
+ 'getUserData',
+ 'getNoteableData',
+ 'discussionCount',
+ 'unresolvedDiscussions',
+ 'resolvedDiscussionCount',
+ ]),
+ isLoggedIn() {
+ return this.getUserData.id;
+ },
+ hasNextButton() {
+ return this.isLoggedIn && !this.allResolved;
+ },
+ countText() {
+ return pluralize('discussion', this.discussionCount);
+ },
+ allResolved() {
+ return this.resolvedDiscussionCount === this.discussionCount;
+ },
+ resolveAllDiscussionsIssuePath() {
+ return this.getNoteableData.create_issue_to_resolve_discussions_path;
+ },
+ firstUnresolvedDiscussionId() {
+ const item = this.unresolvedDiscussions[0] || {};
+
+ return item.id;
+ },
+ },
+ created() {
+ this.resolveSvg = resolveSvg;
+ this.resolvedSvg = resolvedSvg;
+ this.mrIssueSvg = mrIssueSvg;
+ this.nextDiscussionSvg = nextDiscussionSvg;
+ },
+ methods: {
+ jumpToFirstDiscussion() {
+ const el = document.querySelector(`[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`);
+ const activeTab = window.mrTabs.currentAction;
+
+ if (activeTab === 'commits' || activeTab === 'pipelines') {
+ window.mrTabs.activateTab('show');
+ }
+
+ if (el) {
+ scrollToElement(el);
+ }
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="line-resolve-all-container prepend-top-10">
+ <div>
+ <div
+ v-if="discussionCount > 0"
+ :class="{ 'has-next-btn': hasNextButton }"
+ class="line-resolve-all">
+ <span
+ :class="{ 'is-active': allResolved }"
+ class="line-resolve-btn is-disabled"
+ type="button">
+ <span
+ v-if="allResolved"
+ v-html="resolvedSvg"
+ ></span>
+ <span
+ v-else
+ v-html="resolveSvg"
+ ></span>
+ </span>
+ <span class=".line-resolve-text">
+ {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved
+ </span>
+ </div>
+ <div
+ v-if="resolveAllDiscussionsIssuePath && !allResolved"
+ class="btn-group"
+ role="group">
+ <a
+ :href="resolveAllDiscussionsIssuePath"
+ v-tooltip
+ title="Resolve all discussions in new issue"
+ data-container="body"
+ class="new-issue-for-discussion btn btn-default discussion-create-issue-btn">
+ <span v-html="mrIssueSvg"></span>
+ </a>
+ </div>
+ <div
+ v-if="isLoggedIn && !allResolved"
+ class="btn-group"
+ role="group">
+ <button
+ @click="jumpToFirstDiscussion"
+ v-tooltip
+ title="Jump to first unresolved discussion"
+ data-container="body"
+ class="btn btn-default discussion-next-btn">
+ <span v-html="nextDiscussionSvg"></span>
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 46ffb60aa60..c26aa6fa15d 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -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>
@@ -90,6 +126,31 @@
{{ 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">
<a
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 2d7cd30115d..ca12df9db64 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -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();
}
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index d382a9bb642..1a13fdbeb7c 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,9 +1,10 @@
<script>
- import { mapGetters } from 'vuex';
+ import { mapGetters, mapActions } from 'vuex';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state';
+ import resolvable from '../mixins/resolvable';
export default {
name: 'IssueNoteForm',
@@ -13,6 +14,7 @@
},
mixins: [
issuableStateMixin,
+ resolvable,
],
props: {
noteBody: {
@@ -30,7 +32,7 @@
required: false,
default: 'Save comment',
},
- discussion: {
+ note: {
type: Object,
required: false,
default: () => ({}),
@@ -42,9 +44,11 @@
},
data() {
return {
- note: this.noteBody,
+ updatedNoteBody: this.noteBody,
conflictWhileEditing: false,
isSubmitting: false,
+ isResolving: false,
+ resolveAsThread: true,
};
},
computed: {
@@ -71,13 +75,13 @@
return this.getUserDataByProp('id');
},
isDisabled() {
- return !this.note.length || this.isSubmitting;
+ return !this.updatedNoteBody.length || this.isSubmitting;
},
},
watch: {
noteBody() {
- if (this.note === this.noteBody) {
- this.note = this.noteBody;
+ if (this.updatedNoteBody === this.noteBody) {
+ this.updatedNoteBody = this.noteBody;
} else {
this.conflictWhileEditing = true;
}
@@ -87,16 +91,24 @@
this.$refs.textarea.focus();
},
methods: {
- handleUpdate() {
+ ...mapActions([
+ 'toggleResolveNote',
+ ]),
+ handleUpdate(shouldResolve) {
+ const beforeSubmitDiscussionState = this.discussionResolved;
this.isSubmitting = true;
- this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => {
+ this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
this.isSubmitting = false;
+
+ if (shouldResolve) {
+ this.resolveHandler(beforeSubmitDiscussionState);
+ }
});
},
editMyLastNote() {
- if (this.note === '') {
- const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
+ if (this.updatedNoteBody === '') {
+ const lastNoteInDiscussion = this.getDiscussionLastNote(this.updatedNoteBody);
if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', {
@@ -107,7 +119,7 @@
},
cancelHandler(shouldConfirm = false) {
// Sends information about confirm message and if the textarea has changed
- this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note);
+ this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.updatedNoteBody);
},
},
};
@@ -150,7 +162,7 @@
js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
:data-supports-quick-actions="!isEditing"
aria-label="Description"
- v-model="note"
+ v-model="updatedNoteBody"
ref="textarea"
slot="textarea"
placeholder="Write a comment or drag your files here..."
@@ -169,6 +181,13 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
{{ 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"
type="button">
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 5b255d4a710..4743d95b951 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -34,15 +34,15 @@
required: false,
default: false,
},
- },
- data() {
- return {
- isExpanded: true,
- };
+ expanded: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
toggleChevronClass() {
- return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down';
+ return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down';
},
noteTimestampLink() {
return `#note_${this.noteId}`;
@@ -53,7 +53,6 @@
'setTargetNoteHash',
]),
handleToggle() {
- this.isExpanded = !this.isExpanded;
this.$emit('toggleHandler');
},
updateTargetNoteHash() {
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 98a06c5fc71..76bb53eaf2f 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -1,5 +1,7 @@
<script>
import { mapActions, mapGetters } from 'vuex';
+ import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg';
+ import nextDiscussionsSvg from 'icons/_next_discussion.svg';
import Flash from '../../flash';
import { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -8,13 +10,19 @@
import noteSignedOutWidget from './note_signed_out_widget.vue';
import noteEditedText from './note_edited_text.vue';
import noteForm from './note_form.vue';
+ import diffWithNote from './diff_with_note.vue';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import autosave from '../mixins/autosave';
+ import noteable from '../mixins/noteable';
+ import resolvable from '../mixins/resolvable';
+ import tooltip from '../../vue_shared/directives/tooltip';
+ import { scrollToElement } from '../../lib/utils/common_utils';
export default {
components: {
noteableNote,
+ diffWithNote,
userAvatarLink,
noteHeader,
noteSignedOutWidget,
@@ -23,8 +31,13 @@
placeholderNote,
placeholderSystemNote,
},
+ directives: {
+ tooltip,
+ },
mixins: [
autosave,
+ noteable,
+ resolvable,
],
props: {
note: {
@@ -35,14 +48,25 @@
data() {
return {
isReplying: false,
+ isResolving: false,
+ resolveAsThread: true,
};
},
computed: {
...mapGetters([
'getNoteableData',
+ 'discussionCount',
+ 'resolvedDiscussionCount',
+ 'unresolvedDiscussions',
]),
discussion() {
- return this.note.notes[0];
+ return {
+ ...this.note.notes[0],
+ truncatedDiffLines: this.note.truncated_diff_lines,
+ diffFile: this.note.diff_file,
+ diffDiscussion: this.note.diff_discussion,
+ imageDiffHtml: this.note.image_diff_html,
+ };
},
author() {
return this.discussion.author;
@@ -71,26 +95,40 @@
return null;
},
+ hasUnresolvedDiscussion() {
+ return this.unresolvedDiscussions.length > 0;
+ },
+ wrapperComponent() {
+ return (this.discussion.diffDiscussion && this.discussion.diffFile) ? diffWithNote : 'div';
+ },
+ wrapperClass() {
+ return this.isDiffDiscussion ? '' : 'panel panel-default';
+ },
},
mounted() {
if (this.isReplying) {
- this.initAutoSave();
+ this.initAutoSave(this.discussion.noteable_type);
}
},
updated() {
if (this.isReplying) {
if (!this.autosave) {
- this.initAutoSave();
+ this.initAutoSave(this.discussion.noteable_type);
} else {
this.setAutoSave();
}
}
},
+ created() {
+ this.resolveDiscussionsSvg = resolveDiscussionsSvg;
+ this.nextDiscussionsSvg = nextDiscussionsSvg;
+ },
methods: {
...mapActions([
'saveNote',
'toggleDiscussion',
'removePlaceholderNotes',
+ 'toggleResolveNote',
]),
componentName(note) {
if (note.isPlaceholderNote) {
@@ -103,7 +141,7 @@
return noteableNote;
},
componentData(note) {
- return note.isPlaceholderNote ? note.notes[0] : note;
+ return note.isPlaceholderNote ? this.note.notes[0] : note;
},
toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.note.id });
@@ -128,7 +166,7 @@
flashContainer: this.$el,
data: {
in_reply_to_discussion_id: this.note.reply_id,
- target_type: 'issue',
+ target_type: this.noteableType,
target_id: this.discussion.noteable_id,
note: { note: noteText },
},
@@ -152,12 +190,27 @@ Please check your network connection and try again.`;
});
});
},
+ jumpToDiscussion() {
+ const unresolvedIds = this.unresolvedDiscussions.map(d => d.id);
+ const index = unresolvedIds.indexOf(this.note.id);
+
+ if (index >= 0 && index !== unresolvedIds.length) {
+ const nextId = unresolvedIds[index + 1];
+ const el = document.querySelector(`[data-discussion-id="${nextId}"]`);
+
+ if (el) {
+ scrollToElement(el);
+ }
+ }
+ },
},
};
</script>
<template>
- <li class="note note-discussion timeline-entry">
+ <li
+ :data-discussion-id="note.id"
+ class="note note-discussion timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
@@ -175,6 +228,7 @@ Please check your network connection and try again.`;
:created-at="discussion.created_at"
:note-id="discussion.id"
:include-toggle="true"
+ :expanded="note.expanded"
@toggleHandler="toggleDiscussionHandler"
action-text="started a discussion"
class="discussion"
@@ -187,43 +241,103 @@ Please check your network connection and try again.`;
class-name="discussion-headline-light js-discussion-headline"
/>
</div>
- </div>
- <div
- v-if="note.expanded"
- class="discussion-body">
- <div class="panel panel-default">
- <div class="discussion-notes">
- <ul class="notes">
- <component
- v-for="note in note.notes"
- :is="componentName(note)"
- :note="componentData(note)"
- :key="note.id"
- />
- </ul>
- <div
- :class="{ 'is-replying': isReplying }"
- class="discussion-reply-holder">
- <button
- v-if="canReply && !isReplying"
- @click="showReplyForm"
- type="button"
- class="js-vue-discussion-reply btn btn-text-field"
- title="Add a reply">
- Reply...
- </button>
- <note-form
- v-if="isReplying"
- save-button-title="Comment"
- :discussion="note"
- :is-editing="false"
- @handleFormUpdate="saveReply"
- @cancelFormEdition="cancelReplyForm"
- ref="noteForm"
- />
- <note-signed-out-widget v-if="!canReply" />
+ <div
+ v-if="note.expanded"
+ class="discussion-body">
+ <component
+ :is="wrapperComponent"
+ :discussion="discussion"
+ :class="wrapperClass"
+ >
+ <div class="discussion-notes">
+ <ul class="notes">
+ <component
+ v-for="note in note.notes"
+ :is="componentName(note)"
+ :note="componentData(note)"
+ :key="note.id"
+ />
+ </ul>
+ <div
+ :class="{ 'is-replying': isReplying }"
+ class="discussion-reply-holder">
+ <template v-if="!isReplying && canReply">
+ <div
+ class="btn-group-justified discussion-with-resolve-btn"
+ role="group">
+ <div
+ class="btn-group"
+ role="group">
+ <button
+ @click="showReplyForm"
+ type="button"
+ class="js-vue-discussion-reply btn btn-text-field"
+ title="Add a reply">Reply...</button>
+ </div>
+ <div
+ v-if="note.resolvable"
+ class="btn-group"
+ role="group">
+ <button
+ @click="resolveHandler()"
+ type="button"
+ class="btn btn-default"
+ >
+ <i
+ v-if="isResolving"
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin"
+ ></i>
+ {{ resolveButtonTitle }}
+ </button>
+ </div>
+ <div
+ class="btn-group discussion-actions"
+ role="group">
+ <div
+ v-if="note.resolvable && !discussionResolved"
+ class="btn-group"
+ role="group">
+ <a
+ :href="note.resolve_with_issue_path"
+ v-tooltip
+ class="new-issue-for-discussion btn
+ btn-default discussion-create-issue-btn"
+ title="Resolve this discussion in a new issue"
+ data-container="body"
+ >
+ <span v-html="resolveDiscussionsSvg"></span>
+ </a>
+ </div>
+ <div
+ v-if="hasUnresolvedDiscussion"
+ class="btn-group"
+ role="group">
+ <button
+ @click="jumpToDiscussion"
+ v-tooltip
+ class="btn btn-default discussion-next-btn"
+ title="Jump to next unresolved discussion"
+ data-container="body"
+ >
+ <span v-html="nextDiscussionsSvg"></span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </template>
+ <note-form
+ v-if="isReplying"
+ save-button-title="Comment"
+ :note="note"
+ :is-editing="false"
+ @handleFormUpdate="saveReply"
+ @cancelFormEdition="cancelReplyForm"
+ ref="noteForm" />
+ <note-signed-out-widget v-if="!canReply" />
+ </div>
</div>
- </div>
+ </component>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 045077de383..4d17bd5acc2 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -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
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 92db4830704..74afed5560b 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -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>