Commit adf8ad9e authored by Phil Hughes's avatar Phil Hughes

Improve discussion rendering performance

Improve the renderign of new and existing discussions
by reducing the number of watchers on each object & array.
Previously every discussion change would trigger an update for every
discussion component.

Also tidied up some components to get them closer to our docs.

Closes https://gitlab.com/gitlab-org/gitlab-ce/issues/51506
parent 921d6b1a
...@@ -99,7 +99,7 @@ export default { ...@@ -99,7 +99,7 @@ export default {
methods: { methods: {
...mapActions('diffs', ['loadMoreLines', 'showCommentForm']), ...mapActions('diffs', ['loadMoreLines', 'showCommentForm']),
handleCommentButton() { handleCommentButton() {
this.showCommentForm({ lineCode: this.line.line_code }); this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
}, },
handleLoadMoreLines() { handleLoadMoreLines() {
if (this.isRequesting) { if (this.isRequesting) {
...@@ -160,7 +160,7 @@ export default { ...@@ -160,7 +160,7 @@ export default {
> >
<template v-else> <template v-else>
<button <button
v-if="shouldShowCommentButton" v-show="shouldShowCommentButton"
type="button" type="button"
class="add-diff-note js-add-diff-note-button qa-diff-comment" class="add-diff-note js-add-diff-note-button qa-diff-comment"
title="Add a comment to this line" title="Add a comment to this line"
......
...@@ -73,6 +73,7 @@ export default { ...@@ -73,6 +73,7 @@ export default {
this.cancelCommentForm({ this.cancelCommentForm({
lineCode: this.line.line_code, lineCode: this.line.line_code,
fileHash: this.diffFileHash,
}); });
this.$nextTick(() => { this.$nextTick(() => {
this.resetAutoSave(); this.resetAutoSave();
......
<script> <script>
import { mapState } from 'vuex';
import diffDiscussions from './diff_discussions.vue'; import diffDiscussions from './diff_discussions.vue';
import diffLineNoteForm from './diff_line_note_form.vue'; import diffLineNoteForm from './diff_line_note_form.vue';
...@@ -17,29 +16,31 @@ export default { ...@@ -17,29 +16,31 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
lineIndex: {
type: Number,
required: true,
},
}, },
computed: { computed: {
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
className() { className() {
return this.line.discussions.length ? '' : 'js-temp-notes-holder'; return this.line.discussions.length ? '' : 'js-temp-notes-holder';
}, },
shouldRender() {
if (this.line.hasForm) return true;
if (!this.line.discussions || !this.line.discussions.length) {
return false;
}
return this.line.discussions.every(discussion => discussion.expanded);
},
}, },
}; };
</script> </script>
<template> <template>
<tr :class="className" class="notes_holder"> <tr v-if="shouldRender" :class="className" class="notes_holder">
<td class="notes_content" colspan="3"> <td class="notes_content" colspan="3">
<div class="content"> <div class="content">
<diff-discussions v-if="line.discussions.length" :discussions="line.discussions" /> <diff-discussions v-if="line.discussions.length" :discussions="line.discussions" />
<diff-line-note-form <diff-line-note-form
v-if="diffLineCommentForms[line.line_code]" v-if="line.hasForm"
:diff-file-hash="diffFileHash" :diff-file-hash="diffFileHash"
:line="line" :line="line"
:note-target-line="line" :note-target-line="line"
......
<script> <script>
import { mapGetters, mapState } from 'vuex'; import { mapGetters } from 'vuex';
import inlineDiffTableRow from './inline_diff_table_row.vue'; import inlineDiffTableRow from './inline_diff_table_row.vue';
import inlineDiffCommentRow from './inline_diff_comment_row.vue'; import inlineDiffCommentRow from './inline_diff_comment_row.vue';
...@@ -19,23 +19,18 @@ export default { ...@@ -19,23 +19,18 @@ export default {
}, },
}, },
computed: { computed: {
...mapGetters('diffs', ['commitId', 'shouldRenderInlineCommentRow']), ...mapGetters('diffs', ['commitId']),
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
diffLinesLength() { diffLinesLength() {
return this.diffLines.length; return this.diffLines.length;
}, },
userColorScheme() {
return window.gon.user_color_scheme;
},
}, },
userColorScheme: window.gon.user_color_scheme,
}; };
</script> </script>
<template> <template>
<table <table
:class="userColorScheme" :class="$options.userColorScheme"
:data-commit-id="commitId" :data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view" class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view"
> >
...@@ -49,11 +44,9 @@ export default { ...@@ -49,11 +44,9 @@ export default {
:is-bottom="index + 1 === diffLinesLength" :is-bottom="index + 1 === diffLinesLength"
/> />
<inline-diff-comment-row <inline-diff-comment-row
v-if="shouldRenderInlineCommentRow(line)" :key="`icr-${index}`"
:key="index"
:diff-file-hash="diffFile.file_hash" :diff-file-hash="diffFile.file_hash"
:line="line" :line="line"
:line-index="index"
/> />
</template> </template>
</tbody> </tbody>
......
<script> <script>
import { mapState } from 'vuex';
import diffDiscussions from './diff_discussions.vue'; import diffDiscussions from './diff_discussions.vue';
import diffLineNoteForm from './diff_line_note_form.vue'; import diffLineNoteForm from './diff_line_note_form.vue';
...@@ -23,22 +22,13 @@ export default { ...@@ -23,22 +22,13 @@ export default {
}, },
}, },
computed: { computed: {
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
leftLineCode() {
return this.line.left && this.line.left.line_code;
},
rightLineCode() {
return this.line.right && this.line.right.line_code;
},
hasExpandedDiscussionOnLeft() { hasExpandedDiscussionOnLeft() {
return this.line.left && this.line.left.discussions return this.line.left && this.line.left.discussions.length
? this.line.left.discussions.every(discussion => discussion.expanded) ? this.line.left.discussions.every(discussion => discussion.expanded)
: false; : false;
}, },
hasExpandedDiscussionOnRight() { hasExpandedDiscussionOnRight() {
return this.line.right && this.line.right.discussions return this.line.right && this.line.right.discussions.length
? this.line.right.discussions.every(discussion => discussion.expanded) ? this.line.right.discussions.every(discussion => discussion.expanded)
: false; : false;
}, },
...@@ -57,9 +47,10 @@ export default { ...@@ -57,9 +47,10 @@ export default {
); );
}, },
showRightSideCommentForm() { showRightSideCommentForm() {
return ( return this.line.right && this.line.right.type && this.line.right.hasForm;
this.line.right && this.line.right.type && this.diffLineCommentForms[this.rightLineCode] },
); showLeftSideCommentForm() {
return this.line.left && this.line.left.hasForm;
}, },
className() { className() {
return (this.left && this.line.left.discussions.length > 0) || return (this.left && this.line.left.discussions.length > 0) ||
...@@ -67,12 +58,30 @@ export default { ...@@ -67,12 +58,30 @@ export default {
? '' ? ''
: 'js-temp-notes-holder'; : 'js-temp-notes-holder';
}, },
shouldRender() {
const { line } = this;
const hasDiscussion =
(line.left && line.left.discussions && line.left.discussions.length) ||
(line.right && line.right.discussions && line.right.discussions.length);
if (
hasDiscussion &&
(this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight)
) {
return true;
}
const hasCommentFormOnLeft = line.left && line.left.hasForm;
const hasCommentFormOnRight = line.right && line.right.hasForm;
return hasCommentFormOnLeft || hasCommentFormOnRight;
},
}, },
}; };
</script> </script>
<template> <template>
<tr :class="className" class="notes_holder"> <tr v-if="shouldRender" :class="className" class="notes_holder">
<td class="notes_content parallel old" colspan="2"> <td class="notes_content parallel old" colspan="2">
<div v-if="shouldRenderDiscussionsOnLeft" class="content"> <div v-if="shouldRenderDiscussionsOnLeft" class="content">
<diff-discussions <diff-discussions
...@@ -81,7 +90,7 @@ export default { ...@@ -81,7 +90,7 @@ export default {
/> />
</div> </div>
<diff-line-note-form <diff-line-note-form
v-if="diffLineCommentForms[leftLineCode]" v-if="showLeftSideCommentForm"
:diff-file-hash="diffFileHash" :diff-file-hash="diffFileHash"
:line="line.left" :line="line.left"
:note-target-line="line.left" :note-target-line="line.left"
......
<script> <script>
import { mapState, mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import parallelDiffTableRow from './parallel_diff_table_row.vue'; import parallelDiffTableRow from './parallel_diff_table_row.vue';
import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; import parallelDiffCommentRow from './parallel_diff_comment_row.vue';
...@@ -19,23 +19,18 @@ export default { ...@@ -19,23 +19,18 @@ export default {
}, },
}, },
computed: { computed: {
...mapGetters('diffs', ['commitId', 'shouldRenderParallelCommentRow']), ...mapGetters('diffs', ['commitId']),
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
diffLinesLength() { diffLinesLength() {
return this.diffLines.length; return this.diffLines.length;
}, },
userColorScheme() {
return window.gon.user_color_scheme;
},
}, },
userColorScheme: window.gon.user_color_scheme,
}; };
</script> </script>
<template> <template>
<div <div
:class="userColorScheme" :class="$options.userColorScheme"
:data-commit-id="commitId" :data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file" class="code diff-wrap-lines js-syntax-highlight text-file"
> >
...@@ -50,7 +45,6 @@ export default { ...@@ -50,7 +45,6 @@ export default {
:is-bottom="index + 1 === diffLinesLength" :is-bottom="index + 1 === diffLinesLength"
/> />
<parallel-diff-comment-row <parallel-diff-comment-row
v-if="shouldRenderParallelCommentRow(line)"
:key="`dcr-${index}`" :key="`dcr-${index}`"
:line="line" :line="line"
:diff-file-hash="diffFile.file_hash" :diff-file-hash="diffFile.file_hash"
......
...@@ -99,12 +99,12 @@ export const setParallelDiffViewType = ({ commit }) => { ...@@ -99,12 +99,12 @@ export const setParallelDiffViewType = ({ commit }) => {
historyPushState(url); historyPushState(url);
}; };
export const showCommentForm = ({ commit }, params) => { export const showCommentForm = ({ commit }, { lineCode, fileHash }) => {
commit(types.ADD_COMMENT_FORM_LINE, params); commit(types.TOGGLE_LINE_HAS_FORM, { lineCode, fileHash, hasForm: true });
}; };
export const cancelCommentForm = ({ commit }, params) => { export const cancelCommentForm = ({ commit }, { lineCode, fileHash }) => {
commit(types.REMOVE_COMMENT_FORM_LINE, params); commit(types.TOGGLE_LINE_HAS_FORM, { lineCode, fileHash, hasForm: false });
}; };
export const loadMoreLines = ({ commit }, options) => { export const loadMoreLines = ({ commit }, options) => {
...@@ -191,8 +191,8 @@ export const saveDiffDiscussion = ({ dispatch }, { note, formData }) => { ...@@ -191,8 +191,8 @@ export const saveDiffDiscussion = ({ dispatch }, { note, formData }) => {
return dispatch('saveNote', postData, { root: true }) return dispatch('saveNote', postData, { root: true })
.then(result => dispatch('updateDiscussion', result.discussion, { root: true })) .then(result => dispatch('updateDiscussion', result.discussion, { root: true }))
.then(discussion => dispatch('assignDiscussionsToDiff', [discussion])) .then(discussion => dispatch('assignDiscussionsToDiff', [discussion]))
.then(() => dispatch('updateResolvableDiscussonsCounts', null, { root: true }))
.then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash)) .then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash))
.then(() => dispatch('startTaskList', null, { root: true }))
.catch(() => createFlash(s__('MergeRequests|Saving the comment failed'))); .catch(() => createFlash(s__('MergeRequests|Saving the comment failed')));
}; };
......
...@@ -70,40 +70,6 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) = ...@@ -70,40 +70,6 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) =
discussion => discussion.diff_discussion && discussion.diff_file.file_hash === diff.file_hash, discussion => discussion.diff_discussion && discussion.diff_file.file_hash === diff.file_hash,
) || []; ) || [];
export const shouldRenderParallelCommentRow = state => line => {
const hasDiscussion =
(line.left && line.left.discussions && line.left.discussions.length) ||
(line.right && line.right.discussions && line.right.discussions.length);
const hasExpandedDiscussionOnLeft =
line.left && line.left.discussions && line.left.discussions.length
? line.left.discussions.every(discussion => discussion.expanded)
: false;
const hasExpandedDiscussionOnRight =
line.right && line.right.discussions && line.right.discussions.length
? line.right.discussions.every(discussion => discussion.expanded)
: false;
if (hasDiscussion && (hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight)) {
return true;
}
const hasCommentFormOnLeft = line.left && state.diffLineCommentForms[line.left.line_code];
const hasCommentFormOnRight = line.right && state.diffLineCommentForms[line.right.line_code];
return hasCommentFormOnLeft || hasCommentFormOnRight;
};
export const shouldRenderInlineCommentRow = state => line => {
if (state.diffLineCommentForms[line.line_code]) return true;
if (!line.discussions || line.discussions.length === 0) {
return false;
}
return line.discussions.every(discussion => discussion.expanded);
};
// prevent babel-plugin-rewire from generating an invalid default during karma∂ tests // prevent babel-plugin-rewire from generating an invalid default during karma∂ tests
export const getDiffFileByHash = state => fileHash => export const getDiffFileByHash = state => fileHash =>
state.diffFiles.find(file => file.file_hash === fileHash); state.diffFiles.find(file => file.file_hash === fileHash);
......
...@@ -17,7 +17,6 @@ export default () => ({ ...@@ -17,7 +17,6 @@ export default () => ({
diffFiles: [], diffFiles: [],
mergeRequestDiffs: [], mergeRequestDiffs: [],
mergeRequestDiff: null, mergeRequestDiff: null,
diffLineCommentForms: {},
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
tree: [], tree: [],
treeEntries: {}, treeEntries: {},
......
...@@ -3,8 +3,7 @@ export const SET_LOADING = 'SET_LOADING'; ...@@ -3,8 +3,7 @@ export const SET_LOADING = 'SET_LOADING';
export const SET_DIFF_DATA = 'SET_DIFF_DATA'; export const SET_DIFF_DATA = 'SET_DIFF_DATA';
export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE'; export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE';
export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS'; export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS';
export const ADD_COMMENT_FORM_LINE = 'ADD_COMMENT_FORM_LINE'; export const TOGGLE_LINE_HAS_FORM = 'TOGGLE_LINE_HAS_FORM';
export const REMOVE_COMMENT_FORM_LINE = 'REMOVE_COMMENT_FORM_LINE';
export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES'; export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES';
export const ADD_COLLAPSED_DIFFS = 'ADD_COLLAPSED_DIFFS'; export const ADD_COLLAPSED_DIFFS = 'ADD_COLLAPSED_DIFFS';
export const EXPAND_ALL_FILES = 'EXPAND_ALL_FILES'; export const EXPAND_ALL_FILES = 'EXPAND_ALL_FILES';
......
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { sortTree } from '~/ide/stores/utils'; import { sortTree } from '~/ide/stores/utils';
import { import {
...@@ -49,12 +48,30 @@ export default { ...@@ -49,12 +48,30 @@ export default {
Object.assign(state, { diffViewType }); Object.assign(state, { diffViewType });
}, },
[types.ADD_COMMENT_FORM_LINE](state, { lineCode }) { [types.TOGGLE_LINE_HAS_FORM](state, { lineCode, fileHash, hasForm }) {
Vue.set(state.diffLineCommentForms, lineCode, true); const diffFile = state.diffFiles.find(f => f.file_hash === fileHash);
},
if (!diffFile) return;
if (diffFile.highlighted_diff_lines) {
diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm;
}
if (diffFile.parallel_diff_lines) {
const line = diffFile.parallel_diff_lines.find(l => {
const { left, right } = l;
[types.REMOVE_COMMENT_FORM_LINE](state, { lineCode }) { return (left && left.line_code === lineCode) || (right && right.line_code === lineCode);
Vue.delete(state.diffLineCommentForms, lineCode); });
if (line.left && line.left.line_code === lineCode) {
line.left.hasForm = hasForm;
}
if (line.right && line.right.line_code === lineCode) {
line.right.hasForm = hasForm;
}
}
}, },
[types.ADD_CONTEXT_LINES](state, options) { [types.ADD_CONTEXT_LINES](state, options) {
...@@ -68,6 +85,7 @@ export default { ...@@ -68,6 +85,7 @@ export default {
...line, ...line,
line_code: line.line_code || `${fileHash}_${line.old_line}_${line.new_line}`, line_code: line.line_code || `${fileHash}_${line.old_line}_${line.new_line}`,
discussions: line.discussions || [], discussions: line.discussions || [],
hasForm: false,
})); }));
addContextLines({ addContextLines({
......
...@@ -209,9 +209,11 @@ export function prepareDiffData(diffData) { ...@@ -209,9 +209,11 @@ export function prepareDiffData(diffData) {
const line = file.parallel_diff_lines[u]; const line = file.parallel_diff_lines[u];
if (line.left) { if (line.left) {
line.left = trimFirstCharOfLineContent(line.left); line.left = trimFirstCharOfLineContent(line.left);
line.left.hasForm = false;
} }
if (line.right) { if (line.right) {
line.right = trimFirstCharOfLineContent(line.right); line.right = trimFirstCharOfLineContent(line.right);
line.right.hasForm = false;
} }
} }
} }
...@@ -220,7 +222,7 @@ export function prepareDiffData(diffData) { ...@@ -220,7 +222,7 @@ export function prepareDiffData(diffData) {
const linesLength = file.highlighted_diff_lines.length; const linesLength = file.highlighted_diff_lines.length;
for (let u = 0; u < linesLength; u += 1) { for (let u = 0; u < linesLength; u += 1) {
const line = file.highlighted_diff_lines[u]; const line = file.highlighted_diff_lines[u];
Object.assign(line, { ...trimFirstCharOfLineContent(line) }); Object.assign(line, { ...trimFirstCharOfLineContent(line), hasForm: false });
} }
showingLines += file.parallel_diff_lines.length; showingLines += file.parallel_diff_lines.length;
} }
......
...@@ -4,7 +4,9 @@ import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; ...@@ -4,7 +4,9 @@ import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
import { GlSkeletonLoading } from '@gitlab/ui'; import { GlSkeletonLoading } from '@gitlab/ui';
import { trimFirstCharOfLineContent, getDiffMode } from '~/diffs/store/utils'; import { getDiffMode } from '~/diffs/store/utils';
const FIRST_CHAR_REGEX = /^(\+|-| )/;
export default { export default {
components: { components: {
...@@ -26,46 +28,16 @@ export default { ...@@ -26,46 +28,16 @@ export default {
}, },
computed: { computed: {
...mapState({ ...mapState({
noteableData: state => state.notes.noteableData,
projectPath: state => state.diffs.projectPath, projectPath: state => state.diffs.projectPath,
}), }),
diffMode() { diffMode() {
return getDiffMode(this.diffFile); return getDiffMode(this.discussion.diff_file);
}, },
hasTruncatedDiffLines() { hasTruncatedDiffLines() {
return ( return (
this.discussion.truncated_diff_lines && this.discussion.truncated_diff_lines.length !== 0 this.discussion.truncated_diff_lines && this.discussion.truncated_diff_lines.length !== 0
); );
}, },
isDiscussionsExpanded() {
return true; // TODO: @fatihacet - Fix this.
},
isCollapsed() {
return this.diffFile.collapsed || false;
},
isImageDiff() {
return !this.diffFile.text;
},
diffFileClass() {
const { text } = this.diffFile;
return text ? 'text-file' : 'js-image-file';
},
diffFile() {
return this.discussion.diff_file;
},
imageDiffHtml() {
return this.discussion.image_diff_html;
},
userColorScheme() {
return window.gon.user_color_scheme;
},
normalizedDiffLines() {
if (this.discussion.truncated_diff_lines) {
return this.discussion.truncated_diff_lines.map(line => trimFirstCharOfLineContent(line));
}
return [];
},
}, },
mounted() { mounted() {
if (!this.hasTruncatedDiffLines) { if (!this.hasTruncatedDiffLines) {
...@@ -74,9 +46,6 @@ export default { ...@@ -74,9 +46,6 @@ export default {
}, },
methods: { methods: {
...mapActions(['fetchDiscussionDiffLines']), ...mapActions(['fetchDiscussionDiffLines']),
rowTag(html) {
return html.outerHTML ? 'tr' : 'template';
},
fetchDiff() { fetchDiff() {
this.error = false; this.error = false;
this.fetchDiscussionDiffLines(this.discussion) this.fetchDiscussionDiffLines(this.discussion)
...@@ -85,31 +54,45 @@ export default { ...@@ -85,31 +54,45 @@ export default {
this.error = true; this.error = true;
}); });
}, },
trimChar(line) {
return line.replace(FIRST_CHAR_REGEX, '');
}, },
},
userColorSchemeClass: window.gon.user_color_scheme,
}; };
</script> </script>
<template> <template>
<div ref="fileHolder" :class="diffFileClass" class="diff-file file-holder"> <div :class="{ 'text-file': discussion.diff_file.text }" class="diff-file file-holder">
<diff-file-header <diff-file-header
:discussion-path="discussion.discussion_path" :discussion-path="discussion.discussion_path"
:diff-file="diffFile" :diff-file="discussion.diff_file"
:can-current-user-fork="false" :can-current-user-fork="false"
:discussions-expanded="isDiscussionsExpanded" :expanded="!discussion.diff_file.collapsed"
:expanded="!isCollapsed"
/> />
<div v-if="diffFile.text" :class="userColorScheme" class="diff-content code"> <div
v-if="discussion.diff_file.text"
:class="$options.userColorSchemeClass"
class="diff-content code"
>
<table> <table>
<tr v-for="line in normalizedDiffLines" :key="line.line_code" class="line_holder"> <template v-if="hasTruncatedDiffLines">
<tr
v-for="line in discussion.truncated_diff_lines"
v-once
:key="line.line_code"
class="line_holder"
>
<td class="diff-line-num old_line">{{ line.old_line }}</td> <td class="diff-line-num old_line">{{ line.old_line }}</td>
<td class="diff-line-num new_line">{{ line.new_line }}</td> <td class="diff-line-num new_line">{{ line.new_line }}</td>
<td :class="line.type" class="line_content" v-html="line.rich_text"></td> <td :class="line.type" class="line_content" v-html="trimChar(line.rich_text)"></td>
</tr> </tr>
</template>
<tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder"> <tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder">
<td class="old_line diff-line-num"></td> <td class="old_line diff-line-num"></td>
<td class="new_line diff-line-num"></td> <td class="new_line diff-line-num"></td>
<td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block"> <td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block">
Unable to load the diff {{ error }} Unable to load the diff
<button <button
class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button" class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button"
@click="fetchDiff" @click="fetchDiff"
...@@ -131,17 +114,17 @@ export default { ...@@ -131,17 +114,17 @@ export default {
<div v-else> <div v-else>
<diff-viewer <diff-viewer
:diff-mode="diffMode" :diff-mode="diffMode"
:new-path="diffFile.new_path" :new-path="discussion.diff_file.new_path"
:new-sha="diffFile.diff_refs.head_sha" :new-sha="discussion.diff_file.diff_refs.head_sha"
:old-path="diffFile.old_path" :old-path="discussion.diff_file.old_path"
:old-sha="diffFile.diff_refs.base_sha" :old-sha="discussion.diff_file.diff_refs.base_sha"
:file-hash="diffFile.file_hash" :file-hash="discussion.diff_file.file_hash"
:project-path="projectPath" :project-path="projectPath"
> >
<image-diff-overlay <image-diff-overlay
slot="image-overlay" slot="image-overlay"
:discussions="discussion" :discussions="discussion"
:file-hash="diffFile.file_hash" :file-hash="discussion.diff_file.file_hash"
:show-comment-icon="true" :show-comment-icon="true"
:should-toggle-discussion="false" :should-toggle-discussion="false"
badge-class="image-comment-badge" badge-class="image-comment-badge"
......
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { pluralize } from '../../lib/utils/text_utility';
import discussionNavigation from '../mixins/discussion_navigation'; import discussionNavigation from '../mixins/discussion_navigation';
import tooltip from '../../vue_shared/directives/tooltip';
export default { export default {
directives: { directives: {
tooltip, GlTooltip: GlTooltipDirective,
}, },
components: { components: {
Icon, Icon,
...@@ -17,9 +16,9 @@ export default { ...@@ -17,9 +16,9 @@ export default {
...mapGetters([ ...mapGetters([
'getUserData', 'getUserData',
'getNoteableData', 'getNoteableData',
'discussionCount', 'resolvableDiscussionsCount',
'firstUnresolvedDiscussionId', 'firstUnresolvedDiscussionId',
'resolvedDiscussionCount', 'unresolvedDiscussionsCount',
]), ]),
isLoggedIn() { isLoggedIn() {
return this.getUserData.id; return this.getUserData.id;
...@@ -27,15 +26,15 @@ export default { ...@@ -27,15 +26,15 @@ export default {
hasNextButton() { hasNextButton() {
return this.isLoggedIn && !this.allResolved; return this.isLoggedIn && !this.allResolved;
}, },
countText() {
return pluralize('discussion', this.discussionCount);
},
allResolved() { allResolved() {
return this.resolvedDiscussionCount === this.discussionCount; return this.unresolvedDiscussionsCount === 0;
}, },
resolveAllDiscussionsIssuePath() { resolveAllDiscussionsIssuePath() {
return this.getNoteableData.create_issue_to_resolve_discussions_path; return this.getNoteableData.create_issue_to_resolve_discussions_path;
}, },
resolvedDiscussionsCount() {
return this.resolvableDiscussionsCount - this.unresolvedDiscussionsCount;
},
}, },
methods: { methods: {
...mapActions(['expandDiscussion']), ...mapActions(['expandDiscussion']),
...@@ -50,7 +49,7 @@ export default { ...@@ -50,7 +49,7 @@ export default {
</script> </script>
<template> <template>
<div v-if="discussionCount > 0" class="line-resolve-all-container prepend-top-8"> <div v-if="resolvableDiscussionsCount > 0" class="line-resolve-all-container prepend-top-8">
<div> <div>
<div :class="{ 'has-next-btn': hasNextButton }" class="line-resolve-all"> <div :class="{ 'has-next-btn': hasNextButton }" class="line-resolve-all">
<span <span
...@@ -61,15 +60,15 @@ export default { ...@@ -61,15 +60,15 @@ export default {
<icon name="check-circle" /> <icon name="check-circle" />
</span> </span>
<span class="line-resolve-text"> <span class="line-resolve-text">
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved {{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }}
{{ n__('discussion resolved', 'discussions resolved', resolvableDiscussionsCount) }}
</span> </span>
</div> </div>
<div v-if="resolveAllDiscussionsIssuePath && !allResolved" class="btn-group" role="group"> <div v-if="resolveAllDiscussionsIssuePath && !allResolved" class="btn-group" role="group">
<a <a
v-tooltip v-gl-tooltip
:href="resolveAllDiscussionsIssuePath" :href="resolveAllDiscussionsIssuePath"
:title="s__('Resolve all discussions in new issue')" :title="s__('Resolve all discussions in new issue')"
data-container="body"
class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"
> >
<icon name="issue-new" /> <icon name="issue-new" />
...@@ -77,9 +76,8 @@ export default { ...@@ -77,9 +76,8 @@ export default {
</div> </div>
<div v-if="isLoggedIn && !allResolved" class="btn-group" role="group"> <div v-if="isLoggedIn && !allResolved" class="btn-group" role="group">
<button <button
v-tooltip v-gl-tooltip
title="Jump to first unresolved discussion" title="Jump to first unresolved discussion"
data-container="body"
class="btn btn-default discussion-next-btn" class="btn btn-default discussion-next-btn"
@click="jumpToFirstUnresolvedDiscussion" @click="jumpToFirstUnresolvedDiscussion"
> >
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlLoadingIcon } from '@gitlab/ui';
export default { export default {
name: 'NoteActions', name: 'NoteActions',
...@@ -11,7 +10,7 @@ export default { ...@@ -11,7 +10,7 @@ export default {
GlLoadingIcon, GlLoadingIcon,
}, },
directives: { directives: {
tooltip, GlTooltip: GlTooltipDirective,
}, },
props: { props: {
authorId: { authorId: {
...@@ -119,10 +118,10 @@ export default { ...@@ -119,10 +118,10 @@ export default {
<template> <template>
<div class="note-actions"> <div class="note-actions">
<span v-if="accessLevel" class="note-role user-access-role"> {{ accessLevel }} </span> <span v-if="accessLevel" class="note-role user-access-role">{{ accessLevel }}</span>
<div v-if="canResolve" class="note-actions-item"> <div v-if="canResolve" class="note-actions-item">
<button <button
v-tooltip v-gl-tooltip
:class="{ 'is-disabled': !resolvable, 'is-active': isResolved }" :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }"
:title="resolveButtonTitle" :title="resolveButtonTitle"
:aria-label="resolveButtonTitle" :aria-label="resolveButtonTitle"
...@@ -138,12 +137,10 @@ export default { ...@@ -138,12 +137,10 @@ export default {
</div> </div>
<div v-if="canAwardEmoji" class="note-actions-item"> <div v-if="canAwardEmoji" class="note-actions-item">
<a <a
v-tooltip v-gl-tooltip.bottom
:class="{ 'js-user-authored': isAuthoredByCurrentUser }" :class="{ 'js-user-authored': isAuthoredByCurrentUser }"
class="note-action-button note-emoji-button js-add-award js-note-emoji" class="note-action-button note-emoji-button js-add-award js-note-emoji"
data-position="right" data-position="right"
data-placement="bottom"
data-container="body"
href="#" href="#"
title="Add reaction" title="Add reaction"
> >
...@@ -158,12 +155,10 @@ export default { ...@@ -158,12 +155,10 @@ export default {
</div> </div>
<div v-if="canEdit" class="note-actions-item"> <div v-if="canEdit" class="note-actions-item">
<button <button
v-tooltip v-gl-tooltip.bottom
type="button" type="button"
title="Edit comment" title="Edit comment"
class="note-action-button js-note-edit btn btn-transparent" class="note-action-button js-note-edit btn btn-transparent"
data-container="body"
data-placement="bottom"
@click="onEdit" @click="onEdit"
> >
<icon name="pencil" css-classes="link-highlight" /> <icon name="pencil" css-classes="link-highlight" />
...@@ -171,12 +166,10 @@ export default { ...@@ -171,12 +166,10 @@ export default {
</div> </div>
<div v-if="showDeleteAction" class="note-actions-item"> <div v-if="showDeleteAction" class="note-actions-item">
<button <button
v-tooltip v-gl-tooltip.bottom
type="button" type="button"
title="Delete comment" title="Delete comment"
class="note-action-button js-note-delete btn btn-transparent" class="note-action-button js-note-delete btn btn-transparent"
data-container="body"
data-placement="bottom"
@click="onDelete" @click="onDelete"
> >
<icon name="remove" class="link-highlight" /> <icon name="remove" class="link-highlight" />
...@@ -184,19 +177,17 @@ export default { ...@@ -184,19 +177,17 @@ export default {
</div> </div>
<div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item"> <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item">
<button <button
v-tooltip v-gl-tooltip.bottom
type="button" type="button"
title="More actions" title="More actions"
class="note-action-button more-actions-toggle btn btn-transparent" class="note-action-button more-actions-toggle btn btn-transparent"
data-toggle="dropdown" data-toggle="dropdown"
data-container="body"
data-placement="bottom"
> >
<icon css-classes="icon" name="ellipsis_v" /> <icon css-classes="icon" name="ellipsis_v" />
</button> </button>
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
<li v-if="canReportAsAbuse"> <li v-if="canReportAsAbuse">
<a :href="reportAbusePath"> {{ __('Report abuse to GitLab') }} </a> <a :href="reportAbusePath">{{ __('Report abuse to GitLab') }}</a>
</li> </li>
<li v-if="noteUrl"> <li v-if="noteUrl">
<button <button
...@@ -213,7 +204,7 @@ export default { ...@@ -213,7 +204,7 @@ export default {
type="button" type="button"
@click.prevent="onDelete" @click.prevent="onDelete"
> >
<span class="text-danger"> {{ __('Delete comment') }} </span> <span class="text-danger">{{ __('Delete comment') }}</span>
</button> </button>
</li> </li>
</ul> </ul>
......
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import Flash from '../../flash'; import Flash from '../../flash';
import { glEmojiTag } from '../../emoji'; import { glEmojiTag } from '../../emoji';
import tooltip from '../../vue_shared/directives/tooltip';
export default { export default {
components: { components: {
Icon, Icon,
}, },
directives: { directives: {
tooltip, GlTooltip: GlTooltipDirective,
}, },
props: { props: {
awards: { awards: {
...@@ -167,21 +167,19 @@ export default { ...@@ -167,21 +167,19 @@ export default {
<button <button
v-for="(awardList, awardName, index) in groupedAwards" v-for="(awardList, awardName, index) in groupedAwards"
:key="index" :key="index"
v-tooltip v-gl-tooltip.bottom="{ boundary: 'viewport' }"
:class="getAwardClassBindings(awardList)" :class="getAwardClassBindings(awardList)"
:title="awardTitle(awardList)" :title="awardTitle(awardList)"
class="btn award-control" class="btn award-control"
data-boundary="viewport"
data-placement="bottom"
type="button" type="button"
@click="handleAward(awardName);" @click="handleAward(awardName);"
> >
<span v-html="getAwardHTML(awardName)"></span> <span v-html="getAwardHTML(awardName)"></span>
<span class="award-control-text js-counter"> {{ awardList.length }} </span> <span class="award-control-text js-counter">{{ awardList.length }}</span>
</button> </button>
<div v-if="canAwardEmoji" class="award-menu-holder"> <div v-if="canAwardEmoji" class="award-menu-holder">
<button <button
v-tooltip v-gl-tooltip
:class="{ 'js-user-authored': isAuthoredByMe }" :class="{ 'js-user-authored': isAuthoredByMe }"
class="award-control btn js-add-award" class="award-control btn js-add-award"
title="Add reaction" title="Add reaction"
......
...@@ -73,7 +73,7 @@ export default { ...@@ -73,7 +73,7 @@ export default {
{{ __('Toggle discussion') }} {{ __('Toggle discussion') }}
</button> </button>
</div> </div>
<a v-if="hasAuthor" :href="author.path"> <a v-if="hasAuthor" v-once :href="author.path">
<span class="note-header-author-name">{{ author.name }}</span> <span class="note-header-author-name">{{ author.name }}</span>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
<span class="note-headline-light"> @{{ author.username }} </span> <span class="note-headline-light"> @{{ author.username }} </span>
......
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
import { truncateSha } from '~/lib/utils/text_utility'; import { truncateSha } from '~/lib/utils/text_utility';
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
import systemNote from '~/vue_shared/components/notes/system_note.vue'; import systemNote from '~/vue_shared/components/notes/system_note.vue';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import Flash from '../../flash'; import Flash from '../../flash';
...@@ -20,14 +21,12 @@ import autosave from '../mixins/autosave'; ...@@ -20,14 +21,12 @@ import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable'; import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable'; import resolvable from '../mixins/resolvable';
import discussionNavigation from '../mixins/discussion_navigation'; import discussionNavigation from '../mixins/discussion_navigation';
import tooltip from '../../vue_shared/directives/tooltip';
export default { export default {
name: 'NoteableDiscussion', name: 'NoteableDiscussion',
components: { components: {
icon, icon,
noteableNote, noteableNote,
diffWithNote,
userAvatarLink, userAvatarLink,
noteHeader, noteHeader,
noteSignedOutWidget, noteSignedOutWidget,
...@@ -39,7 +38,7 @@ export default { ...@@ -39,7 +38,7 @@ export default {
systemNote, systemNote,
}, },
directives: { directives: {
tooltip, GlTooltip: GlTooltipDirective,
}, },
mixins: [autosave, noteable, resolvable, discussionNavigation], mixins: [autosave, noteable, resolvable, discussionNavigation],
props: { props: {
...@@ -74,33 +73,12 @@ export default { ...@@ -74,33 +73,12 @@ export default {
computed: { computed: {
...mapGetters([ ...mapGetters([
'getNoteableData', 'getNoteableData',
'discussionCount',
'resolvedDiscussionCount',
'allDiscussions',
'unresolvedDiscussionsIdsByDiff',
'unresolvedDiscussionsIdsByDate',
'unresolvedDiscussions',
'unresolvedDiscussionsIdsOrdered',
'nextUnresolvedDiscussionId', 'nextUnresolvedDiscussionId',
'isLastUnresolvedDiscussion', 'unresolvedDiscussionsCount',
'hasUnresolvedDiscussions',
]), ]),
transformedDiscussion() {
return {
...this.discussion.notes[0],
truncated_diff_lines: this.discussion.truncated_diff_lines || [],
truncated_diff_lines_path: this.discussion.truncated_diff_lines_path,
diff_file: this.discussion.diff_file,
diff_discussion: this.discussion.diff_discussion,
active: this.discussion.active,
discussion_path: this.discussion.discussion_path,
resolved: this.discussion.resolved,
resolved_by: this.discussion.resolved_by,
resolved_by_push: this.discussion.resolved_by_push,
resolved_at: this.discussion.resolved_at,
};
},
author() { author() {
return this.transformedDiscussion.author; return this.initialDiscussion.author;
}, },
canReply() { canReply() {
return this.getNoteableData.current_user.can_create_note; return this.getNoteableData.current_user.can_create_note;
...@@ -136,29 +114,13 @@ export default { ...@@ -136,29 +114,13 @@ export default {
return null; return null;
}, },
resolvedText() { resolvedText() {
return this.transformedDiscussion.resolved_by_push ? 'Automatically resolved' : 'Resolved'; return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved');
},
hasMultipleUnresolvedDiscussions() {
return this.unresolvedDiscussions.length > 1;
},
showJumpToNextDiscussion() {
return (
this.hasMultipleUnresolvedDiscussions &&
!this.isLastUnresolvedDiscussion(this.discussion.id, this.discussionsByDiffOrder)
);
}, },
shouldRenderDiffs() { shouldRenderDiffs() {
return ( return this.discussion.diff_discussion && this.renderDiffFile;
this.transformedDiscussion.diff_discussion &&
this.transformedDiscussion.diff_file &&
this.renderDiffFile
);
}, },
shouldGroupReplies() { shouldGroupReplies() {
return !this.shouldRenderDiffs && !this.transformedDiscussion.diff_discussion; return !this.shouldRenderDiffs && !this.discussion.diff_discussion;
},
shouldRenderHeader() {
return this.shouldRenderDiffs;
}, },
wrapperComponent() { wrapperComponent() {
return this.shouldRenderDiffs ? diffWithNote : 'div'; return this.shouldRenderDiffs ? diffWithNote : 'div';
...@@ -170,9 +132,6 @@ export default { ...@@ -170,9 +132,6 @@ export default {
return {}; return {};
}, },
wrapperClass() {
return this.isDiffDiscussion ? '' : 'card discussion-wrapper';
},
componentClassName() { componentClassName() {
if (this.shouldRenderDiffs) { if (this.shouldRenderDiffs) {
if (!this.lastUpdatedAt && !this.discussion.resolved) { if (!this.lastUpdatedAt && !this.discussion.resolved) {
...@@ -183,11 +142,10 @@ export default { ...@@ -183,11 +142,10 @@ export default {
return ''; return '';
}, },
shouldShowDiscussions() { shouldShowDiscussions() {
const isExpanded = this.discussion.expanded; const { expanded, resolved } = this.discussion;
const { resolved } = this.transformedDiscussion; const isResolvedNonDiffDiscussion = !this.discussion.diff_discussion && resolved;
const isResolvedNonDiffDiscussion = !this.transformedDiscussion.diff_discussion && resolved;
return isExpanded || this.alwaysExpanded || isResolvedNonDiffDiscussion; return expanded || this.alwaysExpanded || isResolvedNonDiffDiscussion;
}, },
isRepliesCollapsed() { isRepliesCollapsed() {
const { discussion, isRepliesToggledByUser } = this; const { discussion, isRepliesToggledByUser } = this;
...@@ -204,7 +162,7 @@ export default { ...@@ -204,7 +162,7 @@ export default {
if (this.isReplying) { if (this.isReplying) {
this.$nextTick(() => { this.$nextTick(() => {
// Pass an extra key to separate reply and note edit forms // Pass an extra key to separate reply and note edit forms
this.initAutoSave(this.transformedDiscussion, ['Reply']); this.initAutoSave({ ...this.initialDiscussion, ...this.discussion }, ['Reply']);
}); });
} else { } else {
this.disposeAutoSave(); this.disposeAutoSave();
...@@ -314,12 +272,9 @@ Please check your network connection and try again.`; ...@@ -314,12 +272,9 @@ Please check your network connection and try again.`;
<li class="note note-discussion timeline-entry" :class="componentClassName"> <li class="note note-discussion timeline-entry" :class="componentClassName">
<div class="timeline-entry-inner"> <div class="timeline-entry-inner">
<div class="timeline-content"> <div class="timeline-content">
<div <div :data-discussion-id="discussion.id" class="discussion js-discussion-container">
:data-discussion-id="transformedDiscussion.discussion_id" <div v-if="shouldRenderDiffs" class="discussion-header note-wrapper">
class="discussion js-discussion-container" <div v-once class="timeline-icon">
>
<div v-if="shouldRenderHeader" class="discussion-header note-wrapper">
<div class="timeline-icon">
<user-avatar-link <user-avatar-link
v-if="author" v-if="author"
:link-href="author.path" :link-href="author.path"
...@@ -330,35 +285,35 @@ Please check your network connection and try again.`; ...@@ -330,35 +285,35 @@ Please check your network connection and try again.`;
</div> </div>
<note-header <note-header
:author="author" :author="author"
:created-at="transformedDiscussion.created_at" :created-at="initialDiscussion.created_at"
:note-id="transformedDiscussion.id" :note-id="initialDiscussion.id"
:include-toggle="true" :include-toggle="true"
:expanded="discussion.expanded" :expanded="discussion.expanded"
@toggleHandler="toggleDiscussionHandler" @toggleHandler="toggleDiscussionHandler"
> >
<template v-if="transformedDiscussion.diff_discussion"> <template v-if="discussion.diff_discussion">
started a discussion on started a discussion on
<a :href="transformedDiscussion.discussion_path"> <a :href="discussion.discussion_path">
<template v-if="transformedDiscussion.active"> <template v-if="discussion.active"
the diff >the diff</template
</template> >
<template v-else> <template v-else
an old version of the diff >an old version of the diff</template
</template> >
</a> </a>
</template> </template>
<template v-else-if="discussion.for_commit"> <template v-else-if="discussion.for_commit">
started a discussion on commit started a discussion on commit
<a :href="discussion.discussion_path"> {{ truncateSha(discussion.commit_id) }} </a> <a :href="discussion.discussion_path">{{ truncateSha(discussion.commit_id) }}</a>
</template>
<template v-else>
started a discussion
</template> </template>
<template v-else
>started a discussion</template
>
</note-header> </note-header>
<note-edited-text <note-edited-text
v-if="transformedDiscussion.resolved" v-if="discussion.resolved"
:edited-at="transformedDiscussion.resolved_at" :edited-at="discussion.resolved_at"
:edited-by="transformedDiscussion.resolved_by" :edited-by="discussion.resolved_by"
:action-text="resolvedText" :action-text="resolvedText"
class-name="discussion-headline-light js-discussion-headline" class-name="discussion-headline-light js-discussion-headline"
/> />
...@@ -371,7 +326,11 @@ Please check your network connection and try again.`; ...@@ -371,7 +326,11 @@ Please check your network connection and try again.`;
/> />
</div> </div>
<div v-if="shouldShowDiscussions" class="discussion-body"> <div v-if="shouldShowDiscussions" class="discussion-body">
<component :is="wrapperComponent" v-bind="wrapperComponentProps" :class="wrapperClass"> <component
:is="wrapperComponent"
v-bind="wrapperComponentProps"
class="card discussion-wrapper"
>
<div class="discussion-notes"> <div class="discussion-notes">
<ul class="notes"> <ul class="notes">
<template v-if="shouldGroupReplies"> <template v-if="shouldGroupReplies">
...@@ -380,7 +339,7 @@ Please check your network connection and try again.`; ...@@ -380,7 +339,7 @@ Please check your network connection and try again.`;
:note="componentData(initialDiscussion)" :note="componentData(initialDiscussion)"
@handleDeleteNote="deleteNoteHandler" @handleDeleteNote="deleteNoteHandler"
> >
<slot slot="avatar-badge" name="avatar-badge"> </slot> <slot slot="avatar-badge" name="avatar-badge"></slot>
</component> </component>
<toggle-replies-widget <toggle-replies-widget
v-if="hasReplies" v-if="hasReplies"
...@@ -406,7 +365,7 @@ Please check your network connection and try again.`; ...@@ -406,7 +365,7 @@ Please check your network connection and try again.`;
:note="componentData(note)" :note="componentData(note)"
@handleDeleteNote="deleteNoteHandler" @handleDeleteNote="deleteNoteHandler"
> >
<slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"> </slot> <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
</component> </component>
</template> </template>
</ul> </ul>
...@@ -446,22 +405,19 @@ Please check your network connection and try again.`; ...@@ -446,22 +405,19 @@ Please check your network connection and try again.`;
> >
<div v-if="!discussionResolved" class="btn-group" role="group"> <div v-if="!discussionResolved" class="btn-group" role="group">
<a <a
v-tooltip v-gl-tooltip
:href="discussion.resolve_with_issue_path" :href="discussion.resolve_with_issue_path"
:title="s__('MergeRequests|Resolve this discussion in a new issue')" :title="s__('MergeRequests|Resolve this discussion in a new issue')"
class="new-issue-for-discussion btn class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"
btn-default discussion-create-issue-btn"
data-container="body"
> >
<icon name="issue-new" /> <icon name="issue-new" />
</a> </a>
</div> </div>
<div v-if="showJumpToNextDiscussion" class="btn-group" role="group"> <div v-if="hasUnresolvedDiscussions" class="btn-group" role="group">
<button <button
v-tooltip v-gl-tooltip
class="btn btn-default discussion-next-btn" class="btn btn-default discussion-next-btn"
title="Jump to next unresolved discussion" title="Jump to next unresolved discussion"
data-container="body"
@click="jumpToNextDiscussion" @click="jumpToNextDiscussion"
> >
<icon name="comment-next" /> <icon name="comment-next" />
......
...@@ -177,7 +177,7 @@ export default { ...@@ -177,7 +177,7 @@ export default {
class="note timeline-entry note-wrapper" class="note timeline-entry note-wrapper"
> >
<div class="timeline-entry-inner"> <div class="timeline-entry-inner">
<div class="timeline-icon"> <div v-once class="timeline-icon">
<user-avatar-link <user-avatar-link
:link-href="author.path" :link-href="author.path"
:img-src="author.avatar_url" :img-src="author.avatar_url"
...@@ -190,6 +190,7 @@ export default { ...@@ -190,6 +190,7 @@ export default {
<div class="timeline-content"> <div class="timeline-content">
<div class="note-header"> <div class="note-header">
<note-header <note-header
v-once
:author="author" :author="author"
:created-at="note.created_at" :created-at="note.created_at"
:note-id="note.id" :note-id="note.id"
......
...@@ -22,6 +22,7 @@ export default { ...@@ -22,6 +22,7 @@ export default {
commentForm, commentForm,
placeholderNote, placeholderNote,
placeholderSystemNote, placeholderSystemNote,
skeletonLoadingContainer,
}, },
props: { props: {
noteableData: { noteableData: {
...@@ -59,7 +60,6 @@ export default { ...@@ -59,7 +60,6 @@ export default {
'isNotesFetched', 'isNotesFetched',
'discussions', 'discussions',
'getNotesDataByProp', 'getNotesDataByProp',
'discussionCount',
'isLoading', 'isLoading',
'commentsDisabled', 'commentsDisabled',
]), ]),
...@@ -109,39 +109,22 @@ export default { ...@@ -109,39 +109,22 @@ export default {
this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'))); this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member')));
}, },
methods: { methods: {
...mapActions({ ...mapActions([
setLoadingState: 'setLoadingState', 'setLoadingState',
fetchDiscussions: 'fetchDiscussions', 'fetchDiscussions',
poll: 'poll', 'poll',
actionToggleAward: 'toggleAward', 'toggleAward',
scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', 'scrollToNoteIfNeeded',
setNotesData: 'setNotesData', 'setNotesData',
setNoteableData: 'setNoteableData', 'setNoteableData',
setUserData: 'setUserData', 'setUserData',
setLastFetchedAt: 'setLastFetchedAt', 'setLastFetchedAt',
setTargetNoteHash: 'setTargetNoteHash', 'setTargetNoteHash',
toggleDiscussion: 'toggleDiscussion', 'toggleDiscussion',
setNotesFetchedState: 'setNotesFetchedState', 'setNotesFetchedState',
startTaskList: 'startTaskList', 'expandDiscussion',
}), 'startTaskList',
getComponentName(discussion) { ]),
if (discussion.isSkeletonNote) {
return skeletonLoadingContainer;
}
if (discussion.isPlaceholderNote) {
if (discussion.placeholderType === constants.SYSTEM_NOTE) {
return placeholderSystemNote;
}
return placeholderNote;
} else if (discussion.individual_note) {
return discussion.notes[0].system ? systemNote : noteableNote;
}
return noteableDiscussion;
},
getComponentData(discussion) {
return discussion.individual_note ? { note: discussion.notes[0] } : { discussion };
},
fetchNotes() { fetchNotes() {
if (this.isFetching) return null; if (this.isFetching) return null;
...@@ -181,31 +164,46 @@ export default { ...@@ -181,31 +164,46 @@ export default {
const noteId = hash && hash.replace(/^note_/, ''); const noteId = hash && hash.replace(/^note_/, '');
if (noteId) { if (noteId) {
this.discussions.forEach(discussion => { const discussion = this.discussions.find(d => d.notes.some(({ id }) => id === noteId));
if (discussion.notes) {
discussion.notes.forEach(note => { if (discussion) {
if (`${note.id}` === `${noteId}`) { this.expandDiscussion({ discussionId: discussion.id });
// FIXME: this modifies the store state without using a mutation/action
Object.assign(discussion, { expanded: true });
}
});
} }
});
} }
}, },
}, },
systemNote: constants.SYSTEM_NOTE,
}; };
</script> </script>
<template> <template>
<div v-show="shouldShow" id="notes"> <div v-show="shouldShow" id="notes">
<ul id="notes-list" class="notes main-notes-list timeline"> <ul id="notes-list" class="notes main-notes-list timeline">
<component <template v-for="discussion in allDiscussions">
:is="getComponentName(discussion)" <skeleton-loading-container v-if="discussion.isSkeletonNote" :key="discussion.id" />
v-for="discussion in allDiscussions" <template v-else-if="discussion.isPlaceholderNote">
<placeholder-system-note
v-if="discussion.placeholderType === $options.systemNote"
:key="discussion.id"
:note="discussion.notes[0]"
/>
<placeholder-note v-else :key="discussion.id" :note="discussion.notes[0]" />
</template>
<template v-else-if="discussion.individual_note">
<system-note
v-if="discussion.notes[0].system"
:key="discussion.id"
:note="discussion.notes[0]"
/>
<noteable-note v-else :key="discussion.id" :note="discussion.notes[0]" />
</template>
<noteable-discussion
v-else
:key="discussion.id" :key="discussion.id"
v-bind="getComponentData(discussion)" :discussion="discussion"
:render-diff-file="true"
/> />
</template>
</ul> </ul>
<comment-form <comment-form
......
...@@ -11,7 +11,7 @@ import * as constants from '../constants'; ...@@ -11,7 +11,7 @@ import * as constants from '../constants';
import service from '../services/notes_service'; import service from '../services/notes_service';
import loadAwardsHandler from '../../awards_handler'; import loadAwardsHandler from '../../awards_handler';
import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
import { isInViewport, scrollToElement } from '../../lib/utils/common_utils'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils';
import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub';
import { __ } from '~/locale'; import { __ } from '~/locale';
...@@ -39,12 +39,13 @@ export const setNotesFetchedState = ({ commit }, state) => ...@@ -39,12 +39,13 @@ export const setNotesFetchedState = ({ commit }, state) =>
export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data);
export const fetchDiscussions = ({ commit }, { path, filter }) => export const fetchDiscussions = ({ commit, dispatch }, { path, filter }) =>
service service
.fetchDiscussions(path, filter) .fetchDiscussions(path, filter)
.then(res => res.json()) .then(res => res.json())
.then(discussions => { .then(discussions => {
commit(types.SET_INITIAL_DISCUSSIONS, discussions); commit(types.SET_INITIAL_DISCUSSIONS, discussions);
dispatch('updateResolvableDiscussonsCounts');
}); });
export const updateDiscussion = ({ commit, state }, discussion) => { export const updateDiscussion = ({ commit, state }, discussion) => {
...@@ -53,11 +54,18 @@ export const updateDiscussion = ({ commit, state }, discussion) => { ...@@ -53,11 +54,18 @@ export const updateDiscussion = ({ commit, state }, discussion) => {
return utils.findNoteObjectById(state.discussions, discussion.id); return utils.findNoteObjectById(state.discussions, discussion.id);
}; };
export const deleteNote = ({ commit, dispatch }, note) => export const deleteNote = ({ commit, dispatch, state }, note) =>
service.deleteNote(note.path).then(() => { service.deleteNote(note.path).then(() => {
const discussion = state.discussions.find(({ id }) => id === note.discussion_id);
commit(types.DELETE_NOTE, note); commit(types.DELETE_NOTE, note);
dispatch('updateMergeRequestWidget'); dispatch('updateMergeRequestWidget');
dispatch('updateResolvableDiscussonsCounts');
if (isInMRPage()) {
dispatch('diffs/removeDiscussionsFromDiff', discussion);
}
}); });
export const updateNote = ({ commit, dispatch }, { endpoint, note }) => export const updateNote = ({ commit, dispatch }, { endpoint, note }) =>
...@@ -89,6 +97,7 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) => ...@@ -89,6 +97,7 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) =>
dispatch('updateMergeRequestWidget'); dispatch('updateMergeRequestWidget');
dispatch('startTaskList'); dispatch('startTaskList');
dispatch('updateResolvableDiscussonsCounts');
} }
return res; return res;
}); });
...@@ -104,6 +113,8 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, ...@@ -104,6 +113,8 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved,
commit(mutationType, res); commit(mutationType, res);
dispatch('updateResolvableDiscussonsCounts');
dispatch('updateMergeRequestWidget'); dispatch('updateMergeRequestWidget');
}); });
...@@ -385,5 +396,8 @@ export const startTaskList = ({ dispatch }) => ...@@ -385,5 +396,8 @@ export const startTaskList = ({ dispatch }) =>
}), }),
); );
export const updateResolvableDiscussonsCounts = ({ commit }) =>
commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -53,30 +53,15 @@ export const getCurrentUserLastNote = state => ...@@ -53,30 +53,15 @@ export const getCurrentUserLastNote = state =>
export const getDiscussionLastNote = state => discussion => export const getDiscussionLastNote = state => discussion =>
reverseNotes(discussion.notes).find(el => isLastNote(el, state)); reverseNotes(discussion.notes).find(el => isLastNote(el, state));
export const discussionCount = state => { export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCount;
const filteredDiscussions = state.discussions.filter(n => !n.individual_note && n.resolvable); export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount;
export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions;
return filteredDiscussions.length;
};
export const unresolvedDiscussions = (state, getters) => {
const resolvedMap = getters.resolvedDiscussionsById;
return state.discussions.filter(n => !n.individual_note && !resolvedMap[n.id]);
};
export const allDiscussions = (state, getters) => {
const resolved = getters.resolvedDiscussionsById;
const unresolved = getters.unresolvedDiscussions;
return Object.values(resolved).concat(unresolved);
};
export const isDiscussionResolved = (state, getters) => discussionId => export const isDiscussionResolved = (state, getters) => discussionId =>
getters.resolvedDiscussionsById[discussionId] !== undefined; getters.resolvedDiscussionsById[discussionId] !== undefined;
export const allResolvableDiscussions = (state, getters) => export const allResolvableDiscussions = state =>
getters.allDiscussions.filter(d => !d.individual_note && d.resolvable); state.discussions.filter(d => !d.individual_note && d.resolvable);
export const resolvedDiscussionsById = state => { export const resolvedDiscussionsById = state => {
const map = {}; const map = {};
...@@ -147,15 +132,12 @@ export const resolvedDiscussionCount = (state, getters) => { ...@@ -147,15 +132,12 @@ export const resolvedDiscussionCount = (state, getters) => {
return Object.keys(resolvedMap).length; return Object.keys(resolvedMap).length;
}; };
export const discussionTabCounter = state => { export const discussionTabCounter = state =>
let all = []; state.discussions.reduce(
(acc, discussion) =>
state.discussions.forEach(discussion => { acc + discussion.notes.filter(note => !note.system && !note.placeholder).length,
all = all.concat(discussion.notes.filter(note => !note.system && !note.placeholder)); 0,
}); );
return all.length;
};
// Returns the list of discussion IDs ordered according to given parameter // Returns the list of discussion IDs ordered according to given parameter
// @param {Boolean} diffOrder - is ordered by diff? // @param {Boolean} diffOrder - is ordered by diff?
...@@ -182,8 +164,10 @@ export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, dif ...@@ -182,8 +164,10 @@ export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, dif
export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => { export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => {
const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder); const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder);
const currentIndex = idsOrdered.indexOf(discussionId); const currentIndex = idsOrdered.indexOf(discussionId);
const slicedIds = idsOrdered.slice(currentIndex + 1, currentIndex + 2);
return idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0]; // Get the first ID if there is none after the currentIndex
return slicedIds.length ? idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0] : idsOrdered[0];
}; };
// @param {Boolean} diffOrder - is ordered by diff? // @param {Boolean} diffOrder - is ordered by diff?
......
...@@ -22,6 +22,9 @@ export default () => ({ ...@@ -22,6 +22,9 @@ export default () => ({
current_user: {}, current_user: {},
}, },
commentsDisabled: false, commentsDisabled: false,
resolvableDiscussionsCount: 0,
unresolvedDiscussionsCount: 0,
hasUnresolvedDiscussions: false,
}, },
actions, actions,
getters, getters,
......
...@@ -21,6 +21,7 @@ export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; ...@@ -21,6 +21,7 @@ export const DISABLE_COMMENTS = 'DISABLE_COMMENTS';
export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
export const EXPAND_DISCUSSION = 'EXPAND_DISCUSSION'; export const EXPAND_DISCUSSION = 'EXPAND_DISCUSSION';
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS';
// Issue // Issue
export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const CLOSE_ISSUE = 'CLOSE_ISSUE';
......
...@@ -24,6 +24,7 @@ export default { ...@@ -24,6 +24,7 @@ export default {
noteData.resolved = false; noteData.resolved = false;
noteData.resolve_path = note.resolve_path; noteData.resolve_path = note.resolve_path;
noteData.resolve_with_issue_path = note.resolve_with_issue_path; noteData.resolve_with_issue_path = note.resolve_with_issue_path;
noteData.diff_discussion = false;
} }
state.discussions.push(noteData); state.discussions.push(noteData);
...@@ -97,33 +98,36 @@ export default { ...@@ -97,33 +98,36 @@ export default {
}, },
[types.SET_INITIAL_DISCUSSIONS](state, discussionsData) { [types.SET_INITIAL_DISCUSSIONS](state, discussionsData) {
const discussions = []; const discussions = discussionsData.reduce((acc, d) => {
const discussion = { ...d };
const diffData = {};
discussionsData.forEach(discussion => {
if (discussion.diff_file) { if (discussion.diff_file) {
Object.assign(discussion, { diffData.file_hash = discussion.diff_file.file_hash;
file_hash: discussion.diff_file.file_hash, diffData.truncated_diff_lines = discussion.truncated_diff_lines || [];
truncated_diff_lines: discussion.truncated_diff_lines || [],
});
} }
// To support legacy notes, should be very rare case. // To support legacy notes, should be very rare case.
if (discussion.individual_note && discussion.notes.length > 1) { if (discussion.individual_note && discussion.notes.length > 1) {
discussion.notes.forEach(n => { discussion.notes.forEach(n => {
discussions.push({ acc.push({
...discussion, ...discussion,
...diffData,
notes: [n], // override notes array to only have one item to mimick individual_note notes: [n], // override notes array to only have one item to mimick individual_note
}); });
}); });
} else { } else {
const oldNote = utils.findNoteObjectById(state.discussions, discussion.id); const oldNote = utils.findNoteObjectById(state.discussions, discussion.id);
discussions.push({ acc.push({
...discussion, ...discussion,
...diffData,
expanded: oldNote ? oldNote.expanded : discussion.expanded, expanded: oldNote ? oldNote.expanded : discussion.expanded,
}); });
} }
});
return acc;
}, []);
Object.assign(state, { discussions }); Object.assign(state, { discussions });
}, },
...@@ -195,7 +199,9 @@ export default { ...@@ -195,7 +199,9 @@ export default {
const selectedDiscussion = state.discussions.find(disc => disc.id === note.id); const selectedDiscussion = state.discussions.find(disc => disc.id === note.id);
note.expanded = true; // override expand flag to prevent collapse note.expanded = true; // override expand flag to prevent collapse
if (note.diff_file) { if (note.diff_file) {
Object.assign(note, { file_hash: note.diff_file.file_hash }); Object.assign(note, {
file_hash: note.diff_file.file_hash,
});
} }
Object.assign(selectedDiscussion, { ...note }); Object.assign(selectedDiscussion, { ...note });
}, },
...@@ -229,4 +235,16 @@ export default { ...@@ -229,4 +235,16 @@ export default {
[types.DISABLE_COMMENTS](state, value) { [types.DISABLE_COMMENTS](state, value) {
state.commentsDisabled = value; state.commentsDisabled = value;
}, },
[types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS](state) {
state.resolvableDiscussionsCount = state.discussions.filter(
discussion => !discussion.individual_note && discussion.resolvable,
).length;
state.unresolvedDiscussionsCount = state.discussions.filter(
discussion =>
!discussion.individual_note &&
discussion.resolvable &&
discussion.notes.some(note => !note.resolved),
).length;
state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1;
},
}; };
...@@ -804,6 +804,9 @@ msgstr "" ...@@ -804,6 +804,9 @@ msgstr ""
msgid "Automatically marked as default internal user" msgid "Automatically marked as default internal user"
msgstr "" msgstr ""
msgid "Automatically resolved"
msgstr ""
msgid "Available" msgid "Available"
msgstr "" msgstr ""
...@@ -5459,6 +5462,9 @@ msgstr "" ...@@ -5459,6 +5462,9 @@ msgstr ""
msgid "Resolve discussion" msgid "Resolve discussion"
msgstr "" msgstr ""
msgid "Resolved"
msgstr ""
msgid "Response metrics (AWS ELB)" msgid "Response metrics (AWS ELB)"
msgstr "" msgstr ""
...@@ -7559,6 +7565,11 @@ msgstr "" ...@@ -7559,6 +7565,11 @@ msgstr ""
msgid "disabled" msgid "disabled"
msgstr "" msgstr ""
msgid "discussion resolved"
msgid_plural "discussions resolved"
msgstr[0] ""
msgstr[1] ""
msgid "done" msgid "done"
msgstr "" msgstr ""
......
...@@ -50,7 +50,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do ...@@ -50,7 +50,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
find('.line-resolve-btn').click find('.line-resolve-btn').click
expect(page).to have_selector('.line-resolve-btn.is-active') expect(page).to have_selector('.line-resolve-btn.is-active')
expect(find('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") expect(find('.line-resolve-btn')['aria-label']).to eq("Resolved by #{user.name}")
end end
page.within '.diff-content' do page.within '.diff-content' do
...@@ -243,7 +243,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do ...@@ -243,7 +243,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
resolve_button.click resolve_button.click
wait_for_requests wait_for_requests
expect(resolve_button['data-original-title']).to eq("Resolved by #{user.name}") expect(resolve_button['aria-label']).to eq("Resolved by #{user.name}")
end end
end end
...@@ -266,7 +266,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do ...@@ -266,7 +266,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
wait_for_requests wait_for_requests
expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") expect(first('.line-resolve-btn')['aria-label']).to eq("Resolved by #{user.name}")
end end
expect(page).to have_content('Last updated') expect(page).to have_content('Last updated')
...@@ -285,7 +285,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do ...@@ -285,7 +285,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
wait_for_requests wait_for_requests
resolve_buttons.each do |button| resolve_buttons.each do |button|
expect(button['data-original-title']).to eq("Resolved by #{user.name}") expect(button['aria-label']).to eq("Resolved by #{user.name}")
end end
page.within '.line-resolve-all-container' do page.within '.line-resolve-all-container' do
...@@ -357,13 +357,12 @@ describe 'Merge request > User resolves diff notes and discussions', :js do ...@@ -357,13 +357,12 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
resolve_button.click resolve_button.click
wait_for_requests wait_for_requests
expect(resolve_button['data-original-title']).to eq("Resolved by #{user.name}") expect(resolve_button['aria-label']).to eq("Resolved by #{user.name}")
end end
end end
it 'shows jump to next discussion button, apart from the last one' do it 'shows jump to next discussion button' do
expect(page).to have_selector('.discussion-reply-holder', count: 2) expect(page.all('.discussion-reply-holder', count: 2)).to all(have_selector('.discussion-next-btn'))
expect(page).to have_selector('.discussion-reply-holder .discussion-next-btn', count: 1)
end end
it 'displays next discussion even if hidden' do it 'displays next discussion even if hidden' do
......
...@@ -464,7 +464,11 @@ describe('diff_file_header', () => { ...@@ -464,7 +464,11 @@ describe('diff_file_header', () => {
propsCopy.addMergeRequestButtons = true; propsCopy.addMergeRequestButtons = true;
propsCopy.diffFile.deleted_file = true; propsCopy.diffFile.deleted_file = true;
const discussionGetter = () => [diffDiscussionMock]; const discussionGetter = () => [
{
...diffDiscussionMock,
},
];
const notesModuleMock = notesModule(); const notesModuleMock = notesModule();
notesModuleMock.getters.discussions = discussionGetter; notesModuleMock.getters.discussions = discussionGetter;
vm = mountComponentWithStore(Component, { vm = mountComponentWithStore(Component, {
......
...@@ -62,6 +62,7 @@ describe('DiffLineNoteForm', () => { ...@@ -62,6 +62,7 @@ describe('DiffLineNoteForm', () => {
component.$nextTick(() => { component.$nextTick(() => {
expect(component.cancelCommentForm).toHaveBeenCalledWith({ expect(component.cancelCommentForm).toHaveBeenCalledWith({
lineCode: diffLines[0].line_code, lineCode: diffLines[0].line_code,
fileHash: component.diffFileHash,
}); });
expect(component.resetAutoSave).toHaveBeenCalled(); expect(component.resetAutoSave).toHaveBeenCalled();
......
...@@ -310,13 +310,13 @@ describe('DiffsStoreActions', () => { ...@@ -310,13 +310,13 @@ describe('DiffsStoreActions', () => {
describe('showCommentForm', () => { describe('showCommentForm', () => {
it('should call mutation to show comment form', done => { it('should call mutation to show comment form', done => {
const payload = { lineCode: 'lineCode' }; const payload = { lineCode: 'lineCode', fileHash: 'hash' };
testAction( testAction(
showCommentForm, showCommentForm,
payload, payload,
{}, {},
[{ type: types.ADD_COMMENT_FORM_LINE, payload }], [{ type: types.TOGGLE_LINE_HAS_FORM, payload: { ...payload, hasForm: true } }],
[], [],
done, done,
); );
...@@ -325,13 +325,13 @@ describe('DiffsStoreActions', () => { ...@@ -325,13 +325,13 @@ describe('DiffsStoreActions', () => {
describe('cancelCommentForm', () => { describe('cancelCommentForm', () => {
it('should call mutation to cancel comment form', done => { it('should call mutation to cancel comment form', done => {
const payload = { lineCode: 'lineCode' }; const payload = { lineCode: 'lineCode', fileHash: 'hash' };
testAction( testAction(
cancelCommentForm, cancelCommentForm,
payload, payload,
{}, {},
[{ type: types.REMOVE_COMMENT_FORM_LINE, payload }], [{ type: types.TOGGLE_LINE_HAS_FORM, payload: { ...payload, hasForm: false } }],
[], [],
done, done,
); );
......
...@@ -186,77 +186,6 @@ describe('Diffs Module Getters', () => { ...@@ -186,77 +186,6 @@ describe('Diffs Module Getters', () => {
}); });
}); });
describe('shouldRenderParallelCommentRow', () => {
let line;
beforeEach(() => {
line = {};
discussionMock.expanded = true;
line.left = {
line_code: 'ABC',
discussions: [discussionMock],
};
line.right = {
line_code: 'DEF',
discussions: [discussionMock1],
};
});
it('returns true when discussion is expanded', () => {
expect(getters.shouldRenderParallelCommentRow(localState)(line)).toEqual(true);
});
it('returns false when no discussion was found', () => {
line.left.discussions = [];
line.right.discussions = [];
localState.diffLineCommentForms.ABC = false;
localState.diffLineCommentForms.DEF = false;
expect(getters.shouldRenderParallelCommentRow(localState)(line)).toEqual(false);
});
it('returns true when discussionForm was found', () => {
localState.diffLineCommentForms.ABC = {};
expect(getters.shouldRenderParallelCommentRow(localState)(line)).toEqual(true);
});
});
describe('shouldRenderInlineCommentRow', () => {
let line;
beforeEach(() => {
discussionMock.expanded = true;
line = {
lineCode: 'ABC',
discussions: [discussionMock],
};
});
it('returns true when diffLineCommentForms has form', () => {
localState.diffLineCommentForms.ABC = {};
expect(getters.shouldRenderInlineCommentRow(localState)(line)).toEqual(true);
});
it('returns false when no line discussions were found', () => {
line.discussions = [];
expect(getters.shouldRenderInlineCommentRow(localState)(line)).toEqual(false);
});
it('returns true if all found discussions are expanded', () => {
discussionMock.expanded = true;
expect(getters.shouldRenderInlineCommentRow(localState)(line)).toEqual(true);
});
});
describe('getDiffFileDiscussions', () => { describe('getDiffFileDiscussions', () => {
it('returns an array with discussions when fileHash matches and the discussion belongs to a diff', () => { it('returns an array with discussions when fileHash matches and the discussion belongs to a diff', () => {
discussionMock.diff_file.file_hash = diffFileMock.file_hash; discussionMock.diff_file.file_hash = diffFileMock.file_hash;
......
...@@ -55,32 +55,6 @@ describe('DiffsStoreMutations', () => { ...@@ -55,32 +55,6 @@ describe('DiffsStoreMutations', () => {
}); });
}); });
describe('ADD_COMMENT_FORM_LINE', () => {
it('should set a truthy reference for the given line code in diffLineCommentForms', () => {
const state = { diffLineCommentForms: {} };
const lineCode = 'FDE';
mutations[types.ADD_COMMENT_FORM_LINE](state, { lineCode });
expect(state.diffLineCommentForms[lineCode]).toBeTruthy();
});
});
describe('REMOVE_COMMENT_FORM_LINE', () => {
it('should remove given reference from diffLineCommentForms', () => {
const state = { diffLineCommentForms: {} };
const lineCode = 'FDE';
mutations[types.ADD_COMMENT_FORM_LINE](state, { lineCode });
expect(state.diffLineCommentForms[lineCode]).toBeTruthy();
mutations[types.REMOVE_COMMENT_FORM_LINE](state, { lineCode });
expect(state.diffLineCommentForms[lineCode]).toBeUndefined();
});
});
describe('EXPAND_ALL_FILES', () => { describe('EXPAND_ALL_FILES', () => {
it('should change the collapsed prop from diffFiles', () => { it('should change the collapsed prop from diffFiles', () => {
const diffFile = { const diffFile = {
...@@ -98,7 +72,9 @@ describe('DiffsStoreMutations', () => { ...@@ -98,7 +72,9 @@ describe('DiffsStoreMutations', () => {
it('should call utils.addContextLines with proper params', () => { it('should call utils.addContextLines with proper params', () => {
const options = { const options = {
lineNumbers: { oldLineNumber: 1, newLineNumber: 2 }, lineNumbers: { oldLineNumber: 1, newLineNumber: 2 },
contextLines: [{ old_line: 1, new_line: 1, line_code: 'ff9200_1_1', discussions: [] }], contextLines: [
{ old_line: 1, new_line: 1, line_code: 'ff9200_1_1', discussions: [], hasForm: false },
],
fileHash: 'ff9200', fileHash: 'ff9200',
params: { params: {
bottom: true, bottom: true,
...@@ -383,4 +359,35 @@ describe('DiffsStoreMutations', () => { ...@@ -383,4 +359,35 @@ describe('DiffsStoreMutations', () => {
expect(state.currentDiffFileId).toBe('somefileid'); expect(state.currentDiffFileId).toBe('somefileid');
}); });
}); });
describe('TOGGLE_LINE_HAS_FORM', () => {
it('sets hasForm on lines', () => {
const file = {
file_hash: 'hash',
parallel_diff_lines: [
{ left: { line_code: '123', hasForm: false }, right: {} },
{ left: {}, right: { line_code: '124', hasForm: false } },
],
highlighted_diff_lines: [
{ line_code: '123', hasForm: false },
{ line_code: '124', hasForm: false },
],
};
const state = {
diffFiles: [file],
};
mutations[types.TOGGLE_LINE_HAS_FORM](state, {
lineCode: '123',
hasForm: true,
fileHash: 'hash',
});
expect(file.highlighted_diff_lines[0].hasForm).toBe(true);
expect(file.highlighted_diff_lines[1].hasForm).toBe(false);
expect(file.parallel_diff_lines[0].left.hasForm).toBe(true);
expect(file.parallel_diff_lines[1].right.hasForm).toBe(false);
});
});
}); });
...@@ -17,7 +17,7 @@ describe('diff_with_note', () => { ...@@ -17,7 +17,7 @@ describe('diff_with_note', () => {
}; };
const selectors = { const selectors = {
get container() { get container() {
return vm.$refs.fileHolder; return vm.$el;
}, },
get diffTable() { get diffTable() {
return this.container.querySelector('.diff-content table'); return this.container.querySelector('.diff-content table');
...@@ -70,7 +70,6 @@ describe('diff_with_note', () => { ...@@ -70,7 +70,6 @@ describe('diff_with_note', () => {
it('shows image diff', () => { it('shows image diff', () => {
vm = mountComponentWithStore(Component, { props, store }); vm = mountComponentWithStore(Component, { props, store });
expect(selectors.container).toHaveClass('js-image-file');
expect(selectors.diffTable).not.toExist(); expect(selectors.diffTable).not.toExist();
}); });
}); });
......
...@@ -80,43 +80,6 @@ describe('noteable_discussion component', () => { ...@@ -80,43 +80,6 @@ describe('noteable_discussion component', () => {
}); });
describe('computed', () => { describe('computed', () => {
describe('hasMultipleUnresolvedDiscussions', () => {
it('is false if there are no unresolved discussions', done => {
spyOnProperty(vm, 'unresolvedDiscussions').and.returnValue([]);
Vue.nextTick()
.then(() => {
expect(vm.hasMultipleUnresolvedDiscussions).toBe(false);
})
.then(done)
.catch(done.fail);
});
it('is false if there is one unresolved discussion', done => {
spyOnProperty(vm, 'unresolvedDiscussions').and.returnValue([discussionMock]);
Vue.nextTick()
.then(() => {
expect(vm.hasMultipleUnresolvedDiscussions).toBe(false);
})
.then(done)
.catch(done.fail);
});
it('is true if there are two unresolved discussions', done => {
const discussion = getJSONFixture(discussionWithTwoUnresolvedNotes)[0];
discussion.notes[0].resolved = false;
vm.$store.dispatch('setInitialNotes', [discussion, discussion]);
Vue.nextTick()
.then(() => {
expect(vm.hasMultipleUnresolvedDiscussions).toBe(true);
})
.then(done)
.catch(done.fail);
});
});
describe('isRepliesCollapsed', () => { describe('isRepliesCollapsed', () => {
it('should return false for diff discussions', done => { it('should return false for diff discussions', done => {
const diffDiscussion = getJSONFixture(diffDiscussionFixture)[0]; const diffDiscussion = getJSONFixture(diffDiscussionFixture)[0];
......
import Vue from 'vue'; import Vue from 'vue';
import $ from 'jquery';
import _ from 'underscore'; import _ from 'underscore';
import { headersInterceptor } from 'spec/helpers/vue_resource_helper'; import { headersInterceptor } from 'spec/helpers/vue_resource_helper';
import * as actions from '~/notes/stores/actions'; import * as actions from '~/notes/stores/actions';
...@@ -330,10 +331,14 @@ describe('Actions Notes Store', () => { ...@@ -330,10 +331,14 @@ describe('Actions Notes Store', () => {
beforeEach(() => { beforeEach(() => {
Vue.http.interceptors.push(interceptor); Vue.http.interceptors.push(interceptor);
$('body').attr('data-page', '');
}); });
afterEach(() => { afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
$('body').attr('data-page', '');
}); });
it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', done => { it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', done => {
...@@ -353,6 +358,39 @@ describe('Actions Notes Store', () => { ...@@ -353,6 +358,39 @@ describe('Actions Notes Store', () => {
{ {
type: 'updateMergeRequestWidget', type: 'updateMergeRequestWidget',
}, },
{
type: 'updateResolvableDiscussonsCounts',
},
],
done,
);
});
it('dispatches removeDiscussionsFromDiff on merge request page', done => {
const note = { path: `${gl.TEST_HOST}`, id: 1 };
$('body').attr('data-page', 'projects:merge_requests:show');
testAction(
actions.deleteNote,
note,
store.state,
[
{
type: 'DELETE_NOTE',
payload: note,
},
],
[
{
type: 'updateMergeRequestWidget',
},
{
type: 'updateResolvableDiscussonsCounts',
},
{
type: 'diffs/removeDiscussionsFromDiff',
},
], ],
done, done,
); );
...@@ -399,6 +437,9 @@ describe('Actions Notes Store', () => { ...@@ -399,6 +437,9 @@ describe('Actions Notes Store', () => {
{ {
type: 'startTaskList', type: 'startTaskList',
}, },
{
type: 'updateResolvableDiscussonsCounts',
},
], ],
done, done,
); );
...@@ -471,6 +512,9 @@ describe('Actions Notes Store', () => { ...@@ -471,6 +512,9 @@ describe('Actions Notes Store', () => {
}, },
], ],
[ [
{
type: 'updateResolvableDiscussonsCounts',
},
{ {
type: 'updateMergeRequestWidget', type: 'updateMergeRequestWidget',
}, },
...@@ -493,6 +537,9 @@ describe('Actions Notes Store', () => { ...@@ -493,6 +537,9 @@ describe('Actions Notes Store', () => {
}, },
], ],
[ [
{
type: 'updateResolvableDiscussonsCounts',
},
{ {
type: 'updateMergeRequestWidget', type: 'updateMergeRequestWidget',
}, },
...@@ -525,4 +572,17 @@ describe('Actions Notes Store', () => { ...@@ -525,4 +572,17 @@ describe('Actions Notes Store', () => {
); );
}); });
}); });
describe('updateResolvableDiscussonsCounts', () => {
it('commits UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', done => {
testAction(
actions.updateResolvableDiscussonsCounts,
null,
{},
[{ type: 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS' }],
[],
done,
);
});
});
}); });
...@@ -117,17 +117,15 @@ describe('Getters Notes Store', () => { ...@@ -117,17 +117,15 @@ describe('Getters Notes Store', () => {
describe('allResolvableDiscussions', () => { describe('allResolvableDiscussions', () => {
it('should return only resolvable discussions in same order', () => { it('should return only resolvable discussions in same order', () => {
const localGetters = { state.discussions = [
allDiscussions: [
discussion3, discussion3,
unresolvableDiscussion, unresolvableDiscussion,
discussion1, discussion1,
unresolvableDiscussion, unresolvableDiscussion,
discussion2, discussion2,
], ];
};
expect(getters.allResolvableDiscussions(state, localGetters)).toEqual([ expect(getters.allResolvableDiscussions(state)).toEqual([
discussion3, discussion3,
discussion1, discussion1,
discussion2, discussion2,
...@@ -135,11 +133,9 @@ describe('Getters Notes Store', () => { ...@@ -135,11 +133,9 @@ describe('Getters Notes Store', () => {
}); });
it('should return empty array if there are no resolvable discussions', () => { it('should return empty array if there are no resolvable discussions', () => {
const localGetters = { state.discussions = [unresolvableDiscussion, unresolvableDiscussion];
allDiscussions: [unresolvableDiscussion, unresolvableDiscussion],
};
expect(getters.allResolvableDiscussions(state, localGetters)).toEqual([]); expect(getters.allResolvableDiscussions(state)).toEqual([]);
}); });
}); });
...@@ -236,7 +232,7 @@ describe('Getters Notes Store', () => { ...@@ -236,7 +232,7 @@ describe('Getters Notes Store', () => {
it('should return the ID of the discussion after the ID provided', () => { it('should return the ID of the discussion after the ID provided', () => {
expect(getters.nextUnresolvedDiscussionId(state, localGetters)('123')).toBe('456'); expect(getters.nextUnresolvedDiscussionId(state, localGetters)('123')).toBe('456');
expect(getters.nextUnresolvedDiscussionId(state, localGetters)('456')).toBe('789'); expect(getters.nextUnresolvedDiscussionId(state, localGetters)('456')).toBe('789');
expect(getters.nextUnresolvedDiscussionId(state, localGetters)('789')).toBe(undefined); expect(getters.nextUnresolvedDiscussionId(state, localGetters)('789')).toBe('123');
}); });
}); });
......
...@@ -437,4 +437,51 @@ describe('Notes Store mutations', () => { ...@@ -437,4 +437,51 @@ describe('Notes Store mutations', () => {
expect(state.commentsDisabled).toEqual(true); expect(state.commentsDisabled).toEqual(true);
}); });
}); });
describe('UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', () => {
it('updates resolvableDiscussionsCount', () => {
const state = {
discussions: [
{ individual_note: false, resolvable: true, notes: [] },
{ individual_note: true, resolvable: true, notes: [] },
{ individual_note: false, resolvable: false, notes: [] },
],
resolvableDiscussionsCount: 0,
};
mutations.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS(state);
expect(state.resolvableDiscussionsCount).toBe(1);
});
it('updates unresolvedDiscussionsCount', () => {
const state = {
discussions: [
{ individual_note: false, resolvable: true, notes: [{ resolved: false }] },
{ individual_note: true, resolvable: true, notes: [{ resolved: false }] },
{ individual_note: false, resolvable: false, notes: [{ resolved: false }] },
],
unresolvedDiscussionsCount: 0,
};
mutations.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS(state);
expect(state.unresolvedDiscussionsCount).toBe(1);
});
it('updates hasUnresolvedDiscussions', () => {
const state = {
discussions: [
{ individual_note: false, resolvable: true, notes: [{ resolved: false }] },
{ individual_note: false, resolvable: true, notes: [{ resolved: false }] },
{ individual_note: false, resolvable: false, notes: [{ resolved: false }] },
],
hasUnresolvedDiscussions: 0,
};
mutations.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS(state);
expect(state.hasUnresolvedDiscussions).toBe(true);
});
});
}); });
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