Commit d6391c65 authored by Phil Hughes's avatar Phil Hughes

Merge branch '32452-multiple-discussions' into 'master'

Resolve "Multiple discussions per line in merge request diffs"

Closes #32452

See merge request gitlab-org/gitlab-ce!28748
parents fd547ee4 d4151b14
<script>
import { mapGetters } from 'vuex';
import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
name: 'DiffDiscussionReply',
components: {
NoteSignedOutWidget,
ReplyPlaceholder,
UserAvatarLink,
},
props: {
hasForm: {
type: Boolean,
required: false,
default: false,
},
renderReplyPlaceholder: {
type: Boolean,
required: true,
},
},
computed: {
...mapGetters({
currentUser: 'getUserData',
userCanReply: 'userCanReply',
}),
},
};
</script>
<template>
<div class="discussion-reply-holder d-flex clearfix">
<template v-if="userCanReply">
<slot v-if="hasForm" name="form"></slot>
<template v-else-if="renderReplyPlaceholder">
<user-avatar-link
:link-href="currentUser.path"
:img-src="currentUser.avatar_url"
:img-alt="currentUser.name"
:img-size="40"
class="d-none d-sm-block"
/>
<reply-placeholder
class="qa-discussion-reply"
:button-text="__('Start a new discussion...')"
@onClick="$emit('showNewDiscussionForm')"
/>
</template>
</template>
<note-signed-out-widget v-else />
</div>
</template>
...@@ -80,7 +80,6 @@ export default { ...@@ -80,7 +80,6 @@ export default {
v-show="isExpanded(discussion)" v-show="isExpanded(discussion)"
:discussion="discussion" :discussion="discussion"
:render-diff-file="false" :render-diff-file="false"
:always-expanded="true"
:discussions-by-diff-order="true" :discussions-by-diff-order="true"
:line="line" :line="line"
:help-page-path="helpPagePath" :help-page-path="helpPagePath"
......
...@@ -151,7 +151,11 @@ export default { ...@@ -151,7 +151,11 @@ export default {
stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false); stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false);
}, },
methods: { methods: {
...mapActions('diffs', ['toggleFileDiscussions', 'toggleFullDiff']), ...mapActions('diffs', [
'toggleFileDiscussions',
'toggleFileDiscussionWrappers',
'toggleFullDiff',
]),
handleToggleFile(e, checkTarget) { handleToggleFile(e, checkTarget) {
if ( if (
!checkTarget || !checkTarget ||
...@@ -165,7 +169,7 @@ export default { ...@@ -165,7 +169,7 @@ export default {
this.$emit('showForkMessage'); this.$emit('showForkMessage');
}, },
handleToggleDiscussions() { handleToggleDiscussions() {
this.toggleFileDiscussions(this.diffFile); this.toggleFileDiscussionWrappers(this.diffFile);
}, },
handleFileNameClick(e) { handleFileNameClick(e) {
const isLinkToOtherPage = const isLinkToOtherPage =
......
<script> <script>
import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { pluralize, truncate } from '~/lib/utils/text_utility'; import { pluralize, truncate } from '~/lib/utils/text_utility';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
...@@ -19,11 +18,13 @@ export default { ...@@ -19,11 +18,13 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
discussionsExpanded: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
discussionsExpanded() {
return this.discussions.every(discussion => discussion.expanded);
},
allDiscussions() { allDiscussions() {
return this.discussions.reduce((acc, note) => acc.concat(note.notes), []); return this.discussions.reduce((acc, note) => acc.concat(note.notes), []);
}, },
...@@ -45,26 +46,14 @@ export default { ...@@ -45,26 +46,14 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['toggleDiscussion']),
getTooltipText(noteData) { getTooltipText(noteData) {
let { note } = noteData; let { note } = noteData;
if (note.length > LENGTH_OF_AVATAR_TOOLTIP) { if (note.length > LENGTH_OF_AVATAR_TOOLTIP) {
note = truncate(note, LENGTH_OF_AVATAR_TOOLTIP); note = truncate(note, LENGTH_OF_AVATAR_TOOLTIP);
} }
return `${noteData.author.name}: ${note}`; return `${noteData.author.name}: ${note}`;
}, },
toggleDiscussions() {
const forceExpanded = this.discussions.some(discussion => !discussion.expanded);
this.discussions.forEach(discussion => {
this.toggleDiscussion({
discussionId: discussion.id,
forceExpanded,
});
});
},
}, },
}; };
</script> </script>
...@@ -76,7 +65,7 @@ export default { ...@@ -76,7 +65,7 @@ export default {
type="button" type="button"
:aria-label="__('Show comments')" :aria-label="__('Show comments')"
class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button" class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button"
@click="toggleDiscussions" @click="$emit('toggleLineDiscussions')"
> >
<icon :size="12" name="collapse" /> <icon :size="12" name="collapse" />
</button> </button>
...@@ -87,7 +76,7 @@ export default { ...@@ -87,7 +76,7 @@ export default {
:img-src="note.author.avatar_url" :img-src="note.author.avatar_url"
:tooltip-text="getTooltipText(note)" :tooltip-text="getTooltipText(note)"
class="diff-comment-avatar js-diff-comment-avatar" class="diff-comment-avatar js-diff-comment-avatar"
@click.native="toggleDiscussions" @click.native="$emit('toggleLineDiscussions')"
/> />
<span <span
v-if="moreText" v-if="moreText"
...@@ -97,7 +86,7 @@ export default { ...@@ -97,7 +86,7 @@ export default {
data-container="body" data-container="body"
data-placement="top" data-placement="top"
role="button" role="button"
@click="toggleDiscussions" @click="$emit('toggleLineDiscussions')"
>+{{ moreCount }}</span >+{{ moreCount }}</span
> >
</template> </template>
......
...@@ -105,7 +105,13 @@ export default { ...@@ -105,7 +105,13 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']), ...mapActions('diffs', [
'loadMoreLines',
'showCommentForm',
'setHighlightedRow',
'toggleLineDiscussions',
'toggleLineDiscussionWrappers',
]),
handleCommentButton() { handleCommentButton() {
this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash }); this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
}, },
...@@ -184,7 +190,14 @@ export default { ...@@ -184,7 +190,14 @@ export default {
@click="setHighlightedRow(lineCode)" @click="setHighlightedRow(lineCode)"
> >
</a> </a>
<diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" /> <diff-gutter-avatars
v-if="shouldShowAvatarsOnGutter"
:discussions="line.discussions"
:discussions-expanded="line.discussionsExpanded"
@toggleLineDiscussions="
toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded })
"
/>
</template> </template>
</div> </div>
</template> </template>
<script> <script>
import diffDiscussions from './diff_discussions.vue'; import { mapActions } from 'vuex';
import diffLineNoteForm from './diff_line_note_form.vue'; import DiffDiscussions from './diff_discussions.vue';
import DiffLineNoteForm from './diff_line_note_form.vue';
import DiffDiscussionReply from './diff_discussion_reply.vue';
export default { export default {
components: { components: {
diffDiscussions, DiffDiscussions,
diffLineNoteForm, DiffLineNoteForm,
DiffDiscussionReply,
}, },
props: { props: {
line: { line: {
...@@ -32,10 +35,12 @@ export default { ...@@ -32,10 +35,12 @@ export default {
if (!this.line.discussions || !this.line.discussions.length) { if (!this.line.discussions || !this.line.discussions.length) {
return false; return false;
} }
return this.line.discussionsExpanded;
return this.line.discussions.every(discussion => discussion.expanded);
}, },
}, },
methods: {
...mapActions('diffs', ['showCommentForm']),
},
}; };
</script> </script>
...@@ -49,13 +54,22 @@ export default { ...@@ -49,13 +54,22 @@ export default {
:discussions="line.discussions" :discussions="line.discussions"
:help-page-path="helpPagePath" :help-page-path="helpPagePath"
/> />
<diff-line-note-form <diff-discussion-reply
v-if="line.hasForm" :has-form="line.hasForm"
:diff-file-hash="diffFileHash" :render-reply-placeholder="Boolean(line.discussions.length)"
:line="line" @showNewDiscussionForm="
:note-target-line="line" showCommentForm({ lineCode: line.line_code, fileHash: diffFileHash })
:help-page-path="helpPagePath" "
/> >
<template #form>
<diff-line-note-form
:diff-file-hash="diffFileHash"
:line="line"
:note-target-line="line"
:help-page-path="helpPagePath"
/>
</template>
</diff-discussion-reply>
</div> </div>
</td> </td>
</tr> </tr>
......
<script> <script>
import diffDiscussions from './diff_discussions.vue'; import { mapActions } from 'vuex';
import diffLineNoteForm from './diff_line_note_form.vue'; import DiffDiscussions from './diff_discussions.vue';
import DiffLineNoteForm from './diff_line_note_form.vue';
import DiffDiscussionReply from './diff_discussion_reply.vue';
export default { export default {
components: { components: {
diffDiscussions, DiffDiscussions,
diffLineNoteForm, DiffLineNoteForm,
DiffDiscussionReply,
}, },
props: { props: {
line: { line: {
...@@ -29,24 +32,30 @@ export default { ...@@ -29,24 +32,30 @@ export default {
computed: { computed: {
hasExpandedDiscussionOnLeft() { hasExpandedDiscussionOnLeft() {
return this.line.left && this.line.left.discussions.length return this.line.left && this.line.left.discussions.length
? this.line.left.discussions.every(discussion => discussion.expanded) ? this.line.left.discussionsExpanded
: false; : false;
}, },
hasExpandedDiscussionOnRight() { hasExpandedDiscussionOnRight() {
return this.line.right && this.line.right.discussions.length return this.line.right && this.line.right.discussions.length
? this.line.right.discussions.every(discussion => discussion.expanded) ? this.line.right.discussionsExpanded
: false; : false;
}, },
hasAnyExpandedDiscussion() { hasAnyExpandedDiscussion() {
return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight; return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight;
}, },
shouldRenderDiscussionsOnLeft() { shouldRenderDiscussionsOnLeft() {
return this.line.left && this.line.left.discussions && this.hasExpandedDiscussionOnLeft; return (
this.line.left &&
this.line.left.discussions &&
this.line.left.discussions.length &&
this.hasExpandedDiscussionOnLeft
);
}, },
shouldRenderDiscussionsOnRight() { shouldRenderDiscussionsOnRight() {
return ( return (
this.line.right && this.line.right &&
this.line.right.discussions && this.line.right.discussions &&
this.line.right.discussions.length &&
this.hasExpandedDiscussionOnRight && this.hasExpandedDiscussionOnRight &&
this.line.right.type this.line.right.type
); );
...@@ -81,6 +90,22 @@ export default { ...@@ -81,6 +90,22 @@ export default {
return hasCommentFormOnLeft || hasCommentFormOnRight; return hasCommentFormOnLeft || hasCommentFormOnRight;
}, },
shouldRenderReplyPlaceholderOnLeft() {
return Boolean(
this.line.left && this.line.left.discussions && this.line.left.discussions.length,
);
},
shouldRenderReplyPlaceholderOnRight() {
return Boolean(
this.line.right && this.line.right.discussions && this.line.right.discussions.length,
);
},
},
methods: {
...mapActions('diffs', ['showCommentForm']),
showNewDiscussionForm() {
this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.diffFileHash });
},
}, },
}; };
</script> </script>
...@@ -90,37 +115,49 @@ export default { ...@@ -90,37 +115,49 @@ export default {
<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
v-if="line.left.discussions.length"
:discussions="line.left.discussions" :discussions="line.left.discussions"
:line="line.left" :line="line.left"
:help-page-path="helpPagePath" :help-page-path="helpPagePath"
/> />
</div> </div>
<diff-line-note-form <diff-discussion-reply
v-if="showLeftSideCommentForm" :has-form="showLeftSideCommentForm"
:diff-file-hash="diffFileHash" :render-reply-placeholder="shouldRenderReplyPlaceholderOnLeft"
:line="line.left" @showNewDiscussionForm="showNewDiscussionForm"
:note-target-line="line.left" >
:help-page-path="helpPagePath" <template #form>
line-position="left" <diff-line-note-form
/> :diff-file-hash="diffFileHash"
:line="line.left"
:note-target-line="line.left"
:help-page-path="helpPagePath"
line-position="left"
/>
</template>
</diff-discussion-reply>
</td> </td>
<td class="notes-content parallel new" colspan="2"> <td class="notes-content parallel new" colspan="2">
<div v-if="shouldRenderDiscussionsOnRight" class="content"> <div v-if="shouldRenderDiscussionsOnRight" class="content">
<diff-discussions <diff-discussions
v-if="line.right.discussions.length"
:discussions="line.right.discussions" :discussions="line.right.discussions"
:line="line.right" :line="line.right"
:help-page-path="helpPagePath" :help-page-path="helpPagePath"
/> />
</div> </div>
<diff-line-note-form <diff-discussion-reply
v-if="showRightSideCommentForm" :has-form="showRightSideCommentForm"
:diff-file-hash="diffFileHash" :render-reply-placeholder="shouldRenderReplyPlaceholderOnRight"
:line="line.right" @showNewDiscussionForm="showNewDiscussionForm"
:note-target-line="line.right" >
line-position="right" <template #form>
/> <diff-line-note-form
:diff-file-hash="diffFileHash"
:line="line.right"
:note-target-line="line.right"
line-position="right"
/>
</template>
</diff-discussion-reply>
</td> </td>
</tr> </tr>
</template> </template>
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
getNoteFormData, getNoteFormData,
convertExpandLines, convertExpandLines,
idleCallback, idleCallback,
allDiscussionWrappersExpanded,
} from './utils'; } from './utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { import {
...@@ -79,6 +80,7 @@ export const assignDiscussionsToDiff = ( ...@@ -79,6 +80,7 @@ export const assignDiscussionsToDiff = (
discussions = rootState.notes.discussions, discussions = rootState.notes.discussions,
) => { ) => {
const diffPositionByLineCode = getDiffPositionByLineCode(state.diffFiles); const diffPositionByLineCode = getDiffPositionByLineCode(state.diffFiles);
const hash = getLocationHash();
discussions discussions
.filter(discussion => discussion.diff_discussion) .filter(discussion => discussion.diff_discussion)
...@@ -86,6 +88,7 @@ export const assignDiscussionsToDiff = ( ...@@ -86,6 +88,7 @@ export const assignDiscussionsToDiff = (
commit(types.SET_LINE_DISCUSSIONS_FOR_FILE, { commit(types.SET_LINE_DISCUSSIONS_FOR_FILE, {
discussion, discussion,
diffPositionByLineCode, diffPositionByLineCode,
hash,
}); });
}); });
...@@ -99,6 +102,10 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => { ...@@ -99,6 +102,10 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id }); commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id });
}; };
export const toggleLineDiscussions = ({ commit }, options) => {
commit(types.TOGGLE_LINE_DISCUSSIONS, options);
};
export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => { export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => {
const discussion = rootState.notes.discussions.find(d => d.id === discussionId); const discussion = rootState.notes.discussions.find(d => d.id === discussionId);
...@@ -257,6 +264,31 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => { ...@@ -257,6 +264,31 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => {
}); });
}; };
export const toggleFileDiscussionWrappers = ({ commit }, diff) => {
const discussionWrappersExpanded = allDiscussionWrappersExpanded(diff);
let linesWithDiscussions;
if (diff.highlighted_diff_lines) {
linesWithDiscussions = diff.highlighted_diff_lines.filter(line => line.discussions.length);
}
if (diff.parallel_diff_lines) {
linesWithDiscussions = diff.parallel_diff_lines.filter(
line =>
(line.left && line.left.discussions.length) ||
(line.right && line.right.discussions.length),
);
}
if (linesWithDiscussions.length) {
linesWithDiscussions.forEach(line => {
commit(types.TOGGLE_LINE_DISCUSSIONS, {
fileHash: diff.file_hash,
lineCode: line.line_code,
expanded: !discussionWrappersExpanded,
});
});
}
};
export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => { export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => {
const postData = getNoteFormData({ const postData = getNoteFormData({
commit: state.commit, commit: state.commit,
......
...@@ -35,3 +35,5 @@ export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINE ...@@ -35,3 +35,5 @@ export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINE
export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE'; export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE';
export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER'; export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER';
export const TOGGLE_LINE_DISCUSSIONS = 'TOGGLE_LINE_DISCUSSIONS';
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
addContextLines, addContextLines,
prepareDiffData, prepareDiffData,
isDiscussionApplicableToLine, isDiscussionApplicableToLine,
updateLineInFile,
} from './utils'; } from './utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
...@@ -109,7 +110,7 @@ export default { ...@@ -109,7 +110,7 @@ export default {
})); }));
}, },
[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode }) { [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) {
const { latestDiff } = state; const { latestDiff } = state;
const discussionLineCode = discussion.line_code; const discussionLineCode = discussion.line_code;
...@@ -130,13 +131,27 @@ export default { ...@@ -130,13 +131,27 @@ export default {
: [], : [],
}); });
const setDiscussionsExpanded = line => {
const isLineNoteTargeted = line.discussions.some(
disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`),
);
return {
...line,
discussionsExpanded:
line.discussions && line.discussions.length
? line.discussions.some(disc => !disc.resolved) || isLineNoteTargeted
: false,
};
};
state.diffFiles = state.diffFiles.map(diffFile => { state.diffFiles = state.diffFiles.map(diffFile => {
if (diffFile.file_hash === fileHash) { if (diffFile.file_hash === fileHash) {
const file = { ...diffFile }; const file = { ...diffFile };
if (file.highlighted_diff_lines) { if (file.highlighted_diff_lines) {
file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => file.highlighted_diff_lines = file.highlighted_diff_lines.map(line =>
lineCheck(line) ? mapDiscussions(line) : line, setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line),
); );
} }
...@@ -148,8 +163,10 @@ export default { ...@@ -148,8 +163,10 @@ export default {
if (left || right) { if (left || right) {
return { return {
...line, ...line,
left: line.left ? mapDiscussions(line.left) : null, left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null,
right: line.right ? mapDiscussions(line.right, () => !left) : null, right: line.right
? setDiscussionsExpanded(mapDiscussions(line.right, () => !left))
: null,
}; };
} }
...@@ -173,32 +190,11 @@ export default { ...@@ -173,32 +190,11 @@ export default {
[types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) { [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) {
const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash); const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
if (selectedFile) { if (selectedFile) {
if (selectedFile.parallel_diff_lines) { updateLineInFile(selectedFile, lineCode, line =>
const targetLine = selectedFile.parallel_diff_lines.find( Object.assign(line, {
line => discussions: line.discussions.filter(discussion => discussion.notes.length),
(line.left && line.left.line_code === lineCode) || }),
(line.right && line.right.line_code === lineCode), );
);
if (targetLine) {
const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
Object.assign(targetLine[side], {
discussions: targetLine[side].discussions.filter(discussion => discussion.notes.length),
});
}
}
if (selectedFile.highlighted_diff_lines) {
const targetInlineLine = selectedFile.highlighted_diff_lines.find(
line => line.line_code === lineCode,
);
if (targetInlineLine) {
Object.assign(targetInlineLine, {
discussions: targetInlineLine.discussions.filter(discussion => discussion.notes.length),
});
}
}
if (selectedFile.discussions && selectedFile.discussions.length) { if (selectedFile.discussions && selectedFile.discussions.length) {
selectedFile.discussions = selectedFile.discussions.filter( selectedFile.discussions = selectedFile.discussions.filter(
...@@ -207,6 +203,15 @@ export default { ...@@ -207,6 +203,15 @@ export default {
} }
} }
}, },
[types.TOGGLE_LINE_DISCUSSIONS](state, { fileHash, lineCode, expanded }) {
const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
updateLineInFile(selectedFile, lineCode, line =>
Object.assign(line, { discussionsExpanded: expanded }),
);
},
[types.TOGGLE_FOLDER_OPEN](state, path) { [types.TOGGLE_FOLDER_OPEN](state, path) {
state.treeEntries[path].opened = !state.treeEntries[path].opened; state.treeEntries[path].opened = !state.treeEntries[path].opened;
}, },
......
...@@ -454,3 +454,48 @@ export const convertExpandLines = ({ ...@@ -454,3 +454,48 @@ export const convertExpandLines = ({
}; };
export const idleCallback = cb => requestIdleCallback(cb); export const idleCallback = cb => requestIdleCallback(cb);
export const updateLineInFile = (selectedFile, lineCode, updateFn) => {
if (selectedFile.parallel_diff_lines) {
const targetLine = selectedFile.parallel_diff_lines.find(
line =>
(line.left && line.left.line_code === lineCode) ||
(line.right && line.right.line_code === lineCode),
);
if (targetLine) {
const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
updateFn(targetLine[side]);
}
}
if (selectedFile.highlighted_diff_lines) {
const targetInlineLine = selectedFile.highlighted_diff_lines.find(
line => line.line_code === lineCode,
);
if (targetInlineLine) {
updateFn(targetInlineLine);
}
}
};
export const allDiscussionWrappersExpanded = diff => {
const discussionsExpandedArray = [];
if (diff.parallel_diff_lines) {
diff.parallel_diff_lines.forEach(line => {
if (line.left && line.left.discussions.length) {
discussionsExpandedArray.push(line.left.discussionsExpanded);
}
if (line.right && line.right.discussions.length) {
discussionsExpandedArray.push(line.right.discussionsExpanded);
}
});
} else if (diff.highlighted_diff_lines) {
diff.parallel_diff_lines.forEach(line => {
if (line.discussions.length) {
discussionsExpandedArray.push(line.discussionsExpanded);
}
});
}
return discussionsExpandedArray.every(el => el);
};
...@@ -39,15 +39,23 @@ export default { ...@@ -39,15 +39,23 @@ export default {
</script> </script>
<template> <template>
<div class="discussion-with-resolve-btn clearfix"> <div class="discussion-with-resolve-btn">
<reply-placeholder class="qa-discussion-reply" @onClick="$emit('showReplyForm')" /> <reply-placeholder
:button-text="s__('MergeRequests|Reply...')"
<div class="btn-group discussion-actions" role="group"> class="qa-discussion-reply"
<resolve-discussion-button @onClick="$emit('showReplyForm')"
v-if="discussion.resolvable" />
:is-resolving="isResolving" <resolve-discussion-button
:button-title="resolveButtonTitle" v-if="discussion.resolvable"
@onClick="$emit('resolve')" :is-resolving="isResolving"
:button-title="resolveButtonTitle"
@onClick="$emit('resolve')"
/>
<div v-if="discussion.resolvable" class="btn-group discussion-actions ml-sm-2" role="group">
<resolve-with-issue-button v-if="resolveWithIssuePath" :url="resolveWithIssuePath" />
<jump-to-next-discussion-button
v-if="shouldShowJumpToNextDiscussion"
@onClick="$emit('jumpToNextDiscussion')"
/> />
<resolve-with-issue-button <resolve-with-issue-button
v-if="discussion.resolvable && resolveWithIssuePath" v-if="discussion.resolvable && resolveWithIssuePath"
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import { SYSTEM_NOTE } from '../constants'; import { SYSTEM_NOTE } from '../constants';
import { __ } from '~/locale'; import { __ } from '~/locale';
import NoteableNote from './noteable_note.vue'; import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
import PlaceholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
import PlaceholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import SystemNote from '~/vue_shared/components/notes/system_note.vue'; import SystemNote from '~/vue_shared/components/notes/system_note.vue';
import NoteableNote from './noteable_note.vue';
import ToggleRepliesWidget from './toggle_replies_widget.vue'; import ToggleRepliesWidget from './toggle_replies_widget.vue';
import NoteEditedText from './note_edited_text.vue'; import NoteEditedText from './note_edited_text.vue';
...@@ -72,6 +72,7 @@ export default { ...@@ -72,6 +72,7 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['toggleDiscussion']),
componentName(note) { componentName(note) {
if (note.isPlaceholderNote) { if (note.isPlaceholderNote) {
if (note.placeholderType === SYSTEM_NOTE) { if (note.placeholderType === SYSTEM_NOTE) {
...@@ -101,7 +102,7 @@ export default { ...@@ -101,7 +102,7 @@ export default {
<component <component
:is="componentName(firstNote)" :is="componentName(firstNote)"
:note="componentData(firstNote)" :note="componentData(firstNote)"
:line="line" :line="line || diffLine"
:commit="commit" :commit="commit"
:help-page-path="helpPagePath" :help-page-path="helpPagePath"
:show-reply-button="userCanReply" :show-reply-button="userCanReply"
...@@ -118,23 +119,29 @@ export default { ...@@ -118,23 +119,29 @@ export default {
/> />
<slot slot="avatar-badge" name="avatar-badge"></slot> <slot slot="avatar-badge" name="avatar-badge"></slot>
</component> </component>
<toggle-replies-widget <div
v-if="hasReplies" :class="discussion.diff_discussion ? 'discussion-collapsible bordered-box clearfix' : ''"
:collapsed="!isExpanded" >
:replies="replies" <toggle-replies-widget
@toggle="$emit('toggleDiscussion')" v-if="hasReplies"
/> :collapsed="!isExpanded"
<template v-if="isExpanded"> :replies="replies"
<component :class="{ 'discussion-toggle-replies': discussion.diff_discussion }"
:is="componentName(note)" @toggle="toggleDiscussion({ discussionId: discussion.id })"
v-for="note in replies"
:key="note.id"
:note="componentData(note)"
:help-page-path="helpPagePath"
:line="line"
@handleDeleteNote="$emit('deleteNote')"
/> />
</template> <template v-if="isExpanded">
<component
:is="componentName(note)"
v-for="note in replies"
:key="note.id"
:note="componentData(note)"
:help-page-path="helpPagePath"
:line="line"
@handleDeleteNote="$emit('deleteNote')"
/>
</template>
<slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
</div>
</template> </template>
<template v-else> <template v-else>
<component <component
...@@ -148,8 +155,8 @@ export default { ...@@ -148,8 +155,8 @@ export default {
> >
<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>
<slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
</template> </template>
</ul> </ul>
<slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
name: 'ReplyPlaceholder', name: 'ReplyPlaceholder',
props: {
buttonText: {
type: String,
required: true,
},
},
}; };
</script> </script>
...@@ -12,6 +18,6 @@ export default { ...@@ -12,6 +18,6 @@ export default {
:title="s__('MergeRequests|Add a reply')" :title="s__('MergeRequests|Add a reply')"
@click="$emit('onClick')" @click="$emit('onClick')"
> >
{{ s__('MergeRequests|Reply...') }} {{ buttonText }}
</button> </button>
</template> </template>
...@@ -132,7 +132,7 @@ export default { ...@@ -132,7 +132,7 @@ export default {
return this.discussion.diff_discussion && this.renderDiffFile; return this.discussion.diff_discussion && this.renderDiffFile;
}, },
shouldGroupReplies() { shouldGroupReplies() {
return !this.shouldRenderDiffs && !this.discussion.diff_discussion; return !this.shouldRenderDiffs;
}, },
wrapperComponent() { wrapperComponent() {
return this.shouldRenderDiffs ? diffWithNote : 'div'; return this.shouldRenderDiffs ? diffWithNote : 'div';
...@@ -250,6 +250,11 @@ export default { ...@@ -250,6 +250,11 @@ export default {
clearDraft(this.autosaveKey); clearDraft(this.autosaveKey);
}, },
saveReply(noteText, form, callback) { saveReply(noteText, form, callback) {
if (!noteText) {
this.cancelReplyForm();
callback();
return;
}
const postData = { const postData = {
in_reply_to_discussion_id: this.discussion.reply_id, in_reply_to_discussion_id: this.discussion.reply_id,
target_type: this.getNoteableData.targetType, target_type: this.getNoteableData.targetType,
...@@ -363,7 +368,6 @@ Please check your network connection and try again.`; ...@@ -363,7 +368,6 @@ Please check your network connection and try again.`;
:line="line" :line="line"
:should-group-replies="shouldGroupReplies" :should-group-replies="shouldGroupReplies"
@startReplying="showReplyForm" @startReplying="showReplyForm"
@toggleDiscussion="toggleDiscussionHandler"
@deleteNote="deleteNoteHandler" @deleteNote="deleteNoteHandler"
> >
<slot slot="avatar-badge" name="avatar-badge"></slot> <slot slot="avatar-badge" name="avatar-badge"></slot>
...@@ -376,7 +380,7 @@ Please check your network connection and try again.`; ...@@ -376,7 +380,7 @@ Please check your network connection and try again.`;
<div <div
v-else-if="showReplies" v-else-if="showReplies"
:class="{ 'is-replying': isReplying }" :class="{ 'is-replying': isReplying }"
class="discussion-reply-holder" class="discussion-reply-holder clearfix"
> >
<user-avatar-link <user-avatar-link
v-if="!isReplying && userCanReply" v-if="!isReplying && userCanReply"
......
...@@ -39,7 +39,7 @@ export default { ...@@ -39,7 +39,7 @@ export default {
</script> </script>
<template> <template>
<timeline-entry-item class="note being-posted fade-in-half"> <timeline-entry-item class="note note-wrapper being-posted fade-in-half">
<div class="timeline-icon"> <div class="timeline-icon">
<user-avatar-link <user-avatar-link
:link-href="getUserData.path" :link-href="getUserData.path"
......
...@@ -1093,6 +1093,17 @@ table.code { ...@@ -1093,6 +1093,17 @@ table.code {
line-height: 0; line-height: 0;
} }
.discussion-collapsible {
margin: 0 $gl-padding $gl-padding 71px;
}
.parallel {
.discussion-collapsible {
margin: $gl-padding;
margin-top: 0;
}
}
@media (max-width: map-get($grid-breakpoints, md)-1) { @media (max-width: map-get($grid-breakpoints, md)-1) {
.diffs .files { .diffs .files {
@include fixed-width-container; @include fixed-width-container;
...@@ -1110,6 +1121,11 @@ table.code { ...@@ -1110,6 +1121,11 @@ table.code {
padding-right: 0; padding-right: 0;
} }
} }
.discussion-collapsible {
margin: $gl-padding;
margin-top: 0;
}
} }
.image-diff-overlay, .image-diff-overlay,
......
...@@ -134,6 +134,16 @@ $note-form-margin-left: 72px; ...@@ -134,6 +134,16 @@ $note-form-margin-left: 72px;
} }
} }
.discussion-toggle-replies {
border-top: 0;
border-radius: 4px 4px 0 0;
&.collapsed {
border: 0;
border-radius: 4px;
}
}
.note-created-ago, .note-created-ago,
.note-updated-at { .note-updated-at {
white-space: normal; white-space: normal;
...@@ -462,6 +472,14 @@ $note-form-margin-left: 72px; ...@@ -462,6 +472,14 @@ $note-form-margin-left: 72px;
position: relative; position: relative;
} }
.notes-content .discussion-notes.diff-discussions {
border-bottom: 1px solid $border-color;
&:nth-last-child(1) {
border-bottom: 0;
}
}
.notes_holder { .notes_holder {
font-family: $regular-font; font-family: $regular-font;
...@@ -517,6 +535,17 @@ $note-form-margin-left: 72px; ...@@ -517,6 +535,17 @@ $note-form-margin-left: 72px;
.discussion-reply-holder { .discussion-reply-holder {
border-radius: 0 0 $border-radius-default $border-radius-default; border-radius: 0 0 $border-radius-default $border-radius-default;
position: relative; position: relative;
.discussion-form {
width: 100%;
background-color: $gray-light;
padding: 0;
}
.disabled-comment {
padding: $gl-vert-padding 0;
width: 100%;
}
} }
} }
......
---
title: Resolve Multiple discussions per line in merge request diffs
merge_request: 28748
author:
type: added
---
title: Implement borderless discussion design with new reply field
merge_request: 28580
author:
type: added
...@@ -9827,6 +9827,9 @@ msgstr "" ...@@ -9827,6 +9827,9 @@ msgstr ""
msgid "Start a %{new_merge_request} with these changes" msgid "Start a %{new_merge_request} with these changes"
msgstr "" msgstr ""
msgid "Start a new discussion..."
msgstr ""
msgid "Start a new merge request" msgid "Start a new merge request"
msgstr "" msgstr ""
......
...@@ -54,7 +54,7 @@ describe 'Resolve an open discussion in a merge request by creating an issue', : ...@@ -54,7 +54,7 @@ describe 'Resolve an open discussion in a merge request by creating an issue', :
context 'creating the issue' do context 'creating the issue' do
before do before do
find(resolve_discussion_selector).click find(resolve_discussion_selector, match: :first).click
end end
it 'has a hidden field for the discussion' do it 'has a hidden field for the discussion' do
......
...@@ -368,8 +368,8 @@ describe 'Merge request > User resolves diff notes and discussions', :js do ...@@ -368,8 +368,8 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
all_discussion_replies = page.all('.discussion-reply-holder') all_discussion_replies = page.all('.discussion-reply-holder')
expect(all_discussion_replies.count).to eq(2) expect(all_discussion_replies.count).to eq(2)
expect(all_discussion_replies.first.all('.discussion-next-btn').count).to eq(1) expect(all_discussion_replies.first.all('.discussion-next-btn').count).to eq(2)
expect(all_discussion_replies.last.all('.discussion-next-btn').count).to eq(1) expect(all_discussion_replies.last.all('.discussion-next-btn').count).to eq(2)
end end
it 'displays next discussion even if hidden' do it 'displays next discussion even if hidden' do
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import DiffDiscussionReply from '~/diffs/components/diff_discussion_reply.vue';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('DiffDiscussionReply', () => {
let wrapper;
let getters;
let store;
const createComponent = (props = {}, slots = {}) => {
wrapper = shallowMount(DiffDiscussionReply, {
store,
localVue,
sync: false,
propsData: {
...props,
},
slots: {
...slots,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('if user can reply', () => {
beforeEach(() => {
getters = {
userCanReply: () => true,
getUserData: () => ({
path: 'test-path',
avatar_url: 'avatar_url',
name: 'John Doe',
}),
};
store = new Vuex.Store({
getters,
});
});
it('should render a form if component has form', () => {
createComponent(
{
renderReplyPlaceholder: false,
hasForm: true,
},
{
form: `<div id="test-form"></div>`,
},
);
expect(wrapper.find('#test-form').exists()).toBe(true);
});
it('should render a reply placeholder if there is no form', () => {
createComponent({
renderReplyPlaceholder: true,
hasForm: false,
});
expect(wrapper.find(ReplyPlaceholder).exists()).toBe(true);
});
});
it('renders a signed out widget when user is not logged in', () => {
getters = {
userCanReply: () => false,
getUserData: () => null,
};
store = new Vuex.Store({
getters,
});
createComponent({
renderReplyPlaceholder: false,
hasForm: false,
});
expect(wrapper.find(NoteSignedOutWidget).exists()).toBe(true);
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue';
import discussionsMockData from '../mock_data/diff_discussions';
const localVue = createLocalVue();
const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)];
describe('DiffGutterAvatars', () => {
let wrapper;
const findCollapseButton = () => wrapper.find('.diff-notes-collapse');
const findMoreCount = () => wrapper.find('.diff-comments-more-count');
const findUserAvatars = () => wrapper.findAll('.diff-comment-avatar');
const createComponent = (props = {}) => {
wrapper = shallowMount(DiffGutterAvatars, {
localVue,
sync: false,
propsData: {
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when expanded', () => {
beforeEach(() => {
createComponent({
discussions: getDiscussionsMockData(),
discussionsExpanded: true,
});
});
it('renders a collapse button when discussions are expanded', () => {
expect(findCollapseButton().exists()).toBe(true);
});
it('should emit toggleDiscussions event on button click', () => {
findCollapseButton().trigger('click');
expect(wrapper.emitted().toggleLineDiscussions).toBeTruthy();
});
});
describe('when collapsed', () => {
beforeEach(() => {
createComponent({
discussions: getDiscussionsMockData(),
discussionsExpanded: false,
});
});
it('renders user avatars and moreCount text', () => {
expect(findUserAvatars().exists()).toBe(true);
expect(findMoreCount().exists()).toBe(true);
});
it('renders correct amount of user avatars', () => {
expect(findUserAvatars().length).toBe(3);
});
it('renders correct moreCount number', () => {
expect(findMoreCount().text()).toBe('+2');
});
it('should emit toggleDiscussions event on avatars click', () => {
findUserAvatars()
.at(0)
.trigger('click');
expect(wrapper.emitted().toggleLineDiscussions).toBeTruthy();
});
it('should emit toggleDiscussions event on more count text click', () => {
findMoreCount().trigger('click');
expect(wrapper.emitted().toggleLineDiscussions).toBeTruthy();
});
});
it('renders an empty more count string if there are no discussions', () => {
createComponent({
discussions: [],
discussionsExpanded: false,
});
expect(findMoreCount().exists()).toBe(false);
});
describe('tooltip text', () => {
beforeEach(() => {
createComponent({
discussions: getDiscussionsMockData(),
discussionsExpanded: false,
});
});
it('returns original comment if it is shorter than max length', () => {
const note = wrapper.vm.discussions[0].notes[0];
expect(wrapper.vm.getTooltipText(note)).toEqual('Administrator: comment 1');
});
it('returns truncated version of comment if it is longer than max length', () => {
const note = wrapper.vm.discussions[0].notes[1];
expect(wrapper.vm.getTooltipText(note)).toEqual('Fatih Acet: comment 2 is r...');
});
});
});
export default {
id: '6b232e05bea388c6b043ccc243ba505faac04ea8',
reply_id: '6b232e05bea388c6b043ccc243ba505faac04ea8',
position: {
old_line: null,
new_line: 2,
old_path: 'CHANGELOG',
new_path: 'CHANGELOG',
base_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a',
start_sha: 'd9eaefe5a676b820c57ff18cf5b68316025f7962',
head_sha: 'c48ee0d1bf3b30453f5b32250ce03134beaa6d13',
},
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2',
expanded: true,
notes: [
{
id: '1749',
type: 'DiffNote',
attachment: null,
author: {
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
path: '/root',
},
created_at: '2018-04-03T21:06:21.521Z',
updated_at: '2018-04-08T08:50:41.762Z',
system: false,
noteable_id: 51,
noteable_type: 'MergeRequest',
noteable_iid: 20,
human_access: 'Owner',
note: 'comment 1',
note_html: '<p dir="auto">comment 1</p>',
last_edited_at: '2018-04-08T08:50:41.762Z',
last_edited_by: {
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
path: '/root',
},
current_user: {
can_edit: true,
can_award_emoji: true,
},
resolved: false,
resolvable: true,
resolved_by: null,
discussion_id: '6b232e05bea388c6b043ccc243ba505faac04ea8',
emoji_awardable: true,
award_emoji: [],
toggle_award_path: '/gitlab-org/gitlab-test/notes/1749/toggle_award_emoji',
report_abuse_path:
'/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-test%2Fmerge_requests%2F20%23note_1749&user_id=1',
path: '/gitlab-org/gitlab-test/notes/1749',
noteable_note_url: 'http://localhost:3000/gitlab-org/gitlab-test/merge_requests/20#note_1749',
resolve_path:
'/gitlab-org/gitlab-test/merge_requests/20/discussions/6b232e05bea388c6b043ccc243ba505faac04ea8/resolve',
resolve_with_issue_path:
'/gitlab-org/gitlab-test/issues/new?discussion_to_resolve=6b232e05bea388c6b043ccc243ba505faac04ea8&merge_request_to_resolve_discussions_of=20',
},
{
id: '1753',
type: 'DiffNote',
attachment: null,
author: {
id: 1,
name: 'Fatih Acet',
username: 'fatihacet',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
path: '/fatihacevt',
},
created_at: '2018-04-08T08:49:35.804Z',
updated_at: '2018-04-08T08:50:45.915Z',
system: false,
noteable_id: 51,
noteable_type: 'MergeRequest',
noteable_iid: 20,
human_access: 'Owner',
note: 'comment 2 is really long one',
note_html: '<p dir="auto">comment 2 is really long one</p>',
last_edited_at: '2018-04-08T08:50:45.915Z',
last_edited_by: {
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
path: '/root',
},
current_user: {
can_edit: true,
can_award_emoji: true,
},
resolved: false,
resolvable: true,
resolved_by: null,
discussion_id: '6b232e05bea388c6b043ccc243ba505faac04ea8',
emoji_awardable: true,
award_emoji: [],
toggle_award_path: '/gitlab-org/gitlab-test/notes/1753/toggle_award_emoji',
report_abuse_path:
'/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-test%2Fmerge_requests%2F20%23note_1753&user_id=1',
path: '/gitlab-org/gitlab-test/notes/1753',
noteable_note_url: 'http://localhost:3000/gitlab-org/gitlab-test/merge_requests/20#note_1753',
resolve_path:
'/gitlab-org/gitlab-test/merge_requests/20/discussions/6b232e05bea388c6b043ccc243ba505faac04ea8/resolve',
resolve_with_issue_path:
'/gitlab-org/gitlab-test/issues/new?discussion_to_resolve=6b232e05bea388c6b043ccc243ba505faac04ea8&merge_request_to_resolve_discussions_of=20',
},
{
id: '1754',
type: 'DiffNote',
attachment: null,
author: {
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
path: '/root',
},
created_at: '2018-04-08T08:50:48.294Z',
updated_at: '2018-04-08T08:50:48.294Z',
system: false,
noteable_id: 51,
noteable_type: 'MergeRequest',
noteable_iid: 20,
human_access: 'Owner',
note: 'comment 3',
note_html: '<p dir="auto">comment 3</p>',
current_user: {
can_edit: true,
can_award_emoji: true,
},
resolved: false,
resolvable: true,
resolved_by: null,
discussion_id: '6b232e05bea388c6b043ccc243ba505faac04ea8',
emoji_awardable: true,
award_emoji: [],
toggle_award_path: '/gitlab-org/gitlab-test/notes/1754/toggle_award_emoji',
report_abuse_path:
'/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-test%2Fmerge_requests%2F20%23note_1754&user_id=1',
path: '/gitlab-org/gitlab-test/notes/1754',
noteable_note_url: 'http://localhost:3000/gitlab-org/gitlab-test/merge_requests/20#note_1754',
resolve_path:
'/gitlab-org/gitlab-test/merge_requests/20/discussions/6b232e05bea388c6b043ccc243ba505faac04ea8/resolve',
resolve_with_issue_path:
'/gitlab-org/gitlab-test/issues/new?discussion_to_resolve=6b232e05bea388c6b043ccc243ba505faac04ea8&merge_request_to_resolve_discussions_of=20',
},
{
id: '1755',
type: 'DiffNote',
attachment: null,
author: {
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
path: '/root',
},
created_at: '2018-04-08T08:50:50.911Z',
updated_at: '2018-04-08T08:50:50.911Z',
system: false,
noteable_id: 51,
noteable_type: 'MergeRequest',
noteable_iid: 20,
human_access: 'Owner',
note: 'comment 4',
note_html: '<p dir="auto">comment 4</p>',
current_user: {
can_edit: true,
can_award_emoji: true,
},
resolved: false,
resolvable: true,
resolved_by: null,
discussion_id: '6b232e05bea388c6b043ccc243ba505faac04ea8',
emoji_awardable: true,
award_emoji: [],
toggle_award_path: '/gitlab-org/gitlab-test/notes/1755/toggle_award_emoji',
report_abuse_path:
'/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-test%2Fmerge_requests%2F20%23note_1755&user_id=1',
path: '/gitlab-org/gitlab-test/notes/1755',
noteable_note_url: 'http://localhost:3000/gitlab-org/gitlab-test/merge_requests/20#note_1755',
resolve_path:
'/gitlab-org/gitlab-test/merge_requests/20/discussions/6b232e05bea388c6b043ccc243ba505faac04ea8/resolve',
resolve_with_issue_path:
'/gitlab-org/gitlab-test/issues/new?discussion_to_resolve=6b232e05bea388c6b043ccc243ba505faac04ea8&merge_request_to_resolve_discussions_of=20',
},
{
id: '1756',
type: 'DiffNote',
attachment: null,
author: {
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
path: '/root',
},
created_at: '2018-04-08T08:50:53.895Z',
updated_at: '2018-04-08T08:50:53.895Z',
system: false,
noteable_id: 51,
noteable_type: 'MergeRequest',
noteable_iid: 20,
human_access: 'Owner',
note: 'comment 5',
note_html: '<p dir="auto">comment 5</p>',
current_user: {
can_edit: true,
can_award_emoji: true,
},
resolved: false,
resolvable: true,
resolved_by: null,
discussion_id: '6b232e05bea388c6b043ccc243ba505faac04ea8',
emoji_awardable: true,
award_emoji: [],
toggle_award_path: '/gitlab-org/gitlab-test/notes/1756/toggle_award_emoji',
report_abuse_path:
'/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-test%2Fmerge_requests%2F20%23note_1756&user_id=1',
path: '/gitlab-org/gitlab-test/notes/1756',
noteable_note_url: 'http://localhost:3000/gitlab-org/gitlab-test/merge_requests/20#note_1756',
resolve_path:
'/gitlab-org/gitlab-test/merge_requests/20/discussions/6b232e05bea388c6b043ccc243ba505faac04ea8/resolve',
resolve_with_issue_path:
'/gitlab-org/gitlab-test/issues/new?discussion_to_resolve=6b232e05bea388c6b043ccc243ba505faac04ea8&merge_request_to_resolve_discussions_of=20',
},
],
individual_note: false,
resolvable: true,
resolved: false,
resolve_path:
'/gitlab-org/gitlab-test/merge_requests/20/discussions/6b232e05bea388c6b043ccc243ba505faac04ea8/resolve',
resolve_with_issue_path:
'/gitlab-org/gitlab-test/issues/new?discussion_to_resolve=6b232e05bea388c6b043ccc243ba505faac04ea8&merge_request_to_resolve_discussions_of=20',
diff_file: {
submodule: false,
submodule_link: null,
blob: {
id: '9e10516ca50788acf18c518a231914a21e5f16f7',
path: 'CHANGELOG',
name: 'CHANGELOG',
mode: '100644',
readable_text: true,
icon: 'file-text-o',
},
blob_path: 'CHANGELOG',
blob_name: 'CHANGELOG',
blob_icon: '<i aria-hidden="true" data-hidden="true" class="fa fa-file-text-o fa-fw"></i>',
file_hash: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a',
file_path: 'CHANGELOG.rb',
new_file: false,
deleted_file: false,
renamed_file: false,
old_path: 'CHANGELOG',
new_path: 'CHANGELOG',
mode_changed: false,
a_mode: '100644',
b_mode: '100644',
text: true,
added_lines: 2,
removed_lines: 0,
diff_refs: {
base_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a',
start_sha: 'd9eaefe5a676b820c57ff18cf5b68316025f7962',
head_sha: 'c48ee0d1bf3b30453f5b32250ce03134beaa6d13',
},
content_sha: 'c48ee0d1bf3b30453f5b32250ce03134beaa6d13',
stored_externally: null,
external_storage: null,
old_path_html: 'CHANGELOG_OLD',
new_path_html: 'CHANGELOG',
is_fully_expanded: true,
context_lines_path:
'/gitlab-org/gitlab-test/blob/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG/diff',
highlighted_diff_lines: [
{
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1',
type: 'new',
old_line: null,
new_line: 1,
text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
rich_text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
meta_data: null,
},
{
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2',
type: 'new',
old_line: null,
new_line: 2,
text: '<span id="LC2" class="line" lang="plaintext"></span>\n',
rich_text: '<span id="LC2" class="line" lang="plaintext"></span>\n',
meta_data: null,
},
{
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3',
type: null,
old_line: 1,
new_line: 3,
text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
meta_data: null,
},
{
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_2_4',
type: null,
old_line: 2,
new_line: 4,
text: '<span id="LC4" class="line" lang="plaintext"></span>\n',
rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n',
meta_data: null,
},
{
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_3_5',
type: null,
old_line: 3,
new_line: 5,
text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
rich_text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
meta_data: null,
},
{
line_code: null,
type: 'match',
old_line: null,
new_line: null,
text: '',
rich_text: '',
meta_data: {
old_pos: 3,
new_pos: 5,
},
},
{
line_code: null,
type: 'match',
old_line: null,
new_line: null,
text: '',
rich_text: '',
meta_data: {
old_pos: 3,
new_pos: 5,
},
},
{
line_code: null,
type: 'match',
old_line: null,
new_line: null,
text: '',
rich_text: '',
meta_data: {
old_pos: 3,
new_pos: 5,
},
},
],
parallel_diff_lines: [
{
left: null,
right: {
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1',
type: 'new',
old_line: null,
new_line: 1,
text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
rich_text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
meta_data: null,
},
},
{
left: null,
right: {
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2',
type: 'new',
old_line: null,
new_line: 2,
text: '<span id="LC2" class="line" lang="plaintext"></span>\n',
rich_text: '<span id="LC2" class="line" lang="plaintext"></span>\n',
meta_data: null,
},
},
{
left: {
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3',
type: null,
old_line: 1,
new_line: 3,
text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
meta_data: null,
},
right: {
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3',
type: null,
old_line: 1,
new_line: 3,
text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n',
meta_data: null,
},
},
{
left: {
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_2_4',
type: null,
old_line: 2,
new_line: 4,
text: '<span id="LC4" class="line" lang="plaintext"></span>\n',
rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n',
meta_data: null,
},
right: {
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_2_4',
type: null,
old_line: 2,
new_line: 4,
text: '<span id="LC4" class="line" lang="plaintext"></span>\n',
rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n',
meta_data: null,
},
},
{
left: {
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_3_5',
type: null,
old_line: 3,
new_line: 5,
text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
rich_text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
meta_data: null,
},
right: {
line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_3_5',
type: null,
old_line: 3,
new_line: 5,
text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
rich_text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n',
meta_data: null,
},
},
{
left: {
line_code: null,
type: 'match',
old_line: null,
new_line: null,
text: '',
rich_text: '',
meta_data: {
old_pos: 3,
new_pos: 5,
},
},
right: {
line_code: null,
type: 'match',
old_line: null,
new_line: null,
text: '',
rich_text: '',
meta_data: {
old_pos: 3,
new_pos: 5,
},
},
},
],
viewer: {
name: 'text',
error: null,
},
},
diff_discussion: true,
truncated_diff_lines: [
{
text: 'line',
rich_text:
'<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="1">\n1\n</td>\n<td class="line_content new"><span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n</td>\n</tr>\n<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="2">\n2\n</td>\n<td class="line_content new"><span id="LC2" class="line" lang="plaintext"></span>\n</td>\n</tr>\n',
can_receive_suggestion: true,
line_code: '6f209374f7e565f771b95720abf46024c41d1885_1_1',
type: 'new',
old_line: null,
new_line: 1,
meta_data: null,
},
],
};
export const imageDiffDiscussions = [
{
id: '1',
position: {
x: 10,
y: 10,
width: 100,
height: 200,
},
},
{
id: '2',
position: {
x: 5,
y: 5,
width: 100,
height: 200,
},
},
];
...@@ -87,7 +87,7 @@ describe('DiscussionNotes', () => { ...@@ -87,7 +87,7 @@ describe('DiscussionNotes', () => {
discussion.notes[0], discussion.notes[0],
]; ];
discussion.notes = notesData; discussion.notes = notesData;
createComponent({ discussion }); createComponent({ discussion, shouldRenderDiffs: true });
const notes = wrapper.findAll('.notes > li'); const notes = wrapper.findAll('.notes > li');
expect(notes.at(0).is(PlaceholderSystemNote)).toBe(true); expect(notes.at(0).is(PlaceholderSystemNote)).toBe(true);
......
...@@ -2,13 +2,19 @@ import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vu ...@@ -2,13 +2,19 @@ import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vu
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
const localVue = createLocalVue(); const localVue = createLocalVue();
const buttonText = 'Test Button Text';
describe('ReplyPlaceholder', () => { describe('ReplyPlaceholder', () => {
let wrapper; let wrapper;
const findButton = () => wrapper.find({ ref: 'button' });
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(ReplyPlaceholder, { wrapper = shallowMount(ReplyPlaceholder, {
localVue, localVue,
propsData: {
buttonText,
},
}); });
}); });
...@@ -17,9 +23,7 @@ describe('ReplyPlaceholder', () => { ...@@ -17,9 +23,7 @@ describe('ReplyPlaceholder', () => {
}); });
it('emits onClick even on button click', () => { it('emits onClick even on button click', () => {
const button = wrapper.find({ ref: 'button' }); findButton().trigger('click');
button.trigger('click');
expect(wrapper.emitted()).toEqual({ expect(wrapper.emitted()).toEqual({
onClick: [[]], onClick: [[]],
...@@ -27,8 +31,6 @@ describe('ReplyPlaceholder', () => { ...@@ -27,8 +31,6 @@ describe('ReplyPlaceholder', () => {
}); });
it('should render reply button', () => { it('should render reply button', () => {
const button = wrapper.find({ ref: 'button' }); expect(findButton().text()).toEqual(buttonText);
expect(button.text()).toEqual('Reply...');
}); });
}); });
...@@ -521,7 +521,7 @@ describe('diff_file_header', () => { ...@@ -521,7 +521,7 @@ describe('diff_file_header', () => {
}); });
describe('with discussions', () => { describe('with discussions', () => {
it('dispatches toggleFileDiscussions when user clicks on toggle discussions button', () => { it('dispatches toggleFileDiscussionWrappers when user clicks on toggle discussions button', () => {
const propsCopy = Object.assign({}, props); const propsCopy = Object.assign({}, props);
propsCopy.diffFile.submodule = false; propsCopy.diffFile.submodule = false;
propsCopy.diffFile.blob = { propsCopy.diffFile.blob = {
...@@ -552,11 +552,11 @@ describe('diff_file_header', () => { ...@@ -552,11 +552,11 @@ describe('diff_file_header', () => {
}), }),
}); });
spyOn(vm, 'toggleFileDiscussions'); spyOn(vm, 'toggleFileDiscussionWrappers');
vm.$el.querySelector('.js-btn-vue-toggle-comments').click(); vm.$el.querySelector('.js-btn-vue-toggle-comments').click();
expect(vm.toggleFileDiscussions).toHaveBeenCalled(); expect(vm.toggleFileDiscussionWrappers).toHaveBeenCalled();
}); });
}); });
}); });
......
import Vue from 'vue';
import DiffGutterAvatarsComponent from '~/diffs/components/diff_gutter_avatars.vue';
import { COUNT_OF_AVATARS_IN_GUTTER } from '~/diffs/constants';
import store from '~/mr_notes/stores';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import discussionsMockData from '../mock_data/diff_discussions';
describe('DiffGutterAvatars', () => {
let component;
const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)];
beforeEach(() => {
component = createComponentWithStore(Vue.extend(DiffGutterAvatarsComponent), store, {
discussions: getDiscussionsMockData(),
}).$mount();
});
describe('computed', () => {
describe('discussionsExpanded', () => {
it('should return true when all discussions are expanded', () => {
expect(component.discussionsExpanded).toEqual(true);
});
it('should return false when all discussions are not expanded', () => {
component.discussions[0].expanded = false;
expect(component.discussionsExpanded).toEqual(false);
});
});
describe('allDiscussions', () => {
it('should return an array of notes', () => {
expect(component.allDiscussions).toEqual([...component.discussions[0].notes]);
});
});
describe('notesInGutter', () => {
it('should return a subset of discussions to show in gutter', () => {
expect(component.notesInGutter.length).toEqual(COUNT_OF_AVATARS_IN_GUTTER);
expect(component.notesInGutter[0]).toEqual({
note: component.discussions[0].notes[0].note,
author: component.discussions[0].notes[0].author,
});
});
});
describe('moreCount', () => {
it('should return count of remaining discussions from gutter', () => {
expect(component.moreCount).toEqual(2);
});
});
describe('moreText', () => {
it('should return proper text if moreCount > 0', () => {
expect(component.moreText).toEqual('2 more comments');
});
it('should return empty string if there is no discussion', () => {
component.discussions = [];
expect(component.moreText).toEqual('');
});
});
});
describe('methods', () => {
describe('getTooltipText', () => {
it('should return original comment if it is shorter than max length', () => {
const note = component.discussions[0].notes[0];
expect(component.getTooltipText(note)).toEqual('Administrator: comment 1');
});
it('should return truncated version of comment', () => {
const note = component.discussions[0].notes[1];
expect(component.getTooltipText(note)).toEqual('Fatih Acet: comment 2 is r...');
});
});
describe('toggleDiscussions', () => {
it('should toggle all discussions', () => {
expect(component.discussions[0].expanded).toEqual(true);
component.$store.dispatch('setInitialNotes', getDiscussionsMockData());
component.discussions = component.$store.state.notes.discussions;
component.toggleDiscussions();
expect(component.discussions[0].expanded).toEqual(false);
component.$store.dispatch('setInitialNotes', []);
});
it('forces expansion of all discussions', () => {
spyOn(component.$store, 'dispatch');
component.discussions[0].expanded = true;
component.discussions.push({
...component.discussions[0],
id: '123test',
expanded: false,
});
component.toggleDiscussions();
expect(component.$store.dispatch.calls.argsFor(0)).toEqual([
'toggleDiscussion',
{
discussionId: component.discussions[0].id,
forceExpanded: true,
},
]);
expect(component.$store.dispatch.calls.argsFor(1)).toEqual([
'toggleDiscussion',
{
discussionId: component.discussions[1].id,
forceExpanded: true,
},
]);
});
});
});
describe('template', () => {
const buttonSelector = '.js-diff-comment-button';
const svgSelector = `${buttonSelector} svg`;
const avatarSelector = '.js-diff-comment-avatar';
const plusCountSelector = '.js-diff-comment-plus';
it('should have button to collapse discussions when the discussions expanded', () => {
expect(component.$el.querySelector(buttonSelector)).toBeDefined();
expect(component.$el.querySelector(svgSelector)).toBeDefined();
});
it('should have user avatars when discussions collapsed', () => {
component.discussions[0].expanded = false;
Vue.nextTick(() => {
expect(component.$el.querySelector(buttonSelector)).toBeNull();
expect(component.$el.querySelectorAll(avatarSelector).length).toEqual(4);
expect(component.$el.querySelector(plusCountSelector)).toBeDefined();
expect(component.$el.querySelector(plusCountSelector).textContent).toEqual('+2');
});
});
});
});
...@@ -36,10 +36,11 @@ describe('InlineDiffView', () => { ...@@ -36,10 +36,11 @@ describe('InlineDiffView', () => {
it('should render discussions', done => { it('should render discussions', done => {
const el = component.$el; const el = component.$el;
component.diffLines[1].discussions = getDiscussionsMockData(); component.diffLines[1].discussions = getDiscussionsMockData();
component.diffLines[1].discussionsExpanded = true;
Vue.nextTick(() => { Vue.nextTick(() => {
expect(el.querySelectorAll('.notes_holder').length).toEqual(1); expect(el.querySelectorAll('.notes_holder').length).toEqual(1);
expect(el.querySelectorAll('.notes_holder .note-discussion li').length).toEqual(5); expect(el.querySelectorAll('.notes_holder .note-discussion li').length).toEqual(6);
expect(el.innerText.indexOf('comment 5')).toBeGreaterThan(-1); expect(el.innerText.indexOf('comment 5')).toBeGreaterThan(-1);
component.$store.dispatch('setInitialNotes', []); component.$store.dispatch('setInitialNotes', []);
......
...@@ -206,6 +206,7 @@ describe('DiffsStoreActions', () => { ...@@ -206,6 +206,7 @@ describe('DiffsStoreActions', () => {
position_type: 'text', position_type: 'text',
}, },
}, },
hash: 'diff-content-1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a',
}, },
}, },
], ],
......
...@@ -36,6 +36,12 @@ describe('noteable_discussion component', () => { ...@@ -36,6 +36,12 @@ describe('noteable_discussion component', () => {
}); });
it('should render user avatar', () => { it('should render user avatar', () => {
const discussion = { ...discussionMock };
discussion.diff_file = mockDiffFile;
discussion.diff_discussion = true;
wrapper.setProps({ discussion, renderDiffFile: true });
expect(wrapper.find('.user-avatar-link').exists()).toBe(true); expect(wrapper.find('.user-avatar-link').exists()).toBe(true);
}); });
......
...@@ -176,7 +176,7 @@ shared_examples 'discussion comments' do |resource_name| ...@@ -176,7 +176,7 @@ shared_examples 'discussion comments' do |resource_name|
if resource_name == 'merge request' if resource_name == 'merge request'
let(:note_id) { find("#{comments_selector} .note:first-child", match: :first)['data-note-id'] } let(:note_id) { find("#{comments_selector} .note:first-child", match: :first)['data-note-id'] }
let(:reply_id) { find("#{comments_selector} .note:last-child", match: :first)['data-note-id'] } let(:reply_id) { find("#{comments_selector} .note:last-of-type", match: :first)['data-note-id'] }
it 'can be replied to after resolving' do it 'can be replied to after resolving' do
click_button "Resolve discussion" click_button "Resolve discussion"
......
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