Merge branch 'diff-line-comment-vuejs' into 'master'
Diff line comments resolve
## What does this MR do?
Diff line comments can be resolved.
Part of #10325
To do:
- [x] Backend (@DouweM)
- [x] Fix https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5022#note_13319326. Will be made easier by https://gitlab.com/gitlab-org/gitlab-ce/issues/17237#note_13370331
- [x] System note when all discussions are resolved
- [x] Notification when all discussions are resolved
- [x] Write unit tests
- [x] Look at resolve time https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5022#note_13912743 - Fixed by 4a13aa9
- [x] Frontend (@iamphill)
- [x] Fix bugs
- [x] Write more feature tests
- [x] Frontend (@connorshea)
- [x] Address frontend feedback
- [x] Feature specs for Jump feature
- [x] Documentation
- [x] Add Vue.js in a standard way
See merge request !5022
This commit is contained in:
commit
579951720a
|
@ -30,6 +30,7 @@ v 8.11.0 (unreleased)
|
||||||
- Expand commit message width in repo view (ClemMakesApps)
|
- Expand commit message width in repo view (ClemMakesApps)
|
||||||
- Cache highlighted diff lines for merge requests
|
- Cache highlighted diff lines for merge requests
|
||||||
- Pre-create all builds for a Pipeline when the new Pipeline is created !5295
|
- Pre-create all builds for a Pipeline when the new Pipeline is created !5295
|
||||||
|
- Allow merge request diff notes and discussions to be explicitly marked as resolved
|
||||||
- API: Add deployment endpoints
|
- API: Add deployment endpoints
|
||||||
- API: Add Play endpoint on Builds
|
- API: Add Play endpoint on Builds
|
||||||
- Fix of 'Commits being passed to custom hooks are already reachable when using the UI'
|
- Fix of 'Commits being passed to custom hooks are already reachable when using the UI'
|
||||||
|
|
|
@ -225,10 +225,13 @@
|
||||||
});
|
});
|
||||||
$body.on("click", ".js-toggle-diff-comments", function(e) {
|
$body.on("click", ".js-toggle-diff-comments", function(e) {
|
||||||
var $this = $(this);
|
var $this = $(this);
|
||||||
var showComments = $this.hasClass('active');
|
|
||||||
|
|
||||||
$this.toggleClass('active');
|
$this.toggleClass('active');
|
||||||
$this.closest(".diff-file").find(".notes_holder").toggle(showComments);
|
var notesHolders = $this.closest('.diff-file').find('.notes_holder');
|
||||||
|
if ($this.hasClass('active')) {
|
||||||
|
notesHolders.show();
|
||||||
|
} else {
|
||||||
|
notesHolders.hide();
|
||||||
|
}
|
||||||
return e.preventDefault();
|
return e.preventDefault();
|
||||||
});
|
});
|
||||||
$document.off("click", '.js-confirm-danger');
|
$document.off("click", '.js-confirm-danger');
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
((w) => {
|
||||||
|
w.CommentAndResolveBtn = Vue.extend({
|
||||||
|
props: {
|
||||||
|
discussionId: String,
|
||||||
|
textareaIsEmpty: Boolean
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
discussion: function () {
|
||||||
|
return CommentsStore.state[this.discussionId];
|
||||||
|
},
|
||||||
|
showButton: function () {
|
||||||
|
if (this.discussion) {
|
||||||
|
return this.discussion.isResolvable();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isDiscussionResolved: function () {
|
||||||
|
return this.discussion.isResolved();
|
||||||
|
},
|
||||||
|
buttonText: function () {
|
||||||
|
if (this.isDiscussionResolved) {
|
||||||
|
if (this.textareaIsEmpty) {
|
||||||
|
return "Unresolve discussion";
|
||||||
|
} else {
|
||||||
|
return "Comment & unresolve discussion";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.textareaIsEmpty) {
|
||||||
|
return "Resolve discussion";
|
||||||
|
} else {
|
||||||
|
return "Comment & resolve discussion";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ready: function () {
|
||||||
|
const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`);
|
||||||
|
this.textareaIsEmpty = $textarea.val() === '';
|
||||||
|
|
||||||
|
$textarea.on('input.comment-and-resolve-btn', () => {
|
||||||
|
this.textareaIsEmpty = $textarea.val() === '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
destroyed: function () {
|
||||||
|
$(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})(window);
|
|
@ -0,0 +1,188 @@
|
||||||
|
(() => {
|
||||||
|
JumpToDiscussion = Vue.extend({
|
||||||
|
mixins: [DiscussionMixins],
|
||||||
|
props: {
|
||||||
|
discussionId: String
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
discussions: CommentsStore.state,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
discussion: function () {
|
||||||
|
return this.discussions[this.discussionId];
|
||||||
|
},
|
||||||
|
allResolved: function () {
|
||||||
|
return this.unresolvedDiscussionCount === 0;
|
||||||
|
},
|
||||||
|
showButton: function () {
|
||||||
|
if (this.discussionId) {
|
||||||
|
if (this.unresolvedDiscussionCount > 1) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return this.discussionId !== this.lastResolvedId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return this.unresolvedDiscussionCount >= 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lastResolvedId: function () {
|
||||||
|
let lastId;
|
||||||
|
for (const discussionId in this.discussions) {
|
||||||
|
const discussion = this.discussions[discussionId];
|
||||||
|
|
||||||
|
if (!discussion.isResolved()) {
|
||||||
|
lastId = discussion.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastId;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
jumpToNextUnresolvedDiscussion: function () {
|
||||||
|
let discussionsSelector,
|
||||||
|
discussionIdsInScope,
|
||||||
|
firstUnresolvedDiscussionId,
|
||||||
|
nextUnresolvedDiscussionId,
|
||||||
|
activeTab = window.mrTabs.currentAction,
|
||||||
|
hasDiscussionsToJumpTo = true,
|
||||||
|
jumpToFirstDiscussion = !this.discussionId;
|
||||||
|
|
||||||
|
const discussionIdsForElements = function(elements) {
|
||||||
|
return elements.map(function() {
|
||||||
|
return $(this).attr('data-discussion-id');
|
||||||
|
}).toArray();
|
||||||
|
};
|
||||||
|
|
||||||
|
const discussions = this.discussions;
|
||||||
|
|
||||||
|
if (activeTab === 'diffs') {
|
||||||
|
discussionsSelector = '.diffs .notes[data-discussion-id]';
|
||||||
|
discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
|
||||||
|
|
||||||
|
let unresolvedDiscussionCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < discussionIdsInScope.length; i++) {
|
||||||
|
const discussionId = discussionIdsInScope[i];
|
||||||
|
const discussion = discussions[discussionId];
|
||||||
|
if (discussion && !discussion.isResolved()) {
|
||||||
|
unresolvedDiscussionCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.discussionId && !this.discussion.isResolved()) {
|
||||||
|
// If this is the last unresolved discussion on the diffs tab,
|
||||||
|
// there are no discussions to jump to.
|
||||||
|
if (unresolvedDiscussionCount === 1) {
|
||||||
|
hasDiscussionsToJumpTo = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If there are no unresolved discussions on the diffs tab at all,
|
||||||
|
// there are no discussions to jump to.
|
||||||
|
if (unresolvedDiscussionCount === 0) {
|
||||||
|
hasDiscussionsToJumpTo = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (activeTab !== 'notes') {
|
||||||
|
// If we are on the commits or builds tabs,
|
||||||
|
// there are no discussions to jump to.
|
||||||
|
hasDiscussionsToJumpTo = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasDiscussionsToJumpTo) {
|
||||||
|
// If there are no discussions to jump to on the current page,
|
||||||
|
// switch to the notes tab and jump to the first disucssion there.
|
||||||
|
window.mrTabs.activateTab('notes');
|
||||||
|
activeTab = 'notes';
|
||||||
|
jumpToFirstDiscussion = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'notes') {
|
||||||
|
discussionsSelector = '.discussion[data-discussion-id]';
|
||||||
|
discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentDiscussionFound = false;
|
||||||
|
for (let i = 0; i < discussionIdsInScope.length; i++) {
|
||||||
|
const discussionId = discussionIdsInScope[i];
|
||||||
|
const discussion = discussions[discussionId];
|
||||||
|
|
||||||
|
if (!discussion) {
|
||||||
|
// Discussions for comments on commits in this MR don't have a resolved status.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstUnresolvedDiscussionId && !discussion.isResolved()) {
|
||||||
|
firstUnresolvedDiscussionId = discussionId;
|
||||||
|
|
||||||
|
if (jumpToFirstDiscussion) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jumpToFirstDiscussion) {
|
||||||
|
if (currentDiscussionFound) {
|
||||||
|
if (!discussion.isResolved()) {
|
||||||
|
nextUnresolvedDiscussionId = discussionId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discussionId === this.discussionId) {
|
||||||
|
currentDiscussionFound = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId;
|
||||||
|
|
||||||
|
if (!nextUnresolvedDiscussionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`);
|
||||||
|
|
||||||
|
if (activeTab === 'notes') {
|
||||||
|
$target = $target.closest('.note-discussion');
|
||||||
|
|
||||||
|
// If the next discussion is closed, toggle it open.
|
||||||
|
if ($target.find('.js-toggle-content').is(':hidden')) {
|
||||||
|
$target.find('.js-toggle-button i').trigger('click')
|
||||||
|
}
|
||||||
|
} else if (activeTab === 'diffs') {
|
||||||
|
// Resolved discussions are hidden in the diffs tab by default.
|
||||||
|
// If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab.
|
||||||
|
// When jumping between unresolved discussions on the diffs tab, we show them.
|
||||||
|
$target.closest(".content").show();
|
||||||
|
|
||||||
|
$target = $target.closest("tr.notes_holder");
|
||||||
|
$target.show();
|
||||||
|
|
||||||
|
// If we are on the diffs tab, we don't scroll to the discussion itself, but to
|
||||||
|
// 4 diff lines above it: the line the discussion was in response to + 3 context
|
||||||
|
let prevEl;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
prevEl = $target.prev();
|
||||||
|
|
||||||
|
// If the discussion doesn't have 4 lines above it, we'll have to do with fewer.
|
||||||
|
if (!prevEl.hasClass("line_holder")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = prevEl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$.scrollTo($target, {
|
||||||
|
offset: -($('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Vue.component('jump-to-discussion', JumpToDiscussion);
|
||||||
|
})();
|
|
@ -0,0 +1,107 @@
|
||||||
|
((w) => {
|
||||||
|
w.ResolveBtn = Vue.extend({
|
||||||
|
mixins: [
|
||||||
|
ButtonMixins
|
||||||
|
],
|
||||||
|
props: {
|
||||||
|
noteId: Number,
|
||||||
|
discussionId: String,
|
||||||
|
resolved: Boolean,
|
||||||
|
namespacePath: String,
|
||||||
|
projectPath: String,
|
||||||
|
canResolve: Boolean,
|
||||||
|
resolvedBy: String
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
discussions: CommentsStore.state,
|
||||||
|
loading: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'discussions': {
|
||||||
|
handler: 'updateTooltip',
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
discussion: function () {
|
||||||
|
return this.discussions[this.discussionId];
|
||||||
|
},
|
||||||
|
note: function () {
|
||||||
|
if (this.discussion) {
|
||||||
|
return this.discussion.getNote(this.noteId);
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buttonText: function () {
|
||||||
|
if (this.isResolved) {
|
||||||
|
return `Resolved by ${this.resolvedByName}`;
|
||||||
|
} else if (this.canResolve) {
|
||||||
|
return 'Mark as resolved';
|
||||||
|
} else {
|
||||||
|
return 'Unable to resolve';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isResolved: function () {
|
||||||
|
if (this.note) {
|
||||||
|
return this.note.resolved;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolvedByName: function () {
|
||||||
|
return this.note.resolved_by;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateTooltip: function () {
|
||||||
|
$(this.$els.button)
|
||||||
|
.tooltip('hide')
|
||||||
|
.tooltip('fixTitle');
|
||||||
|
},
|
||||||
|
resolve: function () {
|
||||||
|
if (!this.canResolve) return;
|
||||||
|
|
||||||
|
let promise;
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
if (this.isResolved) {
|
||||||
|
promise = ResolveService
|
||||||
|
.unresolve(this.namespace, this.noteId);
|
||||||
|
} else {
|
||||||
|
promise = ResolveService
|
||||||
|
.resolve(this.namespace, this.noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
promise.then((response) => {
|
||||||
|
this.loading = false;
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
const data = response.json();
|
||||||
|
const resolved_by = data ? data.resolved_by : null;
|
||||||
|
|
||||||
|
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
|
||||||
|
this.discussion.updateHeadline(data);
|
||||||
|
} else {
|
||||||
|
new Flash('An error occurred when trying to resolve a comment. Please try again.', 'alert');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$nextTick(this.updateTooltip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
compiled: function () {
|
||||||
|
$(this.$els.button).tooltip({
|
||||||
|
container: 'body'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
beforeDestroy: function () {
|
||||||
|
CommentsStore.delete(this.discussionId, this.noteId);
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
CommentsStore.create(this.discussionId, this.noteId, this.canResolve, this.resolved, this.resolvedBy);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})(window);
|
|
@ -0,0 +1,18 @@
|
||||||
|
((w) => {
|
||||||
|
w.ResolveCount = Vue.extend({
|
||||||
|
mixins: [DiscussionMixins],
|
||||||
|
props: {
|
||||||
|
loggedOut: Boolean
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
discussions: CommentsStore.state
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
allResolved: function () {
|
||||||
|
return this.resolvedDiscussionCount === this.discussionCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})(window);
|
|
@ -0,0 +1,60 @@
|
||||||
|
((w) => {
|
||||||
|
w.ResolveDiscussionBtn = Vue.extend({
|
||||||
|
mixins: [
|
||||||
|
ButtonMixins
|
||||||
|
],
|
||||||
|
props: {
|
||||||
|
discussionId: String,
|
||||||
|
mergeRequestId: Number,
|
||||||
|
namespacePath: String,
|
||||||
|
projectPath: String,
|
||||||
|
canResolve: Boolean,
|
||||||
|
},
|
||||||
|
data: function() {
|
||||||
|
return {
|
||||||
|
discussions: CommentsStore.state
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
discussion: function () {
|
||||||
|
return this.discussions[this.discussionId];
|
||||||
|
},
|
||||||
|
showButton: function () {
|
||||||
|
if (this.discussion) {
|
||||||
|
return this.discussion.isResolvable();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isDiscussionResolved: function () {
|
||||||
|
if (this.discussion) {
|
||||||
|
return this.discussion.isResolved();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buttonText: function () {
|
||||||
|
if (this.isDiscussionResolved) {
|
||||||
|
return "Unresolve discussion";
|
||||||
|
} else {
|
||||||
|
return "Resolve discussion";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loading: function () {
|
||||||
|
if (this.discussion) {
|
||||||
|
return this.discussion.loading;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
resolve: function () {
|
||||||
|
ResolveService.toggleResolveForDiscussion(this.namespace, this.mergeRequestId, this.discussionId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
CommentsStore.createDiscussion(this.discussionId, this.canResolve);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})(window);
|
|
@ -0,0 +1,35 @@
|
||||||
|
//= require vue
|
||||||
|
//= require vue-resource
|
||||||
|
//= require_directory ./models
|
||||||
|
//= require_directory ./stores
|
||||||
|
//= require_directory ./services
|
||||||
|
//= require_directory ./mixins
|
||||||
|
//= require_directory ./components
|
||||||
|
|
||||||
|
$(() => {
|
||||||
|
window.DiffNotesApp = new Vue({
|
||||||
|
el: '#diff-notes-app',
|
||||||
|
components: {
|
||||||
|
'resolve-btn': ResolveBtn,
|
||||||
|
'resolve-discussion-btn': ResolveDiscussionBtn,
|
||||||
|
'comment-and-resolve-btn': CommentAndResolveBtn
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
compileComponents: function () {
|
||||||
|
const $components = $('resolve-btn, resolve-discussion-btn, jump-to-discussion');
|
||||||
|
if ($components.length) {
|
||||||
|
$components.each(function () {
|
||||||
|
DiffNotesApp.$compile($(this).get(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#resolve-count-app',
|
||||||
|
components: {
|
||||||
|
'resolve-count': ResolveCount
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,35 @@
|
||||||
|
((w) => {
|
||||||
|
w.DiscussionMixins = {
|
||||||
|
computed: {
|
||||||
|
discussionCount: function () {
|
||||||
|
return Object.keys(this.discussions).length;
|
||||||
|
},
|
||||||
|
resolvedDiscussionCount: function () {
|
||||||
|
let resolvedCount = 0;
|
||||||
|
|
||||||
|
for (const discussionId in this.discussions) {
|
||||||
|
const discussion = this.discussions[discussionId];
|
||||||
|
|
||||||
|
if (discussion.isResolved()) {
|
||||||
|
resolvedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedCount;
|
||||||
|
},
|
||||||
|
unresolvedDiscussionCount: function () {
|
||||||
|
let unresolvedCount = 0;
|
||||||
|
|
||||||
|
for (const discussionId in this.discussions) {
|
||||||
|
const discussion = this.discussions[discussionId];
|
||||||
|
|
||||||
|
if (!discussion.isResolved()) {
|
||||||
|
unresolvedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unresolvedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})(window);
|
|
@ -0,0 +1,9 @@
|
||||||
|
((w) => {
|
||||||
|
w.ButtonMixins = {
|
||||||
|
computed: {
|
||||||
|
namespace: function () {
|
||||||
|
return `${this.namespacePath}/${this.projectPath}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})(window);
|
|
@ -0,0 +1,87 @@
|
||||||
|
class DiscussionModel {
|
||||||
|
constructor (discussionId) {
|
||||||
|
this.id = discussionId;
|
||||||
|
this.notes = {};
|
||||||
|
this.loading = false;
|
||||||
|
this.canResolve = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
createNote (noteId, canResolve, resolved, resolved_by) {
|
||||||
|
Vue.set(this.notes, noteId, new NoteModel(this.id, noteId, canResolve, resolved, resolved_by));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteNote (noteId) {
|
||||||
|
Vue.delete(this.notes, noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNote (noteId) {
|
||||||
|
return this.notes[noteId];
|
||||||
|
}
|
||||||
|
|
||||||
|
notesCount() {
|
||||||
|
return Object.keys(this.notes).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
isResolved () {
|
||||||
|
for (const noteId in this.notes) {
|
||||||
|
const note = this.notes[noteId];
|
||||||
|
|
||||||
|
if (!note.resolved) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAllNotes (resolved_by) {
|
||||||
|
for (const noteId in this.notes) {
|
||||||
|
const note = this.notes[noteId];
|
||||||
|
|
||||||
|
if (!note.resolved) {
|
||||||
|
note.resolved = true;
|
||||||
|
note.resolved_by = resolved_by;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unResolveAllNotes () {
|
||||||
|
for (const noteId in this.notes) {
|
||||||
|
const note = this.notes[noteId];
|
||||||
|
|
||||||
|
if (note.resolved) {
|
||||||
|
note.resolved = false;
|
||||||
|
note.resolved_by = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHeadline (data) {
|
||||||
|
const $discussionHeadline = $(`.discussion[data-discussion-id="${this.id}"] .js-discussion-headline`);
|
||||||
|
|
||||||
|
if (data.discussion_headline_html) {
|
||||||
|
if ($discussionHeadline.length) {
|
||||||
|
$discussionHeadline.replaceWith(data.discussion_headline_html);
|
||||||
|
} else {
|
||||||
|
$(`.discussion[data-discussion-id="${this.id}"] .discussion-header`).append(data.discussion_headline_html);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$discussionHeadline.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isResolvable () {
|
||||||
|
if (!this.canResolve) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const noteId in this.notes) {
|
||||||
|
const note = this.notes[noteId];
|
||||||
|
|
||||||
|
if (note.canResolve) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
class NoteModel {
|
||||||
|
constructor (discussionId, noteId, canResolve, resolved, resolved_by) {
|
||||||
|
this.discussionId = discussionId;
|
||||||
|
this.id = noteId;
|
||||||
|
this.canResolve = canResolve;
|
||||||
|
this.resolved = resolved;
|
||||||
|
this.resolved_by = resolved_by;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
((w) => {
|
||||||
|
class ResolveServiceClass {
|
||||||
|
constructor() {
|
||||||
|
this.noteResource = Vue.resource('notes{/noteId}/resolve');
|
||||||
|
this.discussionResource = Vue.resource('merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCSRF() {
|
||||||
|
Vue.http.headers.common['X-CSRF-Token'] = $.rails.csrfToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareRequest(namespace) {
|
||||||
|
this.setCSRF();
|
||||||
|
Vue.http.options.root = `/${namespace}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(namespace, noteId) {
|
||||||
|
this.prepareRequest(namespace);
|
||||||
|
|
||||||
|
return this.noteResource.save({ noteId }, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
unresolve(namespace, noteId) {
|
||||||
|
this.prepareRequest(namespace);
|
||||||
|
|
||||||
|
return this.noteResource.delete({ noteId }, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleResolveForDiscussion(namespace, mergeRequestId, discussionId) {
|
||||||
|
const discussion = CommentsStore.state[discussionId],
|
||||||
|
isResolved = discussion.isResolved();
|
||||||
|
let promise;
|
||||||
|
|
||||||
|
if (isResolved) {
|
||||||
|
promise = this.unResolveAll(namespace, mergeRequestId, discussionId);
|
||||||
|
} else {
|
||||||
|
promise = this.resolveAll(namespace, mergeRequestId, discussionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
promise.then((response) => {
|
||||||
|
discussion.loading = false;
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
const data = response.json();
|
||||||
|
const resolved_by = data ? data.resolved_by : null;
|
||||||
|
|
||||||
|
if (isResolved) {
|
||||||
|
discussion.unResolveAllNotes();
|
||||||
|
} else {
|
||||||
|
discussion.resolveAllNotes(resolved_by);
|
||||||
|
}
|
||||||
|
|
||||||
|
discussion.updateHeadline(data);
|
||||||
|
} else {
|
||||||
|
new Flash('An error occurred when trying to resolve a discussion. Please try again.', 'alert');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAll(namespace, mergeRequestId, discussionId) {
|
||||||
|
const discussion = CommentsStore.state[discussionId];
|
||||||
|
|
||||||
|
this.prepareRequest(namespace);
|
||||||
|
|
||||||
|
discussion.loading = true;
|
||||||
|
|
||||||
|
return this.discussionResource.save({
|
||||||
|
mergeRequestId,
|
||||||
|
discussionId
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
unResolveAll(namespace, mergeRequestId, discussionId) {
|
||||||
|
const discussion = CommentsStore.state[discussionId];
|
||||||
|
|
||||||
|
this.prepareRequest(namespace);
|
||||||
|
|
||||||
|
discussion.loading = true;
|
||||||
|
|
||||||
|
return this.discussionResource.delete({
|
||||||
|
mergeRequestId,
|
||||||
|
discussionId
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.ResolveService = new ResolveServiceClass();
|
||||||
|
})(window);
|
|
@ -0,0 +1,53 @@
|
||||||
|
((w) => {
|
||||||
|
w.CommentsStore = {
|
||||||
|
state: {},
|
||||||
|
get: function (discussionId, noteId) {
|
||||||
|
return this.state[discussionId].getNote(noteId);
|
||||||
|
},
|
||||||
|
createDiscussion: function (discussionId, canResolve) {
|
||||||
|
let discussion = this.state[discussionId];
|
||||||
|
if (!this.state[discussionId]) {
|
||||||
|
discussion = new DiscussionModel(discussionId);
|
||||||
|
Vue.set(this.state, discussionId, discussion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canResolve !== undefined) {
|
||||||
|
discussion.canResolve = canResolve;
|
||||||
|
}
|
||||||
|
|
||||||
|
return discussion;
|
||||||
|
},
|
||||||
|
create: function (discussionId, noteId, canResolve, resolved, resolved_by) {
|
||||||
|
const discussion = this.createDiscussion(discussionId);
|
||||||
|
|
||||||
|
discussion.createNote(noteId, canResolve, resolved, resolved_by);
|
||||||
|
},
|
||||||
|
update: function (discussionId, noteId, resolved, resolved_by) {
|
||||||
|
const discussion = this.state[discussionId];
|
||||||
|
const note = discussion.getNote(noteId);
|
||||||
|
note.resolved = resolved;
|
||||||
|
note.resolved_by = resolved_by;
|
||||||
|
},
|
||||||
|
delete: function (discussionId, noteId) {
|
||||||
|
const discussion = this.state[discussionId];
|
||||||
|
discussion.deleteNote(noteId);
|
||||||
|
|
||||||
|
if (discussion.notesCount() === 0) {
|
||||||
|
Vue.delete(this.state, discussionId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unresolvedDiscussionIds: function () {
|
||||||
|
let ids = [];
|
||||||
|
|
||||||
|
for (const discussionId in this.state) {
|
||||||
|
const discussion = this.state[discussionId];
|
||||||
|
|
||||||
|
if (!discussion.isResolved()) {
|
||||||
|
ids.push(discussion.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})(window);
|
|
@ -34,7 +34,7 @@
|
||||||
|
|
||||||
MergeRequest.prototype.initTabs = function() {
|
MergeRequest.prototype.initTabs = function() {
|
||||||
if (this.opts.action !== 'new') {
|
if (this.opts.action !== 'new') {
|
||||||
return new MergeRequestTabs(this.opts);
|
window.mrTabs = new MergeRequestTabs(this.opts);
|
||||||
} else {
|
} else {
|
||||||
return $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show');
|
return $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show');
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,6 +89,7 @@
|
||||||
if (action === 'show') {
|
if (action === 'show') {
|
||||||
action = 'notes';
|
action = 'notes';
|
||||||
}
|
}
|
||||||
|
this.currentAction = action;
|
||||||
new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, '');
|
new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, '');
|
||||||
if (action !== 'notes') {
|
if (action !== 'notes') {
|
||||||
new_state += "/" + action;
|
new_state += "/" + action;
|
||||||
|
@ -127,6 +128,11 @@
|
||||||
success: (function(_this) {
|
success: (function(_this) {
|
||||||
return function(data) {
|
return function(data) {
|
||||||
$('#diffs').html(data.html);
|
$('#diffs').html(data.html);
|
||||||
|
|
||||||
|
if (typeof DiffNotesApp !== 'undefined') {
|
||||||
|
DiffNotesApp.compileComponents();
|
||||||
|
}
|
||||||
|
|
||||||
gl.utils.localTimeAgo($('.js-timeago', 'div#diffs'));
|
gl.utils.localTimeAgo($('.js-timeago', 'div#diffs'));
|
||||||
$('#diffs .js-syntax-highlight').syntaxHighlight();
|
$('#diffs .js-syntax-highlight').syntaxHighlight();
|
||||||
$('#diffs .diff-file').singleFileDiff();
|
$('#diffs .diff-file').singleFileDiff();
|
||||||
|
|
|
@ -68,6 +68,7 @@
|
||||||
$(document).on("click", ".note-edit-cancel", this.cancelEdit);
|
$(document).on("click", ".note-edit-cancel", this.cancelEdit);
|
||||||
$(document).on("click", ".js-comment-button", this.updateCloseButton);
|
$(document).on("click", ".js-comment-button", this.updateCloseButton);
|
||||||
$(document).on("keyup input", ".js-note-text", this.updateTargetButtons);
|
$(document).on("keyup input", ".js-note-text", this.updateTargetButtons);
|
||||||
|
$(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion);
|
||||||
$(document).on("click", ".js-note-delete", this.removeNote);
|
$(document).on("click", ".js-note-delete", this.removeNote);
|
||||||
$(document).on("click", ".js-note-attachment-delete", this.removeAttachment);
|
$(document).on("click", ".js-note-attachment-delete", this.removeAttachment);
|
||||||
$(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
|
$(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
|
||||||
|
@ -100,6 +101,7 @@
|
||||||
$(document).off("click", ".js-note-target-close");
|
$(document).off("click", ".js-note-target-close");
|
||||||
$(document).off("click", ".js-note-discard");
|
$(document).off("click", ".js-note-discard");
|
||||||
$(document).off("keydown", ".js-note-text");
|
$(document).off("keydown", ".js-note-text");
|
||||||
|
$(document).off('click', '.js-comment-resolve-button');
|
||||||
$('.note .js-task-list-container').taskList('disable');
|
$('.note .js-task-list-container').taskList('disable');
|
||||||
return $(document).off('tasklist:changed', '.note .js-task-list-container');
|
return $(document).off('tasklist:changed', '.note .js-task-list-container');
|
||||||
};
|
};
|
||||||
|
@ -304,6 +306,11 @@
|
||||||
} else {
|
} else {
|
||||||
discussionContainer.append(note_html);
|
discussionContainer.append(note_html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof DiffNotesApp !== 'undefined') {
|
||||||
|
DiffNotesApp.compileComponents();
|
||||||
|
}
|
||||||
|
|
||||||
gl.utils.localTimeAgo($('.js-timeago', note_html), false);
|
gl.utils.localTimeAgo($('.js-timeago', note_html), false);
|
||||||
return this.updateNotesCount(1);
|
return this.updateNotesCount(1);
|
||||||
};
|
};
|
||||||
|
@ -350,6 +357,7 @@
|
||||||
form.find("#note_line_code").remove();
|
form.find("#note_line_code").remove();
|
||||||
form.find("#note_position").remove();
|
form.find("#note_position").remove();
|
||||||
form.find("#note_type").remove();
|
form.find("#note_type").remove();
|
||||||
|
form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
|
||||||
return this.parentTimeline = form.parents('.timeline');
|
return this.parentTimeline = form.parents('.timeline');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -393,8 +401,22 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
Notes.prototype.addDiscussionNote = function(xhr, note, status) {
|
Notes.prototype.addDiscussionNote = function(xhr, note, status) {
|
||||||
|
var $form = $(xhr.target);
|
||||||
|
|
||||||
|
if ($form.attr('data-resolve-all') != null) {
|
||||||
|
var namespacePath = $form.attr('data-namespace-path'),
|
||||||
|
projectPath = $form.attr('data-project-path')
|
||||||
|
discussionId = $form.attr('data-discussion-id'),
|
||||||
|
mergeRequestId = $form.attr('data-noteable-iid'),
|
||||||
|
namespace = namespacePath + '/' + projectPath;
|
||||||
|
|
||||||
|
if (ResolveService != null) {
|
||||||
|
ResolveService.toggleResolveForDiscussion(namespace, mergeRequestId, discussionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.renderDiscussionNote(note);
|
this.renderDiscussionNote(note);
|
||||||
return this.removeDiscussionNoteForm($(xhr.target));
|
this.removeDiscussionNoteForm($form);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
@ -411,7 +433,12 @@
|
||||||
$html.syntaxHighlight();
|
$html.syntaxHighlight();
|
||||||
$html.find('.js-task-list-container').taskList('enable');
|
$html.find('.js-task-list-container').taskList('enable');
|
||||||
$note_li = $('.note-row-' + note.id);
|
$note_li = $('.note-row-' + note.id);
|
||||||
return $note_li.replaceWith($html);
|
|
||||||
|
$note_li.replaceWith($html);
|
||||||
|
|
||||||
|
if (typeof DiffNotesApp !== 'undefined') {
|
||||||
|
DiffNotesApp.compileComponents();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
@ -492,6 +519,15 @@
|
||||||
var note, notes;
|
var note, notes;
|
||||||
note = $(el);
|
note = $(el);
|
||||||
notes = note.closest(".notes");
|
notes = note.closest(".notes");
|
||||||
|
|
||||||
|
if (typeof DiffNotesApp !== "undefined" && DiffNotesApp !== null) {
|
||||||
|
ref = DiffNotesApp.$refs[noteId];
|
||||||
|
|
||||||
|
if (ref) {
|
||||||
|
ref.$destroy(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (notes.find(".note").length === 1) {
|
if (notes.find(".note").length === 1) {
|
||||||
notes.closest(".timeline-entry").remove();
|
notes.closest(".timeline-entry").remove();
|
||||||
notes.closest("tr").remove();
|
notes.closest("tr").remove();
|
||||||
|
@ -530,8 +566,10 @@
|
||||||
var form, replyLink;
|
var form, replyLink;
|
||||||
form = this.formClone.clone();
|
form = this.formClone.clone();
|
||||||
replyLink = $(e.target).closest(".js-discussion-reply-button");
|
replyLink = $(e.target).closest(".js-discussion-reply-button");
|
||||||
replyLink.hide();
|
replyLink
|
||||||
replyLink.after(form);
|
.closest('.discussion-reply-holder')
|
||||||
|
.hide()
|
||||||
|
.after(form);
|
||||||
return this.setupDiscussionNoteForm(replyLink, form);
|
return this.setupDiscussionNoteForm(replyLink, form);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -556,9 +594,23 @@
|
||||||
form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
|
form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
|
||||||
form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
|
form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
|
||||||
form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text'));
|
form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text'));
|
||||||
|
form.find('.js-note-target-close').remove();
|
||||||
this.setupNoteForm(form);
|
this.setupNoteForm(form);
|
||||||
|
|
||||||
|
if (typeof DiffNotesApp !== 'undefined') {
|
||||||
|
var $commentBtn = form.find('comment-and-resolve-btn');
|
||||||
|
$commentBtn
|
||||||
|
.attr(':discussion-id', "'" + dataHolder.data('discussionId') + "'");
|
||||||
|
DiffNotesApp.$compile($commentBtn.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
form.find(".js-note-text").focus();
|
form.find(".js-note-text").focus();
|
||||||
return form.removeClass('js-main-target-form').addClass("discussion-form js-discussion-note-form");
|
form
|
||||||
|
.find('.js-comment-resolve-button')
|
||||||
|
.attr('data-discussion-id', dataHolder.data('discussionId'));
|
||||||
|
form
|
||||||
|
.removeClass('js-main-target-form')
|
||||||
|
.addClass("discussion-form js-discussion-note-form");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
@ -577,16 +629,19 @@
|
||||||
nextRow = row.next();
|
nextRow = row.next();
|
||||||
hasNotes = nextRow.is(".notes_holder");
|
hasNotes = nextRow.is(".notes_holder");
|
||||||
addForm = false;
|
addForm = false;
|
||||||
targetContent = ".notes_content";
|
notesContentSelector = ".notes_content";
|
||||||
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"></td></tr>";
|
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"><div class=\"content\"></div></td></tr>";
|
||||||
if (this.isParallelView()) {
|
if (this.isParallelView()) {
|
||||||
lineType = $link.data("lineType");
|
lineType = $link.data("lineType");
|
||||||
targetContent += "." + lineType;
|
notesContentSelector += "." + lineType;
|
||||||
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\"></td><td class=\"notes_content parallel old\"></td><td class=\"notes_line\"></td><td class=\"notes_content parallel new\"></td></tr>";
|
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line old\"></td><td class=\"notes_content parallel old\"><div class=\"content\"></div></td><td class=\"notes_line new\"></td><td class=\"notes_content parallel new\"><div class=\"content\"></div></td></tr>";
|
||||||
}
|
}
|
||||||
|
notesContentSelector += " .content";
|
||||||
if (hasNotes) {
|
if (hasNotes) {
|
||||||
notesContent = nextRow.find(targetContent);
|
nextRow.show();
|
||||||
|
notesContent = nextRow.find(notesContentSelector);
|
||||||
if (notesContent.length) {
|
if (notesContent.length) {
|
||||||
|
notesContent.show();
|
||||||
replyButton = notesContent.find(".js-discussion-reply-button:visible");
|
replyButton = notesContent.find(".js-discussion-reply-button:visible");
|
||||||
if (replyButton.length) {
|
if (replyButton.length) {
|
||||||
e.target = replyButton[0];
|
e.target = replyButton[0];
|
||||||
|
@ -600,11 +655,13 @@
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
row.after(rowCssToAdd);
|
row.after(rowCssToAdd);
|
||||||
|
nextRow = row.next();
|
||||||
|
notesContent = nextRow.find(notesContentSelector);
|
||||||
addForm = true;
|
addForm = true;
|
||||||
}
|
}
|
||||||
if (addForm) {
|
if (addForm) {
|
||||||
newForm = this.formClone.clone();
|
newForm = this.formClone.clone();
|
||||||
newForm.appendTo(row.next().find(targetContent));
|
newForm.appendTo(notesContent);
|
||||||
return this.setupDiscussionNoteForm($link, newForm);
|
return this.setupDiscussionNoteForm($link, newForm);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -623,7 +680,9 @@
|
||||||
glForm = form.data('gl-form');
|
glForm = form.data('gl-form');
|
||||||
glForm.destroy();
|
glForm.destroy();
|
||||||
form.find(".js-note-text").data("autosave").reset();
|
form.find(".js-note-text").data("autosave").reset();
|
||||||
form.prev(".js-discussion-reply-button").show();
|
form
|
||||||
|
.prev('.discussion-reply-holder')
|
||||||
|
.show();
|
||||||
if (row.is(".js-temp-notes-holder")) {
|
if (row.is(".js-temp-notes-holder")) {
|
||||||
return row.remove();
|
return row.remove();
|
||||||
} else {
|
} else {
|
||||||
|
@ -732,6 +791,18 @@
|
||||||
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount);
|
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Notes.prototype.resolveDiscussion = function () {
|
||||||
|
var $this = $(this),
|
||||||
|
discussionId = $this.attr('data-discussion-id');
|
||||||
|
|
||||||
|
$this
|
||||||
|
.closest('form')
|
||||||
|
.attr('data-discussion-id', discussionId)
|
||||||
|
.attr('data-resolve-all', 'true')
|
||||||
|
.attr('data-namespace-path', $this.attr('data-namespace-path'))
|
||||||
|
.attr('data-project-path', $this.attr('data-project-path'));
|
||||||
|
};
|
||||||
|
|
||||||
return Notes;
|
return Notes;
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -35,10 +35,16 @@
|
||||||
this.isOpen = !this.isOpen;
|
this.isOpen = !this.isOpen;
|
||||||
if (!this.isOpen && !this.hasError) {
|
if (!this.isOpen && !this.hasError) {
|
||||||
this.content.hide();
|
this.content.hide();
|
||||||
return this.collapsedContent.show();
|
this.collapsedContent.show();
|
||||||
|
if (typeof DiffNotesApp !== 'undefined') {
|
||||||
|
DiffNotesApp.compileComponents();
|
||||||
|
}
|
||||||
} else if (this.content) {
|
} else if (this.content) {
|
||||||
this.collapsedContent.hide();
|
this.collapsedContent.hide();
|
||||||
return this.content.show();
|
this.content.show();
|
||||||
|
if (typeof DiffNotesApp !== 'undefined') {
|
||||||
|
DiffNotesApp.compileComponents();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return this.getContentHTML();
|
return this.getContentHTML();
|
||||||
}
|
}
|
||||||
|
@ -57,7 +63,11 @@
|
||||||
_this.hasError = true;
|
_this.hasError = true;
|
||||||
_this.content = $(ERROR_HTML);
|
_this.content = $(ERROR_HTML);
|
||||||
}
|
}
|
||||||
return _this.collapsedContent.after(_this.content);
|
_this.collapsedContent.after(_this.content);
|
||||||
|
|
||||||
|
if (typeof DiffNotesApp !== 'undefined') {
|
||||||
|
DiffNotesApp.compileComponents();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
})(this));
|
})(this));
|
||||||
};
|
};
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide element if Vue is still working on rendering it fully.
|
||||||
[v-cloak] {
|
[v-cloak="true"] {
|
||||||
display: none;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
|
@ -159,6 +159,32 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.discussion-with-resolve-btn {
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
table-layout: auto;
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: table-cell;
|
||||||
|
float: none;
|
||||||
|
width: 1%;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.discussion-notes-count {
|
.discussion-notes-count {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -383,3 +383,80 @@ ul.notes {
|
||||||
color: $gl-link-color;
|
color: $gl-link-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.line-resolve-all-container {
|
||||||
|
.btn-group {
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discussion-next-btn {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-resolve-all {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background-color: $background-color;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: $border-radius-default;
|
||||||
|
|
||||||
|
&.has-next-btn {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-resolve-btn {
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-resolve-text {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-resolve-btn {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
top: 2px;
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: 0;
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.is-disabled):hover,
|
||||||
|
&:not(.is-disabled):focus,
|
||||||
|
&.is-active {
|
||||||
|
color: $gl-text-green;
|
||||||
|
|
||||||
|
svg path {
|
||||||
|
fill: $gl-text-green;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
position: relative;
|
||||||
|
color: $notes-action-color;
|
||||||
|
|
||||||
|
path {
|
||||||
|
fill: $notes-action-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.discussion-next-btn {
|
||||||
|
svg {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
path {
|
||||||
|
fill: $gray-darkest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
class Projects::DiscussionsController < Projects::ApplicationController
|
||||||
|
before_action :module_enabled
|
||||||
|
before_action :merge_request
|
||||||
|
before_action :discussion
|
||||||
|
before_action :authorize_resolve_discussion!
|
||||||
|
|
||||||
|
def resolve
|
||||||
|
discussion.resolve!(current_user)
|
||||||
|
|
||||||
|
MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
resolved_by: discussion.resolved_by.try(:name),
|
||||||
|
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def unresolve
|
||||||
|
discussion.unresolve!
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def merge_request
|
||||||
|
@merge_request ||= @project.merge_requests.find_by!(iid: params[:merge_request_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def discussion
|
||||||
|
@discussion ||= @merge_request.find_diff_discussion(params[:id]) || render_404
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_resolve_discussion!
|
||||||
|
access_denied! unless discussion.can_resolve?(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def module_enabled
|
||||||
|
render_404 unless @project.merge_requests_enabled
|
||||||
|
end
|
||||||
|
end
|
|
@ -435,12 +435,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
||||||
# :show, :diff, :commits, :builds. but not when request the data through AJAX
|
# :show, :diff, :commits, :builds. but not when request the data through AJAX
|
||||||
def define_discussion_vars
|
def define_discussion_vars
|
||||||
# Build a note object for comment form
|
# Build a note object for comment form
|
||||||
@note = @project.notes.new(noteable: @noteable)
|
@note = @project.notes.new(noteable: @merge_request)
|
||||||
|
|
||||||
@discussions = @noteable.mr_and_commit_notes.
|
@discussions = @merge_request.discussions
|
||||||
inc_author_project_award_emoji.
|
|
||||||
fresh.
|
|
||||||
discussions
|
|
||||||
|
|
||||||
preload_noteable_for_regular_notes(@discussions.flat_map(&:notes))
|
preload_noteable_for_regular_notes(@discussions.flat_map(&:notes))
|
||||||
|
|
||||||
|
@ -474,7 +471,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
||||||
}
|
}
|
||||||
|
|
||||||
@use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
|
@use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
|
||||||
@grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions
|
@grouped_diff_discussions = @merge_request.notes.inc_relations_for_view.grouped_diff_discussions
|
||||||
|
|
||||||
Banzai::NoteRenderer.render(
|
Banzai::NoteRenderer.render(
|
||||||
@grouped_diff_discussions.values.flat_map(&:notes),
|
@grouped_diff_discussions.values.flat_map(&:notes),
|
||||||
|
|
|
@ -5,6 +5,7 @@ class Projects::NotesController < Projects::ApplicationController
|
||||||
before_action :authorize_read_note!
|
before_action :authorize_read_note!
|
||||||
before_action :authorize_create_note!, only: [:create]
|
before_action :authorize_create_note!, only: [:create]
|
||||||
before_action :authorize_admin_note!, only: [:update, :destroy]
|
before_action :authorize_admin_note!, only: [:update, :destroy]
|
||||||
|
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
|
||||||
before_action :find_current_user_notes, only: [:index]
|
before_action :find_current_user_notes, only: [:index]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
@ -66,6 +67,33 @@ class Projects::NotesController < Projects::ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolve
|
||||||
|
return render_404 unless note.resolvable?
|
||||||
|
|
||||||
|
note.resolve!(current_user)
|
||||||
|
|
||||||
|
MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(note.noteable)
|
||||||
|
|
||||||
|
discussion = note.discussion
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
resolved_by: note.resolved_by.try(:name),
|
||||||
|
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def unresolve
|
||||||
|
return render_404 unless note.resolvable?
|
||||||
|
|
||||||
|
note.unresolve!
|
||||||
|
|
||||||
|
discussion = note.discussion
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def note
|
def note
|
||||||
|
@ -138,7 +166,7 @@ class Projects::NotesController < Projects::ApplicationController
|
||||||
}
|
}
|
||||||
|
|
||||||
if note.diff_note?
|
if note.diff_note?
|
||||||
discussion = Discussion.new([note])
|
discussion = note.to_discussion
|
||||||
|
|
||||||
attrs.merge!(
|
attrs.merge!(
|
||||||
diff_discussion_html: diff_discussion_html(discussion),
|
diff_discussion_html: diff_discussion_html(discussion),
|
||||||
|
@ -175,6 +203,10 @@ class Projects::NotesController < Projects::ApplicationController
|
||||||
return access_denied! unless can?(current_user, :admin_note, note)
|
return access_denied! unless can?(current_user, :admin_note, note)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def authorize_resolve_note!
|
||||||
|
return access_denied! unless can?(current_user, :resolve_note, note)
|
||||||
|
end
|
||||||
|
|
||||||
def note_params
|
def note_params
|
||||||
params.require(:note).permit(
|
params.require(:note).permit(
|
||||||
:note, :noteable, :noteable_id, :noteable_type, :project_id,
|
:note, :noteable, :noteable_id, :noteable_type, :project_id,
|
||||||
|
|
|
@ -32,6 +32,8 @@ module AppearancesHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def custom_icon(icon_name, size: 16)
|
def custom_icon(icon_name, size: 16)
|
||||||
|
# We can't simply do the below, because there are some .erb SVGs.
|
||||||
|
# File.read(Rails.root.join("app/views/shared/icons/_#{icon_name}.svg")).html_safe
|
||||||
render "shared/icons/#{icon_name}.svg", size: size
|
render "shared/icons/#{icon_name}.svg", size: size
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -49,7 +49,7 @@ module NotesHelper
|
||||||
}
|
}
|
||||||
|
|
||||||
if use_legacy_diff_note
|
if use_legacy_diff_note
|
||||||
discussion_id = LegacyDiffNote.build_discussion_id(
|
discussion_id = LegacyDiffNote.discussion_id(
|
||||||
@comments_target[:noteable_type],
|
@comments_target[:noteable_type],
|
||||||
@comments_target[:noteable_id] || @comments_target[:commit_id],
|
@comments_target[:noteable_id] || @comments_target[:commit_id],
|
||||||
line_code
|
line_code
|
||||||
|
@ -60,7 +60,7 @@ module NotesHelper
|
||||||
discussion_id: discussion_id
|
discussion_id: discussion_id
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
discussion_id = DiffNote.build_discussion_id(
|
discussion_id = DiffNote.discussion_id(
|
||||||
@comments_target[:noteable_type],
|
@comments_target[:noteable_type],
|
||||||
@comments_target[:noteable_id] || @comments_target[:commit_id],
|
@comments_target[:noteable_id] || @comments_target[:commit_id],
|
||||||
position
|
position
|
||||||
|
@ -81,10 +81,8 @@ module NotesHelper
|
||||||
|
|
||||||
data = discussion.reply_attributes.merge(line_type: line_type)
|
data = discussion.reply_attributes.merge(line_type: line_type)
|
||||||
|
|
||||||
content_tag(:div, class: "discussion-reply-holder") do
|
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
|
||||||
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
|
data: data, title: 'Add a reply'
|
||||||
data: data, title: 'Add a reply'
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def preload_max_access_for_authors(notes, project)
|
def preload_max_access_for_authors(notes, project)
|
||||||
|
|
|
@ -47,6 +47,13 @@ module Emails
|
||||||
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
|
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id)
|
||||||
|
setup_merge_request_mail(merge_request_id, recipient_id)
|
||||||
|
|
||||||
|
@resolved_by = User.find(resolved_by_user_id)
|
||||||
|
mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id))
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def setup_merge_request_mail(merge_request_id, recipient_id)
|
def setup_merge_request_mail(merge_request_id, recipient_id)
|
||||||
|
|
|
@ -276,6 +276,7 @@ class Ability
|
||||||
:create_merge_request,
|
:create_merge_request,
|
||||||
:create_wiki,
|
:create_wiki,
|
||||||
:push_code,
|
:push_code,
|
||||||
|
:resolve_note,
|
||||||
:create_container_image,
|
:create_container_image,
|
||||||
:update_container_image,
|
:update_container_image,
|
||||||
:create_environment,
|
:create_environment,
|
||||||
|
@ -457,7 +458,8 @@ class Ability
|
||||||
rules += [
|
rules += [
|
||||||
:read_note,
|
:read_note,
|
||||||
:update_note,
|
:update_note,
|
||||||
:admin_note
|
:admin_note,
|
||||||
|
:resolve_note
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -465,6 +467,10 @@ class Ability
|
||||||
rules += project_abilities(user, note.project)
|
rules += project_abilities(user, note.project)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if note.for_merge_request? && note.noteable.author == user
|
||||||
|
rules << :resolve_note
|
||||||
|
end
|
||||||
|
|
||||||
rules
|
rules
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -9,11 +9,13 @@ class DiffNote < Note
|
||||||
validates :diff_line, presence: true
|
validates :diff_line, presence: true
|
||||||
validates :line_code, presence: true, line_code: true
|
validates :line_code, presence: true, line_code: true
|
||||||
validates :noteable_type, inclusion: { in: ['Commit', 'MergeRequest'] }
|
validates :noteable_type, inclusion: { in: ['Commit', 'MergeRequest'] }
|
||||||
|
validates :resolved_by, presence: true, if: :resolved?
|
||||||
validate :positions_complete
|
validate :positions_complete
|
||||||
validate :verify_supported
|
validate :verify_supported
|
||||||
|
|
||||||
|
after_initialize :ensure_original_discussion_id
|
||||||
before_validation :set_original_position, :update_position, on: :create
|
before_validation :set_original_position, :update_position, on: :create
|
||||||
before_validation :set_line_code
|
before_validation :set_line_code, :set_original_discussion_id
|
||||||
after_save :keep_around_commits
|
after_save :keep_around_commits
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
@ -30,14 +32,6 @@ class DiffNote < Note
|
||||||
{ position: position.to_json }
|
{ position: position.to_json }
|
||||||
end
|
end
|
||||||
|
|
||||||
def discussion_id
|
|
||||||
@discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position)
|
|
||||||
end
|
|
||||||
|
|
||||||
def original_discussion_id
|
|
||||||
@original_discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position)
|
|
||||||
end
|
|
||||||
|
|
||||||
def position=(new_position)
|
def position=(new_position)
|
||||||
if new_position.is_a?(String)
|
if new_position.is_a?(String)
|
||||||
new_position = JSON.parse(new_position) rescue nil
|
new_position = JSON.parse(new_position) rescue nil
|
||||||
|
@ -72,10 +66,48 @@ class DiffNote < Note
|
||||||
self.position.diff_refs == diff_refs
|
self.position.diff_refs == diff_refs
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolvable?
|
||||||
|
!system? && for_merge_request?
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolved?
|
||||||
|
return false unless resolvable?
|
||||||
|
|
||||||
|
self.resolved_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve!(current_user)
|
||||||
|
return unless resolvable?
|
||||||
|
return if resolved?
|
||||||
|
|
||||||
|
self.resolved_at = Time.now
|
||||||
|
self.resolved_by = current_user
|
||||||
|
save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def unresolve!
|
||||||
|
return unless resolvable?
|
||||||
|
return unless resolved?
|
||||||
|
|
||||||
|
self.resolved_at = nil
|
||||||
|
self.resolved_by = nil
|
||||||
|
save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def discussion
|
||||||
|
return unless resolvable?
|
||||||
|
|
||||||
|
self.noteable.find_diff_discussion(self.discussion_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_discussion
|
||||||
|
Discussion.new([self])
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def supported?
|
def supported?
|
||||||
!self.for_merge_request? || self.noteable.has_complete_diff_refs?
|
for_commit? || self.noteable.has_complete_diff_refs?
|
||||||
end
|
end
|
||||||
|
|
||||||
def noteable_diff_refs
|
def noteable_diff_refs
|
||||||
|
@ -94,6 +126,26 @@ class DiffNote < Note
|
||||||
self.line_code = self.position.line_code(self.project.repository)
|
self.line_code = self.position.line_code(self.project.repository)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ensure_original_discussion_id
|
||||||
|
return unless self.persisted?
|
||||||
|
return if self.original_discussion_id
|
||||||
|
|
||||||
|
set_original_discussion_id
|
||||||
|
update_column(:original_discussion_id, self.original_discussion_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_original_discussion_id
|
||||||
|
self.original_discussion_id = Digest::SHA1.hexdigest(build_original_discussion_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_discussion_id
|
||||||
|
self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_original_discussion_id
|
||||||
|
self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position)
|
||||||
|
end
|
||||||
|
|
||||||
def update_position
|
def update_position
|
||||||
return unless supported?
|
return unless supported?
|
||||||
return if for_commit?
|
return if for_commit?
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
class Discussion
|
class Discussion
|
||||||
NUMBER_OF_TRUNCATED_DIFF_LINES = 16
|
NUMBER_OF_TRUNCATED_DIFF_LINES = 16
|
||||||
|
|
||||||
attr_reader :first_note, :notes
|
attr_reader :first_note, :last_note, :notes
|
||||||
|
|
||||||
delegate :created_at,
|
delegate :created_at,
|
||||||
:project,
|
:project,
|
||||||
|
@ -18,6 +18,12 @@ class Discussion
|
||||||
|
|
||||||
to: :first_note
|
to: :first_note
|
||||||
|
|
||||||
|
delegate :resolved_at,
|
||||||
|
:resolved_by,
|
||||||
|
|
||||||
|
to: :last_resolved_note,
|
||||||
|
allow_nil: true
|
||||||
|
|
||||||
delegate :blob, :highlighted_diff_lines, to: :diff_file, allow_nil: true
|
delegate :blob, :highlighted_diff_lines, to: :diff_file, allow_nil: true
|
||||||
|
|
||||||
def self.for_notes(notes)
|
def self.for_notes(notes)
|
||||||
|
@ -30,13 +36,30 @@ class Discussion
|
||||||
|
|
||||||
def initialize(notes)
|
def initialize(notes)
|
||||||
@first_note = notes.first
|
@first_note = notes.first
|
||||||
|
@last_note = notes.last
|
||||||
@notes = notes
|
@notes = notes
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def last_resolved_note
|
||||||
|
return unless resolved?
|
||||||
|
|
||||||
|
@last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last
|
||||||
|
end
|
||||||
|
|
||||||
|
def last_updated_at
|
||||||
|
last_note.created_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def last_updated_by
|
||||||
|
last_note.author
|
||||||
|
end
|
||||||
|
|
||||||
def id
|
def id
|
||||||
first_note.discussion_id
|
first_note.discussion_id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
alias_method :to_param, :id
|
||||||
|
|
||||||
def diff_discussion?
|
def diff_discussion?
|
||||||
first_note.diff_note?
|
first_note.diff_note?
|
||||||
end
|
end
|
||||||
|
@ -45,6 +68,50 @@ class Discussion
|
||||||
notes.any?(&:legacy_diff_note?)
|
notes.any?(&:legacy_diff_note?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolvable?
|
||||||
|
return @resolvable if defined?(@resolvable)
|
||||||
|
|
||||||
|
@resolvable = diff_discussion? && notes.any?(&:resolvable?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolved?
|
||||||
|
return @resolved if defined?(@resolved)
|
||||||
|
|
||||||
|
@resolved = resolvable? && notes.none?(&:to_be_resolved?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolved_notes
|
||||||
|
notes.select(&:resolved?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_be_resolved?
|
||||||
|
resolvable? && !resolved?
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_resolve?(current_user)
|
||||||
|
return false unless current_user
|
||||||
|
return false unless resolvable?
|
||||||
|
|
||||||
|
current_user == self.noteable.author ||
|
||||||
|
current_user.can?(:resolve_note, self.project)
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve!(current_user)
|
||||||
|
return unless resolvable?
|
||||||
|
|
||||||
|
notes.each do |note|
|
||||||
|
note.resolve!(current_user) if note.resolvable?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def unresolve!
|
||||||
|
return unless resolvable?
|
||||||
|
|
||||||
|
notes.each do |note|
|
||||||
|
note.unresolve! if note.resolvable?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def for_target?(target)
|
def for_target?(target)
|
||||||
self.noteable == target && !diff_discussion?
|
self.noteable == target && !diff_discussion?
|
||||||
end
|
end
|
||||||
|
@ -55,8 +122,20 @@ class Discussion
|
||||||
@active = first_note.active?
|
@active = first_note.active?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def collapsed?
|
||||||
|
return false unless diff_discussion?
|
||||||
|
|
||||||
|
if resolvable?
|
||||||
|
# New diff discussions only disappear once they are marked resolved
|
||||||
|
resolved?
|
||||||
|
else
|
||||||
|
# Old diff discussions disappear once they become outdated
|
||||||
|
!active?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def expanded?
|
def expanded?
|
||||||
!diff_discussion? || active?
|
!collapsed?
|
||||||
end
|
end
|
||||||
|
|
||||||
def reply_attributes
|
def reply_attributes
|
||||||
|
|
|
@ -8,8 +8,8 @@ class LegacyDiffNote < Note
|
||||||
before_create :set_diff
|
before_create :set_diff
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def build_discussion_id(noteable_type, noteable_id, line_code, active = true)
|
def build_discussion_id(noteable_type, noteable_id, line_code)
|
||||||
[super(noteable_type, noteable_id), line_code, active].join("-")
|
[super(noteable_type, noteable_id), line_code].join("-")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -21,10 +21,6 @@ class LegacyDiffNote < Note
|
||||||
{ line_code: line_code }
|
{ line_code: line_code }
|
||||||
end
|
end
|
||||||
|
|
||||||
def discussion_id
|
|
||||||
@discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
|
|
||||||
end
|
|
||||||
|
|
||||||
def project_repository
|
def project_repository
|
||||||
if RequestStore.active?
|
if RequestStore.active?
|
||||||
RequestStore.fetch("project:#{project_id}:repository") { self.project.repository }
|
RequestStore.fetch("project:#{project_id}:repository") { self.project.repository }
|
||||||
|
@ -119,4 +115,8 @@ class LegacyDiffNote < Note
|
||||||
diffs = noteable.raw_diffs(Commit.max_diff_options)
|
diffs = noteable.raw_diffs(Commit.max_diff_options)
|
||||||
diffs.find { |d| d.new_path == self.diff.new_path }
|
diffs.find { |d| d.new_path == self.diff.new_path }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_discussion_id
|
||||||
|
self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -418,6 +418,32 @@ class MergeRequest < ActiveRecord::Base
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def discussions
|
||||||
|
@discussions ||= self.mr_and_commit_notes.
|
||||||
|
inc_relations_for_view.
|
||||||
|
fresh.
|
||||||
|
discussions
|
||||||
|
end
|
||||||
|
|
||||||
|
def diff_discussions
|
||||||
|
@diff_discussions ||= self.notes.diff_notes.discussions
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_diff_discussion(discussion_id)
|
||||||
|
notes = self.notes.diff_notes.where(discussion_id: discussion_id).fresh.to_a
|
||||||
|
return if notes.empty?
|
||||||
|
|
||||||
|
Discussion.new(notes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def discussions_resolvable?
|
||||||
|
diff_discussions.any?(&:resolvable?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def discussions_resolved?
|
||||||
|
discussions_resolvable? && diff_discussions.none?(&:to_be_resolved?)
|
||||||
|
end
|
||||||
|
|
||||||
def hook_attrs
|
def hook_attrs
|
||||||
attrs = {
|
attrs = {
|
||||||
source: source_project.try(:hook_attrs),
|
source: source_project.try(:hook_attrs),
|
||||||
|
|
|
@ -25,6 +25,9 @@ class Note < ActiveRecord::Base
|
||||||
belongs_to :author, class_name: "User"
|
belongs_to :author, class_name: "User"
|
||||||
belongs_to :updated_by, class_name: "User"
|
belongs_to :updated_by, class_name: "User"
|
||||||
|
|
||||||
|
# Only used by DiffNote, but defined here so that it can be used in `Note.includes`
|
||||||
|
belongs_to :resolved_by, class_name: "User"
|
||||||
|
|
||||||
has_many :todos, dependent: :destroy
|
has_many :todos, dependent: :destroy
|
||||||
has_many :events, as: :target, dependent: :destroy
|
has_many :events, as: :target, dependent: :destroy
|
||||||
|
|
||||||
|
@ -59,7 +62,7 @@ class Note < ActiveRecord::Base
|
||||||
scope :fresh, ->{ order(created_at: :asc, id: :asc) }
|
scope :fresh, ->{ order(created_at: :asc, id: :asc) }
|
||||||
scope :inc_author_project, ->{ includes(:project, :author) }
|
scope :inc_author_project, ->{ includes(:project, :author) }
|
||||||
scope :inc_author, ->{ includes(:author) }
|
scope :inc_author, ->{ includes(:author) }
|
||||||
scope :inc_author_project_award_emoji, ->{ includes(:project, :author, :award_emoji) }
|
scope :inc_relations_for_view, ->{ includes(:project, :author, :updated_by, :resolved_by, :award_emoji) }
|
||||||
|
|
||||||
scope :diff_notes, ->{ where(type: ['LegacyDiffNote', 'DiffNote']) }
|
scope :diff_notes, ->{ where(type: ['LegacyDiffNote', 'DiffNote']) }
|
||||||
scope :non_diff_notes, ->{ where(type: ['Note', nil]) }
|
scope :non_diff_notes, ->{ where(type: ['Note', nil]) }
|
||||||
|
@ -70,7 +73,9 @@ class Note < ActiveRecord::Base
|
||||||
project: [:project_members, { group: [:group_members] }])
|
project: [:project_members, { group: [:group_members] }])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
after_initialize :ensure_discussion_id
|
||||||
before_validation :nullify_blank_type, :nullify_blank_line_code
|
before_validation :nullify_blank_type, :nullify_blank_line_code
|
||||||
|
before_validation :set_discussion_id
|
||||||
after_save :keep_around_commit
|
after_save :keep_around_commit
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
@ -82,13 +87,18 @@ class Note < ActiveRecord::Base
|
||||||
[:discussion, noteable_type.try(:underscore), noteable_id].join("-")
|
[:discussion, noteable_type.try(:underscore), noteable_id].join("-")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def discussion_id(*args)
|
||||||
|
Digest::SHA1.hexdigest(build_discussion_id(*args))
|
||||||
|
end
|
||||||
|
|
||||||
def discussions
|
def discussions
|
||||||
Discussion.for_notes(all)
|
Discussion.for_notes(all)
|
||||||
end
|
end
|
||||||
|
|
||||||
def grouped_diff_discussions
|
def grouped_diff_discussions
|
||||||
notes = diff_notes.fresh.select(&:active?)
|
active_notes = diff_notes.fresh.select(&:active?)
|
||||||
Discussion.for_diff_notes(notes).map { |d| [d.line_code, d] }.to_h
|
Discussion.for_diff_notes(active_notes).
|
||||||
|
map { |d| [d.line_code, d] }.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
# Searches for notes matching the given query.
|
# Searches for notes matching the given query.
|
||||||
|
@ -129,13 +139,16 @@ class Note < ActiveRecord::Base
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def discussion_id
|
def resolvable?
|
||||||
@discussion_id ||=
|
false
|
||||||
if for_merge_request?
|
end
|
||||||
[:discussion, :note, id].join("-")
|
|
||||||
else
|
def resolved?
|
||||||
self.class.build_discussion_id(noteable_type, noteable_id || commit_id)
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_be_resolved?
|
||||||
|
resolvable? && !resolved?
|
||||||
end
|
end
|
||||||
|
|
||||||
def max_attachment_size
|
def max_attachment_size
|
||||||
|
@ -243,4 +256,26 @@ class Note < ActiveRecord::Base
|
||||||
def nullify_blank_line_code
|
def nullify_blank_line_code
|
||||||
self.line_code = nil if self.line_code.blank?
|
self.line_code = nil if self.line_code.blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ensure_discussion_id
|
||||||
|
return unless self.persisted?
|
||||||
|
return if self.discussion_id
|
||||||
|
|
||||||
|
set_discussion_id
|
||||||
|
update_column(:discussion_id, self.discussion_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_discussion_id
|
||||||
|
self.discussion_id = Digest::SHA1.hexdigest(build_discussion_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_discussion_id
|
||||||
|
if for_merge_request?
|
||||||
|
# Notes on merge requests are always in a discussion of their own,
|
||||||
|
# so we generate a unique discussion ID.
|
||||||
|
[:discussion, :note, SecureRandom.hex].join("-")
|
||||||
|
else
|
||||||
|
self.class.build_discussion_id(noteable_type, noteable_id || commit_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
module MergeRequests
|
||||||
|
class ResolvedDiscussionNotificationService < MergeRequests::BaseService
|
||||||
|
def execute(merge_request)
|
||||||
|
return unless merge_request.discussions_resolved?
|
||||||
|
|
||||||
|
SystemNoteService.resolve_all_discussions(merge_request, project, current_user)
|
||||||
|
notification_service.resolve_all_discussions(merge_request, current_user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -148,6 +148,14 @@ class NotificationService
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolve_all_discussions(merge_request, current_user)
|
||||||
|
recipients = build_recipients(merge_request, merge_request.target_project, current_user, action: "resolve_all_discussions")
|
||||||
|
|
||||||
|
recipients.each do |recipient|
|
||||||
|
mailer.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id).deliver_later
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Notify new user with email after creation
|
# Notify new user with email after creation
|
||||||
def new_user(user, token = nil)
|
def new_user(user, token = nil)
|
||||||
# Don't email omniauth created users
|
# Don't email omniauth created users
|
||||||
|
|
|
@ -158,6 +158,12 @@ module SystemNoteService
|
||||||
create_note(noteable: noteable, project: project, author: author, note: body)
|
create_note(noteable: noteable, project: project, author: author, note: body)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.resolve_all_discussions(merge_request, project, author)
|
||||||
|
body = "Resolved all discussions"
|
||||||
|
|
||||||
|
create_note(noteable: merge_request, project: project, author: author, note: body)
|
||||||
|
end
|
||||||
|
|
||||||
# Called when the title of a Noteable is changed
|
# Called when the title of a Noteable is changed
|
||||||
#
|
#
|
||||||
# noteable - Noteable object that responds to `title`
|
# noteable - Noteable object that responds to `title`
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
%tr.notes_holder
|
- expanded = local_assigns.fetch(:expanded, true)
|
||||||
|
%tr.notes_holder{class: ('hide' unless expanded)}
|
||||||
%td.notes_line{ colspan: 2 }
|
%td.notes_line{ colspan: 2 }
|
||||||
%td.notes_content
|
%td.notes_content
|
||||||
%ul.notes{ data: { discussion_id: discussion.id } }
|
.content
|
||||||
= render partial: "projects/notes/note", collection: discussion.notes, as: :note
|
= render "discussions/notes", discussion: discussion
|
||||||
= link_to_reply_discussion(discussion)
|
|
||||||
|
|
|
@ -7,8 +7,11 @@
|
||||||
|
|
||||||
.diff-content.code.js-syntax-highlight
|
.diff-content.code.js-syntax-highlight
|
||||||
%table
|
%table
|
||||||
- discussion.truncated_diff_lines.each do |line|
|
- discussions = { discussion.line_code => discussion }
|
||||||
= render "projects/diffs/line", line: line, diff_file: diff_file, plain: true
|
= render partial: "projects/diffs/line",
|
||||||
|
collection: discussion.truncated_diff_lines,
|
||||||
- if discussion.for_line?(line)
|
as: :line,
|
||||||
= render "discussions/diff_discussion", discussion: discussion
|
locals: { diff_file: diff_file,
|
||||||
|
discussions: discussions,
|
||||||
|
discussion_expanded: true,
|
||||||
|
plain: true }
|
||||||
|
|
|
@ -5,8 +5,17 @@
|
||||||
= link_to user_path(discussion.author) do
|
= link_to user_path(discussion.author) do
|
||||||
= image_tag avatar_icon(discussion.author), class: "avatar s40"
|
= image_tag avatar_icon(discussion.author), class: "avatar s40"
|
||||||
.timeline-content
|
.timeline-content
|
||||||
.discussion.js-toggle-container{ class: discussion.id }
|
.discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } }
|
||||||
.discussion-header
|
.discussion-header
|
||||||
|
.discussion-actions
|
||||||
|
= link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do
|
||||||
|
- if expanded
|
||||||
|
= icon("chevron-up")
|
||||||
|
- else
|
||||||
|
= icon("chevron-down")
|
||||||
|
|
||||||
|
Toggle discussion
|
||||||
|
|
||||||
= link_to_member(@project, discussion.author, avatar: false)
|
= link_to_member(@project, discussion.author, avatar: false)
|
||||||
|
|
||||||
.inline.discussion-headline-light
|
.inline.discussion-headline-light
|
||||||
|
@ -29,17 +38,11 @@
|
||||||
|
|
||||||
= time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago")
|
= time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago")
|
||||||
|
|
||||||
.discussion-actions
|
= render "discussions/headline", discussion: discussion
|
||||||
= link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do
|
|
||||||
- if expanded
|
|
||||||
= icon("chevron-up")
|
|
||||||
- else
|
|
||||||
= icon("chevron-down")
|
|
||||||
|
|
||||||
Toggle discussion
|
|
||||||
|
|
||||||
.discussion-body.js-toggle-content{ class: ("hide" unless expanded) }
|
.discussion-body.js-toggle-content{ class: ("hide" unless expanded) }
|
||||||
- if discussion.diff_discussion? && discussion.diff_file
|
- if discussion.diff_discussion? && discussion.diff_file
|
||||||
= render "discussions/diff_with_notes", discussion: discussion
|
= render "discussions/diff_with_notes", discussion: discussion
|
||||||
- else
|
- else
|
||||||
= render "discussions/notes", discussion: discussion
|
.panel.panel-default
|
||||||
|
= render "discussions/notes", discussion: discussion
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
- if discussion.resolved?
|
||||||
|
.discussion-headline-light.js-discussion-headline
|
||||||
|
Resolved
|
||||||
|
- if discussion.resolved_by
|
||||||
|
by
|
||||||
|
= link_to_member(@project, discussion.resolved_by, avatar: false)
|
||||||
|
= time_ago_with_tooltip(discussion.resolved_at, placement: "bottom")
|
||||||
|
- elsif discussion.last_updated_at != discussion.created_at
|
||||||
|
.discussion-headline-light.js-discussion-headline
|
||||||
|
Last updated
|
||||||
|
- if discussion.last_updated_by
|
||||||
|
by
|
||||||
|
= link_to_member(@project, discussion.last_updated_by, avatar: false)
|
||||||
|
= time_ago_with_tooltip(discussion.last_updated_at, placement: "bottom")
|
|
@ -0,0 +1,9 @@
|
||||||
|
- discussion = local_assigns.fetch(:discussion, nil)
|
||||||
|
- if current_user
|
||||||
|
%jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" }
|
||||||
|
.btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" }
|
||||||
|
%button.btn.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion",
|
||||||
|
title: "Jump to next unresolved discussion",
|
||||||
|
"aria-label" => "Jump to next unresolved discussion",
|
||||||
|
data: { container: "body" } }
|
||||||
|
= custom_icon("next_discussion")
|
|
@ -1,5 +1,15 @@
|
||||||
.panel.panel-default
|
%ul.notes{ data: { discussion_id: discussion.id } }
|
||||||
.notes{ data: { discussion_id: discussion.id } }
|
= render partial: "projects/notes/note", collection: discussion.notes, as: :note
|
||||||
%ul.notes.timeline
|
|
||||||
= render partial: "projects/notes/note", collection: discussion.notes, as: :note
|
- if current_user
|
||||||
= link_to_reply_discussion(discussion)
|
.discussion-reply-holder
|
||||||
|
- if discussion.diff_discussion?
|
||||||
|
- line_type = local_assigns.fetch(:line_type, nil)
|
||||||
|
|
||||||
|
.btn-group-justified.discussion-with-resolve-btn{ role: "group" }
|
||||||
|
.btn-group{ role: "group" }
|
||||||
|
= link_to_reply_discussion(discussion, line_type)
|
||||||
|
= render "discussions/resolve_all", discussion: discussion
|
||||||
|
= render "discussions/jump_to_next", discussion: discussion
|
||||||
|
- else
|
||||||
|
= link_to_reply_discussion(discussion)
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
%tr.notes_holder
|
- expanded = discussion_left.try(:expanded?) || discussion_right.try(:expanded?)
|
||||||
|
%tr.notes_holder{class: ('hide' unless expanded)}
|
||||||
- if discussion_left
|
- if discussion_left
|
||||||
%td.notes_line.old
|
%td.notes_line.old
|
||||||
%td.notes_content.parallel.old
|
%td.notes_content.parallel.old
|
||||||
%ul.notes{ data: { discussion_id: discussion_left.id } }
|
.content{class: ('hide' unless discussion_left.expanded?)}
|
||||||
= render partial: "projects/notes/note", collection: discussion_left.notes, as: :note
|
= render "discussions/notes", discussion: discussion_left, line_type: 'old'
|
||||||
|
|
||||||
= link_to_reply_discussion(discussion_left, 'old')
|
|
||||||
- else
|
- else
|
||||||
%td.notes_line.old= ""
|
%td.notes_line.old= ""
|
||||||
%td.notes_content.parallel.old= ""
|
%td.notes_content.parallel.old
|
||||||
|
.content
|
||||||
|
|
||||||
- if discussion_right
|
- if discussion_right
|
||||||
%td.notes_line.new
|
%td.notes_line.new
|
||||||
%td.notes_content.parallel.new
|
%td.notes_content.parallel.new
|
||||||
%ul.notes{ data: { discussion_id: discussion_right.id } }
|
.content{class: ('hide' unless discussion_right.expanded?)}
|
||||||
= render partial: "projects/notes/note", collection: discussion_right.notes, as: :note
|
= render "discussions/notes", discussion: discussion_right, line_type: 'new'
|
||||||
|
|
||||||
= link_to_reply_discussion(discussion_right, 'new')
|
|
||||||
- else
|
- else
|
||||||
%td.notes_line.new= ""
|
%td.notes_line.new= ""
|
||||||
%td.notes_content.parallel.new= ""
|
%td.notes_content.parallel.new
|
||||||
|
.content
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
- if discussion.for_merge_request?
|
||||||
|
%resolve-discussion-btn{ ":namespace-path" => "'#{discussion.project.namespace.path}'",
|
||||||
|
":project-path" => "'#{discussion.project.path}'",
|
||||||
|
":discussion-id" => "'#{discussion.id}'",
|
||||||
|
":merge-request-id" => discussion.noteable.iid,
|
||||||
|
":can-resolve" => discussion.can_resolve?(current_user),
|
||||||
|
"inline-template" => true }
|
||||||
|
.btn-group{ role: "group", "v-if" => "showButton" }
|
||||||
|
%button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading" }
|
||||||
|
= icon("spinner spin", "v-show" => "loading")
|
||||||
|
{{ buttonText }}
|
|
@ -75,8 +75,7 @@
|
||||||
- blob = diff_file.blob
|
- blob = diff_file.blob
|
||||||
- if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob)
|
- if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob)
|
||||||
%table.code.white
|
%table.code.white
|
||||||
- diff_file.highlighted_diff_lines.each do |line|
|
= render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true }
|
||||||
= render "projects/diffs/line", line: line, diff_file: diff_file, plain: true, email: true
|
|
||||||
- else
|
- else
|
||||||
No preview for this file type
|
No preview for this file type
|
||||||
%br
|
%br
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
%p
|
||||||
|
All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{@resolved_by.name}
|
|
@ -0,0 +1,3 @@
|
||||||
|
All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= @resolved_by.name %>
|
||||||
|
|
||||||
|
<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %>
|
|
@ -1,7 +1,7 @@
|
||||||
- email = local_assigns.fetch(:email, false)
|
- email = local_assigns.fetch(:email, false)
|
||||||
- plain = local_assigns.fetch(:plain, false)
|
- plain = local_assigns.fetch(:plain, false)
|
||||||
- type = line.type
|
- type = line.type
|
||||||
- line_code = diff_file.line_code(line) unless plain
|
- line_code = diff_file.line_code(line)
|
||||||
%tr.line_holder{ plain ? { class: type} : { class: type, id: line_code } }
|
%tr.line_holder{ plain ? { class: type} : { class: type, id: line_code } }
|
||||||
- case type
|
- case type
|
||||||
- when 'match'
|
- when 'match'
|
||||||
|
@ -28,3 +28,10 @@
|
||||||
%pre= diff_line_content(line.text, type)
|
%pre= diff_line_content(line.text, type)
|
||||||
- else
|
- else
|
||||||
= diff_line_content(line.text, type)
|
= diff_line_content(line.text, type)
|
||||||
|
|
||||||
|
- discussions = local_assigns.fetch(:discussions, nil)
|
||||||
|
- if discussions && !line.meta?
|
||||||
|
- discussion = discussions[line_code]
|
||||||
|
- if discussion
|
||||||
|
- discussion_expanded = local_assigns.fetch(:discussion_expanded, discussion.expanded?)
|
||||||
|
= render "discussions/diff_discussion", discussion: discussion, expanded: discussion_expanded
|
||||||
|
|
|
@ -5,15 +5,12 @@
|
||||||
|
|
||||||
%table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' }
|
%table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' }
|
||||||
- last_line = 0
|
- last_line = 0
|
||||||
- diff_file.highlighted_diff_lines.each do |line|
|
- discussions = @grouped_diff_discussions unless @diff_notes_disabled
|
||||||
- last_line = line.new_pos
|
= render partial: "projects/diffs/line",
|
||||||
= render "projects/diffs/line", line: line, diff_file: diff_file
|
collection: diff_file.highlighted_diff_lines,
|
||||||
|
as: :line,
|
||||||
- unless @diff_notes_disabled
|
locals: { diff_file: diff_file, discussions: discussions }
|
||||||
- line_code = diff_file.line_code(line)
|
|
||||||
- discussion = @grouped_diff_discussions[line_code] if line_code
|
|
||||||
- if discussion
|
|
||||||
= render "discussions/diff_discussion", discussion: discussion
|
|
||||||
|
|
||||||
|
- last_line = diff_file.highlighted_diff_lines.last.new_pos
|
||||||
- if !diff_file.new_file && last_line > 0
|
- if !diff_file.new_file && last_line > 0
|
||||||
= diff_match_line last_line, last_line, bottom: true
|
= diff_match_line last_line, last_line, bottom: true
|
||||||
|
|
|
@ -4,5 +4,8 @@
|
||||||
= link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"}
|
= link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"}
|
||||||
- if @merge_request.closed?
|
- if @merge_request.closed?
|
||||||
= link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
|
= link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
|
||||||
|
%comment-and-resolve-btn{ "inline-template" => true, ":discussion-id" => "" }
|
||||||
|
%button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { namespace_path: "#{@merge_request.project.namespace.path}", project_path: "#{@merge_request.project.path}" } }
|
||||||
|
{{ buttonText }}
|
||||||
|
|
||||||
#notes= render "projects/notes/notes_with_form"
|
#notes= render "projects/notes/notes_with_form"
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
|
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
|
||||||
- page_description @merge_request.description
|
- page_description @merge_request.description
|
||||||
- page_card_attributes @merge_request.card_attributes
|
- page_card_attributes @merge_request.card_attributes
|
||||||
|
- content_for :page_specific_javascripts do
|
||||||
|
= page_specific_javascript_tag('diff_notes/diff_notes_bundle.js')
|
||||||
|
|
||||||
- if diff_view == :parallel
|
- if diff_view == :parallel
|
||||||
- fluid_layout true
|
- fluid_layout true
|
||||||
|
@ -65,8 +67,18 @@
|
||||||
= link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do
|
= link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do
|
||||||
Changes
|
Changes
|
||||||
%span.badge= @merge_request.diff_size
|
%span.badge= @merge_request.diff_size
|
||||||
|
%li#resolve-count-app.line-resolve-all-container.pull-right.prepend-top-10.hidden-xs{ "v-cloak" => true }
|
||||||
|
%resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
|
||||||
|
.line-resolve-all{ "v-show" => "discussionCount > 0",
|
||||||
|
":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
|
||||||
|
%span.line-resolve-btn.is-disabled{ type: "button",
|
||||||
|
":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
|
||||||
|
= render "shared/icons/icon_status_success.svg"
|
||||||
|
%span.line-resolve-text
|
||||||
|
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ discussionCount | pluralize 'discussion' }} resolved
|
||||||
|
= render "discussions/jump_to_next"
|
||||||
|
|
||||||
.tab-content
|
.tab-content#diff-notes-app
|
||||||
#notes.notes.tab-pane.voting_notes
|
#notes.notes.tab-pane.voting_notes
|
||||||
.content-block.content-block-small.oneline-block
|
.content-block.content-block-small.oneline-block
|
||||||
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true
|
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form" }, authenticity_token: true do |f|
|
= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
|
||||||
= hidden_field_tag :view, diff_view
|
= hidden_field_tag :view, diff_view
|
||||||
= hidden_field_tag :line_type
|
= hidden_field_tag :line_type
|
||||||
= note_target_fields(@note)
|
= note_target_fields(@note)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
- return unless note.author
|
- return unless note.author
|
||||||
- return if note.cross_reference_not_visible_for?(current_user)
|
- return if note.cross_reference_not_visible_for?(current_user)
|
||||||
|
- can_resolve = can?(current_user, :resolve_note, note)
|
||||||
|
|
||||||
- note_editable = note_editable?(note)
|
- note_editable = note_editable?(note)
|
||||||
%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable} }
|
%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable} }
|
||||||
|
@ -16,19 +17,48 @@
|
||||||
commented
|
commented
|
||||||
%a{ href: "##{dom_id(note)}" }
|
%a{ href: "##{dom_id(note)}" }
|
||||||
= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
|
= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
|
||||||
.note-actions
|
- unless note.system?
|
||||||
- access = note_max_access_for_user(note)
|
.note-actions
|
||||||
- if access and not note.system
|
- access = note_max_access_for_user(note)
|
||||||
%span.note-role.hidden-xs= access
|
- if access
|
||||||
- if current_user and not note.system
|
%span.note-role.hidden-xs= access
|
||||||
= link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do
|
|
||||||
= icon('spinner spin')
|
- if note.resolvable?
|
||||||
= icon('smile-o')
|
%resolve-btn{ ":namespace-path" => "'#{note.project.namespace.path}'",
|
||||||
- if note_editable
|
":project-path" => "'#{note.project.path}'",
|
||||||
= link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do
|
":discussion-id" => "'#{note.discussion_id}'",
|
||||||
= icon('pencil')
|
":note-id" => note.id,
|
||||||
= link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do
|
":resolved" => note.resolved?,
|
||||||
= icon('trash-o')
|
":can-resolve" => can_resolve,
|
||||||
|
":resolved-by" => "'#{note.resolved_by.try(:name)}'",
|
||||||
|
"v-show" => "#{can_resolve || note.resolved?}",
|
||||||
|
"inline-template" => true,
|
||||||
|
"v-ref:note_#{note.id}" => true }
|
||||||
|
|
||||||
|
.note-action-button
|
||||||
|
= icon("spin spinner", "v-show" => "loading")
|
||||||
|
%button.line-resolve-btn{ type: "button",
|
||||||
|
class: ("is-disabled" unless can_resolve),
|
||||||
|
":class" => "{ 'is-active': isResolved }",
|
||||||
|
":aria-label" => "buttonText",
|
||||||
|
"@click" => "resolve",
|
||||||
|
":title" => "buttonText",
|
||||||
|
"v-show" => "!loading",
|
||||||
|
"v-el:button" => true }
|
||||||
|
|
||||||
|
= render "shared/icons/icon_status_success.svg"
|
||||||
|
|
||||||
|
- if current_user
|
||||||
|
- if note.emoji_awardable?
|
||||||
|
= link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do
|
||||||
|
= icon('spinner spin')
|
||||||
|
= icon('smile-o')
|
||||||
|
|
||||||
|
- if note_editable
|
||||||
|
= link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do
|
||||||
|
= icon('pencil')
|
||||||
|
= link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do
|
||||||
|
= icon('trash-o')
|
||||||
.note-body{class: note_editable ? 'js-task-list-container' : ''}
|
.note-body{class: note_editable ? 'js-task-list-container' : ''}
|
||||||
.note-text.md
|
.note-text.md
|
||||||
= preserve do
|
= preserve do
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<svg viewBox="0 0 20 19" ><path d="M15.21 7.783h-3.317c-.268 0-.472.218-.472.486v.953c0 .28.212.486.473.486h3.318v1.575c0 .36.233.452.52.23l3.06-2.37c.274-.213.286-.582 0-.804l-3.06-2.37c-.275-.213-.52-.12-.52.23v1.583zm.57-3.66c-1.558-1.22-3.783-1.98-6.254-1.98C4.816 2.143 1 4.91 1 8.333c0 1.964 1.256 3.715 3.216 4.846-.447 1.615-1.132 2.195-1.732 2.882-.142.174-.304.32-.256.56v.01c.047.213.218.368.41.368h.046c.37-.048.743-.116 1.085-.213 1.645-.425 3.13-1.22 4.377-2.34.447.048.913.077 1.38.077 2.092 0 4.01-.546 5.492-1.454-.416-.208-.798-.475-1.134-.792-1.227.63-2.743 1.008-4.36 1.008-.41 0-.828-.03-1.237-.078l-.543-.058-.41.368c-.78.696-1.655 1.248-2.616 1.654.248-.445.486-.977.667-1.664l.257-.928-.828-.484c-1.646-.948-2.598-2.32-2.598-3.763 0-2.69 3.35-4.952 7.308-4.952 1.893 0 3.647.518 4.962 1.353.393-.266.827-.473 1.29-.61z" /></svg>
|
After Width: | Height: | Size: 853 B |
|
@ -85,6 +85,7 @@ module Gitlab
|
||||||
config.assets.precompile << "users/users_bundle.js"
|
config.assets.precompile << "users/users_bundle.js"
|
||||||
config.assets.precompile << "network/network_bundle.js"
|
config.assets.precompile << "network/network_bundle.js"
|
||||||
config.assets.precompile << "profile/profile_bundle.js"
|
config.assets.precompile << "profile/profile_bundle.js"
|
||||||
|
config.assets.precompile << "diff_notes/diff_notes_bundle.js"
|
||||||
config.assets.precompile << "boards/boards_bundle.js"
|
config.assets.precompile << "boards/boards_bundle.js"
|
||||||
config.assets.precompile << "boards/test_utils/simulate_drag.js"
|
config.assets.precompile << "boards/test_utils/simulate_drag.js"
|
||||||
config.assets.precompile << "lib/utils/*.js"
|
config.assets.precompile << "lib/utils/*.js"
|
||||||
|
|
|
@ -749,6 +749,13 @@ Rails.application.routes.draw do
|
||||||
get :update_branches
|
get :update_branches
|
||||||
get :diff_for_path
|
get :diff_for_path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :discussions, only: [], constraints: { id: /\h{40}/ } do
|
||||||
|
member do
|
||||||
|
post :resolve
|
||||||
|
delete :resolve, action: :unresolve
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :branches, only: [:index, :new, :create, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex }
|
resources :branches, only: [:index, :new, :create, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex }
|
||||||
|
@ -858,6 +865,8 @@ Rails.application.routes.draw do
|
||||||
member do
|
member do
|
||||||
post :toggle_award_emoji
|
post :toggle_award_emoji
|
||||||
delete :delete_attachment
|
delete :delete_attachment
|
||||||
|
post :resolve
|
||||||
|
delete :resolve, action: :unresolve
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
class AddResolvedToNotes < ActiveRecord::Migration
|
||||||
|
include Gitlab::Database::MigrationHelpers
|
||||||
|
|
||||||
|
DOWNTIME = false
|
||||||
|
|
||||||
|
def change
|
||||||
|
add_column :notes, :resolved_at, :datetime
|
||||||
|
add_column :notes, :resolved_by_id, :integer
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,13 @@
|
||||||
|
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
|
||||||
|
# for more information on how to write migrations for GitLab.
|
||||||
|
|
||||||
|
class AddDiscussionIdsToNotes < ActiveRecord::Migration
|
||||||
|
include Gitlab::Database::MigrationHelpers
|
||||||
|
|
||||||
|
DOWNTIME = false
|
||||||
|
|
||||||
|
def change
|
||||||
|
add_column :notes, :discussion_id, :string
|
||||||
|
add_column :notes, :original_discussion_id, :string
|
||||||
|
end
|
||||||
|
end
|
|
@ -11,7 +11,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 20160816161312) do
|
ActiveRecord::Schema.define(version: 20160817154936) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -684,12 +684,16 @@ ActiveRecord::Schema.define(version: 20160816161312) do
|
||||||
t.string "line_code"
|
t.string "line_code"
|
||||||
t.string "commit_id"
|
t.string "commit_id"
|
||||||
t.integer "noteable_id"
|
t.integer "noteable_id"
|
||||||
t.boolean "system", default: false, null: false
|
t.boolean "system", default: false, null: false
|
||||||
t.text "st_diff"
|
t.text "st_diff"
|
||||||
t.integer "updated_by_id"
|
t.integer "updated_by_id"
|
||||||
t.string "type"
|
t.string "type"
|
||||||
t.text "position"
|
t.text "position"
|
||||||
t.text "original_position"
|
t.text "original_position"
|
||||||
|
t.datetime "resolved_at"
|
||||||
|
t.integer "resolved_by_id"
|
||||||
|
t.string "discussion_id"
|
||||||
|
t.string "original_discussion_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree
|
add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 286 KiB |
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
|
@ -0,0 +1,40 @@
|
||||||
|
# Merge Request discussion resolution
|
||||||
|
|
||||||
|
> [Introduced][ce-5022] in GitLab 8.11.
|
||||||
|
|
||||||
|
Discussion resolution helps keep track of progress during code review.
|
||||||
|
Resolving comments prevents you from forgetting to address feedback and lets you
|
||||||
|
hide discussions that are no longer relevant.
|
||||||
|
|
||||||
|
!["A discussion between two people on a piece of code"][discussion-view]
|
||||||
|
|
||||||
|
Comments and discussions can be resolved by anyone with at least Developer
|
||||||
|
access to the project, as well as by the author of the merge request.
|
||||||
|
|
||||||
|
## Marking a comment or discussion as resolved
|
||||||
|
|
||||||
|
You can mark a discussion as resolved by clicking the "Resolve discussion"
|
||||||
|
button at the bottom of the discussion.
|
||||||
|
|
||||||
|
!["Resolve discussion" button][resolve-discussion-button]
|
||||||
|
|
||||||
|
Alternatively, you can mark each comment as resolved individually.
|
||||||
|
|
||||||
|
!["Resolve comment" button][resolve-comment-button]
|
||||||
|
|
||||||
|
## Jumping between unresolved discussions
|
||||||
|
|
||||||
|
When a merge request has a large number of comments it can be difficult to track
|
||||||
|
what remains unresolved. You can jump between unresolved discussions with the
|
||||||
|
Jump button next to the Reply field on a discussion.
|
||||||
|
|
||||||
|
You can also jump to the first unresolved discussion from the button next to the
|
||||||
|
resolved discussions tracker.
|
||||||
|
|
||||||
|
!["3/4 discussions resolved"][discussions-resolved]
|
||||||
|
|
||||||
|
[ce-5022]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5022
|
||||||
|
[resolve-discussion-button]: img/resolve_discussion_button.png
|
||||||
|
[resolve-comment-button]: img/resolve_comment_button.png
|
||||||
|
[discussion-view]: img/discussion_view.png
|
||||||
|
[discussions-resolved]: img/discussions_resolved.png
|
|
@ -0,0 +1,125 @@
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Projects::DiscussionsController do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:project) { create(:project) }
|
||||||
|
let(:merge_request) { create(:merge_request, source_project: project) }
|
||||||
|
let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) }
|
||||||
|
let(:discussion) { note.discussion }
|
||||||
|
|
||||||
|
let(:request_params) do
|
||||||
|
{
|
||||||
|
namespace_id: project.namespace,
|
||||||
|
project_id: project,
|
||||||
|
merge_request_id: merge_request,
|
||||||
|
id: note.discussion_id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST resolve' do
|
||||||
|
before do
|
||||||
|
sign_in user
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the user is not authorized to resolve the discussion" do
|
||||||
|
it "returns status 404" do
|
||||||
|
post :resolve, request_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the user is authorized to resolve the discussion" do
|
||||||
|
before do
|
||||||
|
project.team << [user, :developer]
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the discussion is not resolvable" do
|
||||||
|
before do
|
||||||
|
note.update(system: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns status 404" do
|
||||||
|
post :resolve, request_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the discussion is resolvable" do
|
||||||
|
it "resolves the discussion" do
|
||||||
|
post :resolve, request_params
|
||||||
|
|
||||||
|
expect(note.reload.discussion.resolved?).to be true
|
||||||
|
expect(note.reload.discussion.resolved_by).to eq(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sends notifications if all discussions are resolved" do
|
||||||
|
expect_any_instance_of(MergeRequests::ResolvedDiscussionNotificationService).to receive(:execute).with(merge_request)
|
||||||
|
|
||||||
|
post :resolve, request_params
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns the name of the resolving user" do
|
||||||
|
post :resolve, request_params
|
||||||
|
|
||||||
|
expect(JSON.parse(response.body)["resolved_by"]).to eq(user.name)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns status 200" do
|
||||||
|
post :resolve, request_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'DELETE unresolve' do
|
||||||
|
before do
|
||||||
|
sign_in user
|
||||||
|
|
||||||
|
note.discussion.resolve!(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the user is not authorized to resolve the discussion" do
|
||||||
|
it "returns status 404" do
|
||||||
|
delete :unresolve, request_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the user is authorized to resolve the discussion" do
|
||||||
|
before do
|
||||||
|
project.team << [user, :developer]
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the discussion is not resolvable" do
|
||||||
|
before do
|
||||||
|
note.update(system: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns status 404" do
|
||||||
|
delete :unresolve, request_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the discussion is resolvable" do
|
||||||
|
it "unresolves the discussion" do
|
||||||
|
delete :unresolve, request_params
|
||||||
|
|
||||||
|
expect(note.reload.discussion.resolved?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns status 200" do
|
||||||
|
delete :unresolve, request_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,4 +1,4 @@
|
||||||
require('spec_helper')
|
require 'spec_helper'
|
||||||
|
|
||||||
describe Projects::NotesController do
|
describe Projects::NotesController do
|
||||||
let(:user) { create(:user) }
|
let(:user) { create(:user) }
|
||||||
|
@ -6,7 +6,15 @@ describe Projects::NotesController do
|
||||||
let(:issue) { create(:issue, project: project) }
|
let(:issue) { create(:issue, project: project) }
|
||||||
let(:note) { create(:note, noteable: issue, project: project) }
|
let(:note) { create(:note, noteable: issue, project: project) }
|
||||||
|
|
||||||
describe 'POST #toggle_award_emoji' do
|
let(:request_params) do
|
||||||
|
{
|
||||||
|
namespace_id: project.namespace,
|
||||||
|
project_id: project,
|
||||||
|
id: note
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST toggle_award_emoji' do
|
||||||
before do
|
before do
|
||||||
sign_in(user)
|
sign_in(user)
|
||||||
project.team << [user, :developer]
|
project.team << [user, :developer]
|
||||||
|
@ -14,23 +22,132 @@ describe Projects::NotesController do
|
||||||
|
|
||||||
it "toggles the award emoji" do
|
it "toggles the award emoji" do
|
||||||
expect do
|
expect do
|
||||||
post(:toggle_award_emoji, namespace_id: project.namespace.path,
|
post(:toggle_award_emoji, request_params.merge(name: "thumbsup"))
|
||||||
project_id: project.path, id: note.id, name: "thumbsup")
|
|
||||||
end.to change { note.award_emoji.count }.by(1)
|
end.to change { note.award_emoji.count }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "removes the already awarded emoji" do
|
it "removes the already awarded emoji" do
|
||||||
post(:toggle_award_emoji, namespace_id: project.namespace.path,
|
post(:toggle_award_emoji, request_params.merge(name: "thumbsup"))
|
||||||
project_id: project.path, id: note.id, name: "thumbsup")
|
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
post(:toggle_award_emoji, namespace_id: project.namespace.path,
|
post(:toggle_award_emoji, request_params.merge(name: "thumbsup"))
|
||||||
project_id: project.path, id: note.id, name: "thumbsup")
|
|
||||||
end.to change { AwardEmoji.count }.by(-1)
|
end.to change { AwardEmoji.count }.by(-1)
|
||||||
|
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "resolving and unresolving" do
|
||||||
|
let(:merge_request) { create(:merge_request, source_project: project) }
|
||||||
|
let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) }
|
||||||
|
|
||||||
|
describe 'POST resolve' do
|
||||||
|
before do
|
||||||
|
sign_in user
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the user is not authorized to resolve the note" do
|
||||||
|
it "returns status 404" do
|
||||||
|
post :resolve, request_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the user is authorized to resolve the note" do
|
||||||
|
before do
|
||||||
|
project.team << [user, :developer]
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the note is not resolvable" do
|
||||||
|
before do
|
||||||
|
note.update(system: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns status 404" do
|
||||||
|
post :resolve, request_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the note is resolvable" do
|
||||||
|
it "resolves the note" do
|
||||||
|
post :resolve, request_params
|
||||||
|
|
||||||
|
expect(note.reload.resolved?).to be true
|
||||||
|
expect(note.reload.resolved_by).to eq(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sends notifications if all discussions are resolved" do
|
||||||
|
expect_any_instance_of(MergeRequests::ResolvedDiscussionNotificationService).to receive(:execute).with(merge_request)
|
||||||
|
|
||||||
|
post :resolve, request_params
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns the name of the resolving user" do
|
||||||
|
post :resolve, request_params
|
||||||
|
|
||||||
|
expect(JSON.parse(response.body)["resolved_by"]).to eq(user.name)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns status 200" do
|
||||||
|
post :resolve, request_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'DELETE unresolve' do
|
||||||
|
before do
|
||||||
|
sign_in user
|
||||||
|
|
||||||
|
note.resolve!(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the user is not authorized to resolve the note" do
|
||||||
|
it "returns status 404" do
|
||||||
|
delete :unresolve, request_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the user is authorized to resolve the note" do
|
||||||
|
before do
|
||||||
|
project.team << [user, :developer]
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the note is not resolvable" do
|
||||||
|
before do
|
||||||
|
note.update(system: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns status 404" do
|
||||||
|
delete :unresolve, request_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the note is resolvable" do
|
||||||
|
it "unresolves the note" do
|
||||||
|
delete :unresolve, request_params
|
||||||
|
|
||||||
|
expect(note.reload.resolved?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns status 200" do
|
||||||
|
delete :unresolve, request_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,497 @@
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
feature 'Diff notes resolve', feature: true, js: true do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:project) { create(:project, :public) }
|
||||||
|
let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user, title: "Bug NS-04") }
|
||||||
|
let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) }
|
||||||
|
let(:path) { "files/ruby/popen.rb" }
|
||||||
|
let(:position) do
|
||||||
|
Gitlab::Diff::Position.new(
|
||||||
|
old_path: path,
|
||||||
|
new_path: path,
|
||||||
|
old_line: nil,
|
||||||
|
new_line: 9,
|
||||||
|
diff_refs: merge_request.diff_refs
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'no discussions' do
|
||||||
|
before do
|
||||||
|
project.team << [user, :master]
|
||||||
|
login_as user
|
||||||
|
note.destroy
|
||||||
|
visit_merge_request
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'displays no discussion resolved data' do
|
||||||
|
expect(page).not_to have_content('discussion resolved')
|
||||||
|
expect(page).not_to have_selector('.discussion-next-btn')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'as authorized user' do
|
||||||
|
before do
|
||||||
|
project.team << [user, :master]
|
||||||
|
login_as user
|
||||||
|
visit_merge_request
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'single discussion' do
|
||||||
|
it 'shows text with how many discussions' do
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('0/1 discussion resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to mark a note as resolved' do
|
||||||
|
page.within '.diff-content .note' do
|
||||||
|
find('.line-resolve-btn').click
|
||||||
|
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
expect(find('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}")
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.diff-content' do
|
||||||
|
expect(page).to have_selector('.btn', text: 'Unresolve discussion')
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('1/1 discussion resolved')
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to mark discussion as resolved' do
|
||||||
|
page.within '.diff-content' do
|
||||||
|
click_button 'Resolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.diff-content .note' do
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
|
||||||
|
expect(find('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}")
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('1/1 discussion resolved')
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to unresolve discussion' do
|
||||||
|
page.within '.diff-content' do
|
||||||
|
click_button 'Resolve discussion'
|
||||||
|
click_button 'Unresolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('0/1 discussion resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'hides resolved discussion' do
|
||||||
|
page.within '.diff-content' do
|
||||||
|
click_button 'Resolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
visit_merge_request
|
||||||
|
|
||||||
|
expect(page).to have_selector('.discussion-body', visible: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to resolve from reply form without a comment' do
|
||||||
|
page.within '.diff-content' do
|
||||||
|
click_button 'Reply...'
|
||||||
|
|
||||||
|
click_button 'Resolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('1/1 discussion resolved')
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to unresolve from reply form without a comment' do
|
||||||
|
page.within '.diff-content' do
|
||||||
|
click_button 'Resolve discussion'
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
click_button 'Reply...'
|
||||||
|
|
||||||
|
click_button 'Unresolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('0/1 discussion resolved')
|
||||||
|
expect(page).not_to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to comment & resolve discussion' do
|
||||||
|
page.within '.diff-content' do
|
||||||
|
click_button 'Reply...'
|
||||||
|
|
||||||
|
find('.js-note-text').set 'testing'
|
||||||
|
|
||||||
|
click_button 'Comment & resolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('1/1 discussion resolved')
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to comment & unresolve discussion' do
|
||||||
|
page.within '.diff-content' do
|
||||||
|
click_button 'Resolve discussion'
|
||||||
|
|
||||||
|
click_button 'Reply...'
|
||||||
|
|
||||||
|
find('.js-note-text').set 'testing'
|
||||||
|
|
||||||
|
click_button 'Comment & unresolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('0/1 discussion resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to quickly scroll to next unresolved discussion' do
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
page.find('.discussion-next-btn').click
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(page.evaluate_script("$('body').scrollTop()")).to be > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'hides jump to next button when all resolved' do
|
||||||
|
page.within '.diff-content' do
|
||||||
|
click_button 'Resolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(page).to have_selector('.discussion-next-btn', visible: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates updated text after resolving note' do
|
||||||
|
page.within '.diff-content .note' do
|
||||||
|
find('.line-resolve-btn').click
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(page).to have_content("Resolved by #{user.name}")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'hides jump to next discussion button' do
|
||||||
|
page.within '.discussion-reply-holder' do
|
||||||
|
expect(page).not_to have_selector('.discussion-next-btn')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'multiple notes' do
|
||||||
|
before do
|
||||||
|
create(:diff_note_on_merge_request, project: project, noteable: merge_request)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not mark discussion as resolved when resolving single note' do
|
||||||
|
page.within '.diff-content .note' do
|
||||||
|
first('.line-resolve-btn').click
|
||||||
|
sleep 1
|
||||||
|
expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}")
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(page).to have_content('Last updated')
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('0/1 discussion resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'resolves discussion' do
|
||||||
|
page.all('.note').each do |note|
|
||||||
|
note.find('.line-resolve-btn').click
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(page).to have_content('Resolved by')
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('1/1 discussion resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'muliple discussions' do
|
||||||
|
before do
|
||||||
|
create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request)
|
||||||
|
visit_merge_request
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows text with how many discussions' do
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('0/2 discussions resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to mark a single note as resolved' do
|
||||||
|
click_button('Resolve discussion', match: :first)
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('1/2 discussions resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to mark all notes as resolved' do
|
||||||
|
page.all('.line-resolve-btn').each do |btn|
|
||||||
|
btn.click
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('2/2 discussions resolved')
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user user to mark all discussions as resolved' do
|
||||||
|
page.all('.discussion-reply-holder').each do |reply_holder|
|
||||||
|
page.within reply_holder do
|
||||||
|
click_button 'Resolve discussion'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('2/2 discussions resolved')
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to quickly scroll to next unresolved discussion' do
|
||||||
|
page.within first('.discussion-reply-holder') do
|
||||||
|
click_button 'Resolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
page.find('.discussion-next-btn').click
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(page.evaluate_script("$('body').scrollTop()")).to be > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates updated text after resolving note' do
|
||||||
|
page.within first('.diff-content .note') do
|
||||||
|
find('.line-resolve-btn').click
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(page).to have_content("Resolved by #{user.name}")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows jump to next discussion button' do
|
||||||
|
page.all('.discussion-reply-holder').each do |holder|
|
||||||
|
expect(holder).to have_selector('.discussion-next-btn')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'displays next discussion even if hidden' do
|
||||||
|
page.all('.note-discussion').each do |discussion|
|
||||||
|
page.within discussion do
|
||||||
|
click_link 'Toggle discussion'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within('.issuable-discussion #notes') do
|
||||||
|
expect(page).not_to have_selector('.btn', text: 'Resolve discussion')
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
page.find('.discussion-next-btn').click
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'changes tab' do
|
||||||
|
it 'shows text with how many discussions' do
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('0/1 discussion resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to mark a note as resolved' do
|
||||||
|
page.within '.diff-content .note' do
|
||||||
|
find('.line-resolve-btn').click
|
||||||
|
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.diff-content' do
|
||||||
|
expect(page).to have_selector('.btn', text: 'Unresolve discussion')
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('1/1 discussion resolved')
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to mark discussion as resolved' do
|
||||||
|
page.within '.diff-content' do
|
||||||
|
click_button 'Resolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.diff-content .note' do
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('1/1 discussion resolved')
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to unresolve discussion' do
|
||||||
|
page.within '.diff-content' do
|
||||||
|
click_button 'Resolve discussion'
|
||||||
|
click_button 'Unresolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('0/1 discussion resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to comment & resolve discussion' do
|
||||||
|
page.within '.diff-content' do
|
||||||
|
click_button 'Reply...'
|
||||||
|
|
||||||
|
find('.js-note-text').set 'testing'
|
||||||
|
|
||||||
|
click_button 'Comment & resolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('1/1 discussion resolved')
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to comment & unresolve discussion' do
|
||||||
|
page.within '.diff-content' do
|
||||||
|
click_button 'Resolve discussion'
|
||||||
|
|
||||||
|
click_button 'Reply...'
|
||||||
|
|
||||||
|
find('.js-note-text').set 'testing'
|
||||||
|
|
||||||
|
click_button 'Comment & unresolve discussion'
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('0/1 discussion resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'as a guest' do
|
||||||
|
let(:guest) { create(:user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
project.team << [guest, :guest]
|
||||||
|
login_as guest
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'someone elses merge request' do
|
||||||
|
before do
|
||||||
|
visit_merge_request
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not allow user to mark note as resolved' do
|
||||||
|
page.within '.diff-content .note' do
|
||||||
|
expect(page).not_to have_selector('.line-resolve-btn')
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('0/1 discussion resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not allow user to mark discussion as resolved' do
|
||||||
|
page.within '.diff-content .note' do
|
||||||
|
expect(page).not_to have_selector('.btn', text: 'Resolve discussion')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'guest users merge request' do
|
||||||
|
before do
|
||||||
|
mr = create(:merge_request_with_diffs, source_project: project, source_branch: 'markdown', author: guest, title: "Bug")
|
||||||
|
create(:diff_note_on_merge_request, project: project, noteable: mr)
|
||||||
|
visit_merge_request(mr)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows user to mark a note as resolved' do
|
||||||
|
page.within '.diff-content .note' do
|
||||||
|
find('.line-resolve-btn').click
|
||||||
|
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.diff-content' do
|
||||||
|
expect(page).to have_selector('.btn', text: 'Unresolve discussion')
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('1/1 discussion resolved')
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'unauthorized user' do
|
||||||
|
context 'no resolved comments' do
|
||||||
|
before do
|
||||||
|
visit_merge_request
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not allow user to mark note as resolved' do
|
||||||
|
page.within '.diff-content .note' do
|
||||||
|
expect(page).not_to have_selector('.line-resolve-btn')
|
||||||
|
end
|
||||||
|
|
||||||
|
page.within '.line-resolve-all-container' do
|
||||||
|
expect(page).to have_content('0/1 discussion resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'resolved comment' do
|
||||||
|
before do
|
||||||
|
note.resolve!(user)
|
||||||
|
visit_merge_request
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows resolved icon' do
|
||||||
|
expect(page).to have_content '1/1 discussion resolved'
|
||||||
|
|
||||||
|
click_link 'Toggle discussion'
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-active')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not allow user to click resolve button' do
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-disabled')
|
||||||
|
click_link 'Toggle discussion'
|
||||||
|
|
||||||
|
expect(page).to have_selector('.line-resolve-btn.is-disabled')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def visit_merge_request(mr = nil)
|
||||||
|
mr = mr || merge_request
|
||||||
|
visit namespace_project_merge_request_path(mr.project.namespace, mr.project, mr)
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,122 @@
|
||||||
|
//= require vue
|
||||||
|
//= require diff_notes/models/discussion
|
||||||
|
//= require diff_notes/models/note
|
||||||
|
//= require diff_notes/stores/comments
|
||||||
|
(() => {
|
||||||
|
function createDiscussion(noteId = 1, resolved = true) {
|
||||||
|
CommentsStore.create('a', noteId, true, resolved, 'test');
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
CommentsStore.state = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('New discussion', () => {
|
||||||
|
it('creates new discussion', () => {
|
||||||
|
expect(Object.keys(CommentsStore.state).length).toBe(0);
|
||||||
|
createDiscussion();
|
||||||
|
expect(Object.keys(CommentsStore.state).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new note in discussion', () => {
|
||||||
|
createDiscussion();
|
||||||
|
createDiscussion(2);
|
||||||
|
|
||||||
|
const discussion = CommentsStore.state['a'];
|
||||||
|
expect(Object.keys(discussion.notes).length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Get note', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
expect(Object.keys(CommentsStore.state).length).toBe(0);
|
||||||
|
createDiscussion();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets note by ID', () => {
|
||||||
|
const note = CommentsStore.get('a', 1);
|
||||||
|
expect(note).toBeDefined();
|
||||||
|
expect(note.id).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Delete discussion', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
expect(Object.keys(CommentsStore.state).length).toBe(0);
|
||||||
|
createDiscussion();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes discussion by ID', () => {
|
||||||
|
CommentsStore.delete('a', 1);
|
||||||
|
expect(Object.keys(CommentsStore.state).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes discussion when no more notes', () => {
|
||||||
|
createDiscussion();
|
||||||
|
createDiscussion(2);
|
||||||
|
expect(Object.keys(CommentsStore.state).length).toBe(1);
|
||||||
|
expect(Object.keys(CommentsStore.state['a'].notes).length).toBe(2);
|
||||||
|
|
||||||
|
CommentsStore.delete('a', 1);
|
||||||
|
CommentsStore.delete('a', 2);
|
||||||
|
expect(Object.keys(CommentsStore.state).length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Update note', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
expect(Object.keys(CommentsStore.state).length).toBe(0);
|
||||||
|
createDiscussion();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates note to be unresolved', () => {
|
||||||
|
CommentsStore.update('a', 1, false, 'test');
|
||||||
|
|
||||||
|
const note = CommentsStore.get('a', 1);
|
||||||
|
expect(note.resolved).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Discussion resolved', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
expect(Object.keys(CommentsStore.state).length).toBe(0);
|
||||||
|
createDiscussion();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is resolved with single note', () => {
|
||||||
|
const discussion = CommentsStore.state['a'];
|
||||||
|
expect(discussion.isResolved()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is unresolved with 2 notes', () => {
|
||||||
|
const discussion = CommentsStore.state['a'];
|
||||||
|
createDiscussion(2, false);
|
||||||
|
console.log(discussion.isResolved());
|
||||||
|
|
||||||
|
expect(discussion.isResolved()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is resolved with 2 notes', () => {
|
||||||
|
const discussion = CommentsStore.state['a'];
|
||||||
|
createDiscussion(2);
|
||||||
|
|
||||||
|
expect(discussion.isResolved()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolve all notes', () => {
|
||||||
|
const discussion = CommentsStore.state['a'];
|
||||||
|
createDiscussion(2, false);
|
||||||
|
|
||||||
|
discussion.resolveAllNotes();
|
||||||
|
expect(discussion.isResolved()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unresolve all notes', () => {
|
||||||
|
const discussion = CommentsStore.state['a'];
|
||||||
|
createDiscussion(2);
|
||||||
|
|
||||||
|
discussion.unResolveAllNotes();
|
||||||
|
expect(discussion.isResolved()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
|
@ -0,0 +1,19 @@
|
||||||
|
require 'spec_helper'
|
||||||
|
require 'email_spec'
|
||||||
|
require 'mailers/shared/notify'
|
||||||
|
|
||||||
|
describe Notify, "merge request notifications" do
|
||||||
|
include EmailSpec::Matchers
|
||||||
|
|
||||||
|
describe "#resolved_all_discussions_email" do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:merge_request) { create(:merge_request) }
|
||||||
|
let(:current_user) { create(:user) }
|
||||||
|
|
||||||
|
subject { Notify.resolved_all_discussions_email(user.id, merge_request.id, current_user.id) }
|
||||||
|
|
||||||
|
it "includes the name of the resolver" do
|
||||||
|
expect(subject).to have_body_text current_user.name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -103,7 +103,7 @@ describe DiffNote, models: true do
|
||||||
|
|
||||||
describe "#active?" do
|
describe "#active?" do
|
||||||
context "when noteable is a commit" do
|
context "when noteable is a commit" do
|
||||||
subject { create(:diff_note_on_commit, project: project, position: position) }
|
subject { build(:diff_note_on_commit, project: project, position: position) }
|
||||||
|
|
||||||
it "returns true" do
|
it "returns true" do
|
||||||
expect(subject.active?).to be true
|
expect(subject.active?).to be true
|
||||||
|
@ -188,4 +188,300 @@ describe DiffNote, models: true do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#resolvable?" do
|
||||||
|
context "when noteable is a commit" do
|
||||||
|
subject { create(:diff_note_on_commit, project: project, position: position) }
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.resolvable?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when noteable is a merge request" do
|
||||||
|
context "when a system note" do
|
||||||
|
before do
|
||||||
|
subject.system = true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.resolvable?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when a regular note" do
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.resolvable?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#to_be_resolved?" do
|
||||||
|
context "when not resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.to_be_resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolved" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolved?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.to_be_resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when not resolved" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolved?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.to_be_resolved?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#resolve!" do
|
||||||
|
let(:current_user) { create(:user) }
|
||||||
|
|
||||||
|
context "when not resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns nil" do
|
||||||
|
expect(subject.resolve!(current_user)).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't set resolved_at" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved_at).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't set resolved_by" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved_by).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't mark as resolved" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when already resolved" do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
subject.resolve!(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns nil" do
|
||||||
|
expect(subject.resolve!(current_user)).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't change resolved_at" do
|
||||||
|
expect(subject.resolved_at).not_to be_nil
|
||||||
|
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't change resolved_by" do
|
||||||
|
expect(subject.resolved_by).to eq(user)
|
||||||
|
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't change resolved status" do
|
||||||
|
expect(subject.resolved?).to be true
|
||||||
|
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { subject.resolved? }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when not yet resolved" do
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.resolve!(current_user)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets resolved_at" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved_at).not_to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets resolved_by" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved_by).to eq(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "marks as resolved" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#unresolve!" do
|
||||||
|
context "when not resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns nil" do
|
||||||
|
expect(subject.unresolve!).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolved" do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
subject.resolve!(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.unresolve!).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unsets resolved_at" do
|
||||||
|
subject.unresolve!
|
||||||
|
|
||||||
|
expect(subject.resolved_at).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unsets resolved_by" do
|
||||||
|
subject.unresolve!
|
||||||
|
|
||||||
|
expect(subject.resolved_by).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unmarks as resolved" do
|
||||||
|
subject.unresolve!
|
||||||
|
|
||||||
|
expect(subject.resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when not resolved" do
|
||||||
|
it "returns nil" do
|
||||||
|
expect(subject.unresolve!).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#discussion" do
|
||||||
|
context "when not resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns nil" do
|
||||||
|
expect(subject.discussion).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolvable" do
|
||||||
|
let!(:diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: subject.position) }
|
||||||
|
let!(:diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: active_position2) }
|
||||||
|
|
||||||
|
let(:active_position2) do
|
||||||
|
Gitlab::Diff::Position.new(
|
||||||
|
old_path: "files/ruby/popen.rb",
|
||||||
|
new_path: "files/ruby/popen.rb",
|
||||||
|
old_line: 16,
|
||||||
|
new_line: 22,
|
||||||
|
diff_refs: merge_request.diff_refs
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns the discussion this note is in" do
|
||||||
|
discussion = subject.discussion
|
||||||
|
|
||||||
|
expect(discussion.id).to eq(subject.discussion_id)
|
||||||
|
expect(discussion.notes).to eq([subject, diff_note2])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#discussion_id" do
|
||||||
|
let(:note) { create(:diff_note_on_merge_request) }
|
||||||
|
|
||||||
|
context "when it is newly created" do
|
||||||
|
it "has a discussion id" do
|
||||||
|
expect(note.discussion_id).not_to be_nil
|
||||||
|
expect(note.discussion_id).to match(/\A\h{40}\z/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when it didn't store a discussion id before" do
|
||||||
|
before do
|
||||||
|
note.update_column(:discussion_id, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "has a discussion id" do
|
||||||
|
# The discussion_id is set in `after_initialize`, so `reload` won't work
|
||||||
|
reloaded_note = Note.find(note.id)
|
||||||
|
|
||||||
|
expect(reloaded_note.discussion_id).not_to be_nil
|
||||||
|
expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#original_discussion_id" do
|
||||||
|
let(:note) { create(:diff_note_on_merge_request) }
|
||||||
|
|
||||||
|
context "when it is newly created" do
|
||||||
|
it "has a discussion id" do
|
||||||
|
expect(note.original_discussion_id).not_to be_nil
|
||||||
|
expect(note.original_discussion_id).to match(/\A\h{40}\z/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when it didn't store a discussion id before" do
|
||||||
|
before do
|
||||||
|
note.update_column(:original_discussion_id, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "has a discussion id" do
|
||||||
|
# The original_discussion_id is set in `after_initialize`, so `reload` won't work
|
||||||
|
reloaded_note = Note.find(note.id)
|
||||||
|
|
||||||
|
expect(reloaded_note.original_discussion_id).not_to be_nil
|
||||||
|
expect(reloaded_note.original_discussion_id).to match(/\A\h{40}\z/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,615 @@
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Discussion, model: true do
|
||||||
|
subject { described_class.new([first_note, second_note, third_note]) }
|
||||||
|
|
||||||
|
let(:first_note) { create(:diff_note_on_merge_request) }
|
||||||
|
let(:second_note) { create(:diff_note_on_merge_request) }
|
||||||
|
let(:third_note) { create(:diff_note_on_merge_request) }
|
||||||
|
|
||||||
|
describe "#resolvable?" do
|
||||||
|
context "when a diff discussion" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:diff_discussion?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when all notes are unresolvable" do
|
||||||
|
before do
|
||||||
|
allow(first_note).to receive(:resolvable?).and_return(false)
|
||||||
|
allow(second_note).to receive(:resolvable?).and_return(false)
|
||||||
|
allow(third_note).to receive(:resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.resolvable?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when some notes are unresolvable and some notes are resolvable" do
|
||||||
|
before do
|
||||||
|
allow(first_note).to receive(:resolvable?).and_return(true)
|
||||||
|
allow(second_note).to receive(:resolvable?).and_return(false)
|
||||||
|
allow(third_note).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.resolvable?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when all notes are resolvable" do
|
||||||
|
before do
|
||||||
|
allow(first_note).to receive(:resolvable?).and_return(true)
|
||||||
|
allow(second_note).to receive(:resolvable?).and_return(true)
|
||||||
|
allow(third_note).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.resolvable?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when not a diff discussion" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:diff_discussion?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.resolvable?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#resolved?" do
|
||||||
|
context "when not resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(true)
|
||||||
|
|
||||||
|
allow(first_note).to receive(:resolvable?).and_return(true)
|
||||||
|
allow(second_note).to receive(:resolvable?).and_return(false)
|
||||||
|
allow(third_note).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when all resolvable notes are resolved" do
|
||||||
|
before do
|
||||||
|
allow(first_note).to receive(:resolved?).and_return(true)
|
||||||
|
allow(third_note).to receive(:resolved?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.resolved?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when some resolvable notes are not resolved" do
|
||||||
|
before do
|
||||||
|
allow(first_note).to receive(:resolved?).and_return(true)
|
||||||
|
allow(third_note).to receive(:resolved?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#to_be_resolved?" do
|
||||||
|
context "when not resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.to_be_resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(true)
|
||||||
|
|
||||||
|
allow(first_note).to receive(:resolvable?).and_return(true)
|
||||||
|
allow(second_note).to receive(:resolvable?).and_return(false)
|
||||||
|
allow(third_note).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when all resolvable notes are resolved" do
|
||||||
|
before do
|
||||||
|
allow(first_note).to receive(:resolved?).and_return(true)
|
||||||
|
allow(third_note).to receive(:resolved?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.to_be_resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when some resolvable notes are not resolved" do
|
||||||
|
before do
|
||||||
|
allow(first_note).to receive(:resolved?).and_return(true)
|
||||||
|
allow(third_note).to receive(:resolved?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.to_be_resolved?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#can_resolve?" do
|
||||||
|
let(:current_user) { create(:user) }
|
||||||
|
|
||||||
|
context "when not resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.can_resolve?(current_user)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when not signed in" do
|
||||||
|
let(:current_user) { nil }
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.can_resolve?(current_user)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when signed in" do
|
||||||
|
context "when the signed in user is the noteable author" do
|
||||||
|
before do
|
||||||
|
subject.noteable.author = current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.can_resolve?(current_user)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the signed in user can push to the project" do
|
||||||
|
before do
|
||||||
|
subject.project.team << [current_user, :master]
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.can_resolve?(current_user)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the signed in user is a random user" do
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.can_resolve?(current_user)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#resolve!" do
|
||||||
|
let(:current_user) { create(:user) }
|
||||||
|
|
||||||
|
context "when not resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns nil" do
|
||||||
|
expect(subject.resolve!(current_user)).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't set resolved_at" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved_at).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't set resolved_by" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved_by).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't mark as resolved" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolvable" do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(true)
|
||||||
|
|
||||||
|
allow(first_note).to receive(:resolvable?).and_return(true)
|
||||||
|
allow(second_note).to receive(:resolvable?).and_return(false)
|
||||||
|
allow(third_note).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when all resolvable notes are resolved" do
|
||||||
|
before do
|
||||||
|
first_note.resolve!(user)
|
||||||
|
third_note.resolve!(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "calls resolve! on every resolvable note" do
|
||||||
|
expect(first_note).to receive(:resolve!).with(current_user)
|
||||||
|
expect(second_note).not_to receive(:resolve!)
|
||||||
|
expect(third_note).to receive(:resolve!).with(current_user)
|
||||||
|
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't change resolved_at on the resolved notes" do
|
||||||
|
expect(first_note.resolved_at).not_to be_nil
|
||||||
|
expect(third_note.resolved_at).not_to be_nil
|
||||||
|
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_at }
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_at }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't change resolved_by on the resolved notes" do
|
||||||
|
expect(first_note.resolved_by).to eq(user)
|
||||||
|
expect(third_note.resolved_by).to eq(user)
|
||||||
|
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_by }
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_by }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't change the resolved state on the resolved notes" do
|
||||||
|
expect(first_note.resolved?).to be true
|
||||||
|
expect(third_note.resolved?).to be true
|
||||||
|
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { first_note.resolved? }
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { third_note.resolved? }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't change resolved_at" do
|
||||||
|
expect(subject.resolved_at).not_to be_nil
|
||||||
|
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't change resolved_by" do
|
||||||
|
expect(subject.resolved_by).to eq(user)
|
||||||
|
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't change resolved state" do
|
||||||
|
expect(subject.resolved?).to be true
|
||||||
|
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { subject.resolved? }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when some resolvable notes are resolved" do
|
||||||
|
before do
|
||||||
|
first_note.resolve!(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "calls resolve! on every resolvable note" do
|
||||||
|
expect(first_note).to receive(:resolve!).with(current_user)
|
||||||
|
expect(second_note).not_to receive(:resolve!)
|
||||||
|
expect(third_note).to receive(:resolve!).with(current_user)
|
||||||
|
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't change resolved_at on the resolved note" do
|
||||||
|
expect(first_note.resolved_at).not_to be_nil
|
||||||
|
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_at }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't change resolved_by on the resolved note" do
|
||||||
|
expect(first_note.resolved_by).to eq(user)
|
||||||
|
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_by }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't change the resolved state on the resolved note" do
|
||||||
|
expect(first_note.resolved?).to be true
|
||||||
|
|
||||||
|
expect { subject.resolve!(current_user) }.not_to change { first_note.resolved? }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets resolved_at on the unresolved note" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(third_note.resolved_at).not_to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets resolved_by on the unresolved note" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(third_note.resolved_by).to eq(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "marks the unresolved note as resolved" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(third_note.resolved?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets resolved_at" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved_at).not_to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets resolved_by" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved_by).to eq(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "marks as resolved" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when no resolvable notes are resolved" do
|
||||||
|
it "calls resolve! on every resolvable note" do
|
||||||
|
expect(first_note).to receive(:resolve!).with(current_user)
|
||||||
|
expect(second_note).not_to receive(:resolve!)
|
||||||
|
expect(third_note).to receive(:resolve!).with(current_user)
|
||||||
|
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets resolved_at on the unresolved notes" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(first_note.resolved_at).not_to be_nil
|
||||||
|
expect(third_note.resolved_at).not_to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets resolved_by on the unresolved notes" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(first_note.resolved_by).to eq(current_user)
|
||||||
|
expect(third_note.resolved_by).to eq(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "marks the unresolved notes as resolved" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(first_note.resolved?).to be true
|
||||||
|
expect(third_note.resolved?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets resolved_at" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved_at).not_to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets resolved_by" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved_by).to eq(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "marks as resolved" do
|
||||||
|
subject.resolve!(current_user)
|
||||||
|
|
||||||
|
expect(subject.resolved?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#unresolve!" do
|
||||||
|
context "when not resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns nil" do
|
||||||
|
expect(subject.unresolve!).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolvable" do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(true)
|
||||||
|
|
||||||
|
allow(first_note).to receive(:resolvable?).and_return(true)
|
||||||
|
allow(second_note).to receive(:resolvable?).and_return(false)
|
||||||
|
allow(third_note).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when all resolvable notes are resolved" do
|
||||||
|
before do
|
||||||
|
first_note.resolve!(user)
|
||||||
|
third_note.resolve!(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "calls unresolve! on every resolvable note" do
|
||||||
|
expect(first_note).to receive(:unresolve!)
|
||||||
|
expect(second_note).not_to receive(:unresolve!)
|
||||||
|
expect(third_note).to receive(:unresolve!)
|
||||||
|
|
||||||
|
subject.unresolve!
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unsets resolved_at on the resolved notes" do
|
||||||
|
subject.unresolve!
|
||||||
|
|
||||||
|
expect(first_note.resolved_at).to be_nil
|
||||||
|
expect(third_note.resolved_at).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unsets resolved_by on the resolved notes" do
|
||||||
|
subject.unresolve!
|
||||||
|
|
||||||
|
expect(first_note.resolved_by).to be_nil
|
||||||
|
expect(third_note.resolved_by).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unmarks the resolved notes as resolved" do
|
||||||
|
subject.unresolve!
|
||||||
|
|
||||||
|
expect(first_note.resolved?).to be false
|
||||||
|
expect(third_note.resolved?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unsets resolved_at" do
|
||||||
|
subject.unresolve!
|
||||||
|
|
||||||
|
expect(subject.resolved_at).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unsets resolved_by" do
|
||||||
|
subject.unresolve!
|
||||||
|
|
||||||
|
expect(subject.resolved_by).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unmarks as resolved" do
|
||||||
|
subject.unresolve!
|
||||||
|
|
||||||
|
expect(subject.resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when some resolvable notes are resolved" do
|
||||||
|
before do
|
||||||
|
first_note.resolve!(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "calls unresolve! on every resolvable note" do
|
||||||
|
expect(first_note).to receive(:unresolve!)
|
||||||
|
expect(second_note).not_to receive(:unresolve!)
|
||||||
|
expect(third_note).to receive(:unresolve!)
|
||||||
|
|
||||||
|
subject.unresolve!
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unsets resolved_at on the resolved note" do
|
||||||
|
subject.unresolve!
|
||||||
|
|
||||||
|
expect(first_note.resolved_at).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unsets resolved_by on the resolved note" do
|
||||||
|
subject.unresolve!
|
||||||
|
|
||||||
|
expect(first_note.resolved_by).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unmarks the resolved note as resolved" do
|
||||||
|
subject.unresolve!
|
||||||
|
|
||||||
|
expect(first_note.resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when no resolvable notes are resolved" do
|
||||||
|
it "calls unresolve! on every resolvable note" do
|
||||||
|
expect(first_note).to receive(:unresolve!)
|
||||||
|
expect(second_note).not_to receive(:unresolve!)
|
||||||
|
expect(third_note).to receive(:unresolve!)
|
||||||
|
|
||||||
|
subject.unresolve!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#collapsed?" do
|
||||||
|
context "when a diff discussion" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:diff_discussion?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolved" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolved?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.collapsed?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when not resolved" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolved?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.collapsed?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when not resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when active" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:active?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.collapsed?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when outdated" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:active?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.collapsed?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when not a diff discussion" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:diff_discussion?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.collapsed?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -73,4 +73,29 @@ describe LegacyDiffNote, models: true do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#discussion_id" do
|
||||||
|
let(:note) { create(:note) }
|
||||||
|
|
||||||
|
context "when it is newly created" do
|
||||||
|
it "has a discussion id" do
|
||||||
|
expect(note.discussion_id).not_to be_nil
|
||||||
|
expect(note.discussion_id).to match(/\A\h{40}\z/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when it didn't store a discussion id before" do
|
||||||
|
before do
|
||||||
|
note.update_column(:discussion_id, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "has a discussion id" do
|
||||||
|
# The discussion_id is set in `after_initialize`, so `reload` won't work
|
||||||
|
reloaded_note = Note.find(note.id)
|
||||||
|
|
||||||
|
expect(reloaded_note.discussion_id).not_to be_nil
|
||||||
|
expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -784,6 +784,98 @@ describe MergeRequest, models: true do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "discussion status" do
|
||||||
|
let(:first_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) }
|
||||||
|
let(:second_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) }
|
||||||
|
let(:third_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:diff_discussions).and_return([first_discussion, second_discussion, third_discussion])
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#discussions_resolvable?" do
|
||||||
|
context "when all discussions are unresolvable" do
|
||||||
|
before do
|
||||||
|
allow(first_discussion).to receive(:resolvable?).and_return(false)
|
||||||
|
allow(second_discussion).to receive(:resolvable?).and_return(false)
|
||||||
|
allow(third_discussion).to receive(:resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.discussions_resolvable?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when some discussions are unresolvable and some discussions are resolvable" do
|
||||||
|
before do
|
||||||
|
allow(first_discussion).to receive(:resolvable?).and_return(true)
|
||||||
|
allow(second_discussion).to receive(:resolvable?).and_return(false)
|
||||||
|
allow(third_discussion).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.discussions_resolvable?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when all discussions are resolvable" do
|
||||||
|
before do
|
||||||
|
allow(first_discussion).to receive(:resolvable?).and_return(true)
|
||||||
|
allow(second_discussion).to receive(:resolvable?).and_return(true)
|
||||||
|
allow(third_discussion).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.discussions_resolvable?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#discussions_resolved?" do
|
||||||
|
context "when discussions are not resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:discussions_resolvable?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.discussions_resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when discussions are resolvable" do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:discussions_resolvable?).and_return(true)
|
||||||
|
|
||||||
|
allow(first_discussion).to receive(:resolvable?).and_return(true)
|
||||||
|
allow(second_discussion).to receive(:resolvable?).and_return(false)
|
||||||
|
allow(third_discussion).to receive(:resolvable?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when all resolvable discussions are resolved" do
|
||||||
|
before do
|
||||||
|
allow(first_discussion).to receive(:resolved?).and_return(true)
|
||||||
|
allow(third_discussion).to receive(:resolved?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true" do
|
||||||
|
expect(subject.discussions_resolved?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when some resolvable discussions are not resolved" do
|
||||||
|
before do
|
||||||
|
allow(first_discussion).to receive(:resolved?).and_return(true)
|
||||||
|
allow(third_discussion).to receive(:resolved?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
expect(subject.discussions_resolved?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe '#conflicts_can_be_resolved_in_ui?' do
|
describe '#conflicts_can_be_resolved_in_ui?' do
|
||||||
def create_merge_request(source_branch)
|
def create_merge_request(source_branch)
|
||||||
create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr|
|
create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr|
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
require 'spec_helper'
|
require 'spec_helper'
|
||||||
|
|
||||||
describe Note, models: true do
|
describe Note, models: true do
|
||||||
|
include RepoHelpers
|
||||||
|
|
||||||
describe 'associations' do
|
describe 'associations' do
|
||||||
it { is_expected.to belong_to(:project) }
|
it { is_expected.to belong_to(:project) }
|
||||||
it { is_expected.to belong_to(:noteable).touch(true) }
|
it { is_expected.to belong_to(:noteable).touch(true) }
|
||||||
|
@ -267,4 +269,81 @@ describe Note, models: true do
|
||||||
expect(note.participants).to include(note.author)
|
expect(note.participants).to include(note.author)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe ".grouped_diff_discussions" do
|
||||||
|
let!(:merge_request) { create(:merge_request) }
|
||||||
|
let(:project) { merge_request.project }
|
||||||
|
let!(:active_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) }
|
||||||
|
let!(:active_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) }
|
||||||
|
let!(:active_diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: active_position2) }
|
||||||
|
let!(:outdated_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position) }
|
||||||
|
let!(:outdated_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position) }
|
||||||
|
|
||||||
|
let(:active_position2) do
|
||||||
|
Gitlab::Diff::Position.new(
|
||||||
|
old_path: "files/ruby/popen.rb",
|
||||||
|
new_path: "files/ruby/popen.rb",
|
||||||
|
old_line: 16,
|
||||||
|
new_line: 22,
|
||||||
|
diff_refs: merge_request.diff_refs
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:outdated_position) do
|
||||||
|
Gitlab::Diff::Position.new(
|
||||||
|
old_path: "files/ruby/popen.rb",
|
||||||
|
new_path: "files/ruby/popen.rb",
|
||||||
|
old_line: nil,
|
||||||
|
new_line: 9,
|
||||||
|
diff_refs: project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e").diff_refs
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
subject { merge_request.notes.grouped_diff_discussions }
|
||||||
|
|
||||||
|
it "includes active discussions" do
|
||||||
|
discussions = subject.values
|
||||||
|
|
||||||
|
expect(discussions.count).to eq(2)
|
||||||
|
expect(discussions.map(&:id)).to eq([active_diff_note1.discussion_id, active_diff_note3.discussion_id])
|
||||||
|
expect(discussions.all?(&:active?)).to be true
|
||||||
|
|
||||||
|
expect(discussions.first.notes).to eq([active_diff_note1, active_diff_note2])
|
||||||
|
expect(discussions.last.notes).to eq([active_diff_note3])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't include outdated discussions" do
|
||||||
|
expect(subject.values.map(&:id)).not_to include(outdated_diff_note1.discussion_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "groups the discussions by line code" do
|
||||||
|
expect(subject[active_diff_note1.line_code].id).to eq(active_diff_note1.discussion_id)
|
||||||
|
expect(subject[active_diff_note3.line_code].id).to eq(active_diff_note3.discussion_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#discussion_id" do
|
||||||
|
let(:note) { create(:note) }
|
||||||
|
|
||||||
|
context "when it is newly created" do
|
||||||
|
it "has a discussion id" do
|
||||||
|
expect(note.discussion_id).not_to be_nil
|
||||||
|
expect(note.discussion_id).to match(/\A\h{40}\z/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when it didn't store a discussion id before" do
|
||||||
|
before do
|
||||||
|
note.update_column(:discussion_id, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "has a discussion id" do
|
||||||
|
# The discussion_id is set in `after_initialize`, so `reload` won't work
|
||||||
|
reloaded_note = Note.find(note.id)
|
||||||
|
|
||||||
|
expect(reloaded_note.discussion_id).not_to be_nil
|
||||||
|
expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe MergeRequests::ResolvedDiscussionNotificationService, services: true do
|
||||||
|
let(:merge_request) { create(:merge_request) }
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:project) { merge_request.project }
|
||||||
|
subject { described_class.new(project, user) }
|
||||||
|
|
||||||
|
describe "#execute" do
|
||||||
|
context "when not all discussions are resolved" do
|
||||||
|
before do
|
||||||
|
allow(merge_request).to receive(:discussions_resolved?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't add a system note" do
|
||||||
|
expect(SystemNoteService).not_to receive(:resolve_all_discussions)
|
||||||
|
|
||||||
|
subject.execute(merge_request)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't send a notification email" do
|
||||||
|
expect_any_instance_of(NotificationService).not_to receive(:resolve_all_discussions)
|
||||||
|
|
||||||
|
subject.execute(merge_request)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when all discussions are resolved" do
|
||||||
|
before do
|
||||||
|
allow(merge_request).to receive(:discussions_resolved?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "adds a system note" do
|
||||||
|
expect(SystemNoteService).to receive(:resolve_all_discussions).with(merge_request, project, user)
|
||||||
|
|
||||||
|
subject.execute(merge_request)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sends a notification email" do
|
||||||
|
expect_any_instance_of(NotificationService).to receive(:resolve_all_discussions).with(merge_request, user)
|
||||||
|
|
||||||
|
subject.execute(merge_request)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1042,6 +1042,52 @@ describe NotificationService, services: true do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#resolve_all_discussions" do
|
||||||
|
it do
|
||||||
|
notification.resolve_all_discussions(merge_request, @u_disabled)
|
||||||
|
|
||||||
|
should_email(merge_request.assignee)
|
||||||
|
should_email(@u_watcher)
|
||||||
|
should_email(@u_participant_mentioned)
|
||||||
|
should_email(@subscriber)
|
||||||
|
should_email(@watcher_and_subscriber)
|
||||||
|
should_email(@u_guest_watcher)
|
||||||
|
should_not_email(@unsubscriber)
|
||||||
|
should_not_email(@u_participating)
|
||||||
|
should_not_email(@u_disabled)
|
||||||
|
should_not_email(@u_lazy_participant)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'participating' do
|
||||||
|
context 'by assignee' do
|
||||||
|
before do
|
||||||
|
merge_request.update_attribute(:assignee, @u_lazy_participant)
|
||||||
|
notification.resolve_all_discussions(merge_request, @u_disabled)
|
||||||
|
end
|
||||||
|
|
||||||
|
it { should_email(@u_lazy_participant) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'by note' do
|
||||||
|
let!(:note) { create(:note_on_issue, noteable: merge_request, project_id: project.id, note: 'anything', author: @u_lazy_participant) }
|
||||||
|
|
||||||
|
before { notification.resolve_all_discussions(merge_request, @u_disabled) }
|
||||||
|
|
||||||
|
it { should_email(@u_lazy_participant) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'by author' do
|
||||||
|
before do
|
||||||
|
merge_request.author = @u_lazy_participant
|
||||||
|
merge_request.save
|
||||||
|
notification.resolve_all_discussions(merge_request, @u_disabled)
|
||||||
|
end
|
||||||
|
|
||||||
|
it { should_email(@u_lazy_participant) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'Projects' do
|
describe 'Projects' do
|
||||||
|
|
Loading…
Reference in New Issue