summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/notes
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/notes')
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue11
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue1
-rw-r--r--app/assets/javascripts/notes/components/multiline_comment_form.vue68
-rw-r--r--app/assets/javascripts/notes/components/multiline_comment_utils.js57
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue65
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue36
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue70
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue5
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue98
-rw-r--r--app/assets/javascripts/notes/mixins/description_version_history.js2
-rw-r--r--app/assets/javascripts/notes/mixins/diff_line_note_form.js98
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js4
-rw-r--r--app/assets/javascripts/notes/mixins/draft.js8
-rw-r--r--app/assets/javascripts/notes/mixins/get_discussion.js7
-rw-r--r--app/assets/javascripts/notes/mixins/note_form.js24
-rw-r--r--app/assets/javascripts/notes/stores/actions.js53
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js5
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js36
19 files changed, 583 insertions, 66 deletions
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index a070cf8866a..16dcde46262 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -29,6 +29,7 @@ export default {
name: 'CommentForm',
components: {
issueWarning,
+ epicWarning: () => import('ee_component/vue_shared/components/epic/epic_warning.vue'),
noteSignedOutWidget,
discussionLockedWidget,
markdownField,
@@ -60,6 +61,7 @@ export default {
'getCurrentUserLastNote',
'getUserData',
'getNoteableData',
+ 'getNoteableDataByProp',
'getNotesData',
'openState',
'getBlockedByIssues',
@@ -135,6 +137,9 @@ export default {
? __('merge request')
: __('issue');
},
+ isIssueType() {
+ return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE;
+ },
trackingLabel() {
return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
},
@@ -346,13 +351,13 @@ export default {
<div class="error-alert"></div>
<issue-warning
- v-if="hasWarning(getNoteableData)"
+ v-if="hasWarning(getNoteableData) && isIssueType"
:is-locked="isLocked(getNoteableData)"
:is-confidential="isConfidential(getNoteableData)"
:locked-issue-docs-path="lockedIssueDocsPath"
:confidential-issue-docs-path="confidentialIssueDocsPath"
/>
-
+ <epic-warning :is-confidential="isConfidential(getNoteableData)" />
<markdown-field
ref="markdownField"
:is-submitting="isSubmitting"
@@ -412,7 +417,7 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input"
</gl-alert>
<div class="note-form-actions">
<div
- class="float-left btn-group
+ class="btn-group
append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
>
<button
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index cd5cfc09ea0..8897b54fac7 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -116,6 +116,7 @@ export default {
</div>
<div v-else>
<diff-viewer
+ :diff-file="discussion.diff_file"
:diff-mode="diffMode"
:diff-viewer-mode="diffViewerMode"
:new-path="discussion.diff_file.new_path"
diff --git a/app/assets/javascripts/notes/components/multiline_comment_form.vue b/app/assets/javascripts/notes/components/multiline_comment_form.vue
new file mode 100644
index 00000000000..5fba011a153
--- /dev/null
+++ b/app/assets/javascripts/notes/components/multiline_comment_form.vue
@@ -0,0 +1,68 @@
+<script>
+import { GlFormSelect, GlSprintf } from '@gitlab/ui';
+import { getSymbol, getLineClasses } from './multiline_comment_utils';
+
+export default {
+ components: { GlFormSelect, GlSprintf },
+ props: {
+ lineRange: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ line: {
+ type: Object,
+ required: true,
+ },
+ commentLineOptions: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ commentLineStart: {
+ lineCode: this.lineRange ? this.lineRange.start_line_code : this.line.line_code,
+ type: this.lineRange ? this.lineRange.start_line_type : this.line.type,
+ },
+ };
+ },
+ methods: {
+ getSymbol({ type }) {
+ return getSymbol(type);
+ },
+ getLineClasses(line) {
+ return getLineClasses(line);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-sprintf
+ :message="
+ s__('MergeRequestDiffs|Commenting on lines %{selectStart}start%{selectEnd} to %{end}')
+ "
+ >
+ <template #select>
+ <label for="comment-line-start" class="sr-only">{{
+ s__('MergeRequestDiffs|Select comment starting line')
+ }}</label>
+ <gl-form-select
+ id="comment-line-start"
+ :value="commentLineStart"
+ :options="commentLineOptions"
+ size="sm"
+ class="gl-w-auto gl-vertical-align-baseline"
+ @change="$emit('input', $event)"
+ />
+ </template>
+ <template #end>
+ <span :class="getLineClasses(line)">
+ {{ getSymbol(line) + (line.new_line || line.old_line) }}
+ </span>
+ </template>
+ </gl-sprintf>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/multiline_comment_utils.js b/app/assets/javascripts/notes/components/multiline_comment_utils.js
new file mode 100644
index 00000000000..dc9c55e9b30
--- /dev/null
+++ b/app/assets/javascripts/notes/components/multiline_comment_utils.js
@@ -0,0 +1,57 @@
+import { takeRightWhile } from 'lodash';
+
+export function getSymbol(type) {
+ if (type === 'new') return '+';
+ if (type === 'old') return '-';
+ return '';
+}
+
+function getLineNumber(lineRange, key) {
+ if (!lineRange || !key) return '';
+ const lineCode = lineRange[`${key}_line_code`] || '';
+ const lineType = lineRange[`${key}_line_type`] || '';
+ const lines = lineCode.split('_') || [];
+ const lineNumber = lineType === 'old' ? lines[1] : lines[2];
+ return (lineNumber && getSymbol(lineType) + lineNumber) || '';
+}
+
+export function getStartLineNumber(lineRange) {
+ return getLineNumber(lineRange, 'start');
+}
+
+export function getEndLineNumber(lineRange) {
+ return getLineNumber(lineRange, 'end');
+}
+
+export function getLineClasses(line) {
+ const symbol = typeof line === 'string' ? line.charAt(0) : getSymbol(line?.type);
+
+ if (symbol !== '+' && symbol !== '-') return '';
+
+ return [
+ 'gl-px-1 gl-rounded-small gl-border-solid gl-border-1 gl-border-white',
+ {
+ 'gl-bg-green-100 gl-text-green-800': symbol === '+',
+ 'gl-bg-red-100 gl-text-red-800': symbol === '-',
+ },
+ ];
+}
+
+export function commentLineOptions(diffLines, lineCode) {
+ const selectedIndex = diffLines.findIndex(line => line.line_code === lineCode);
+ const notMatchType = l => l.type !== 'match';
+
+ // We're limiting adding comments to only lines above the current line
+ // to make rendering simpler. Future interations will use a more
+ // intuitive dragging interface that will make this unnecessary
+ const upToSelected = diffLines.slice(0, selectedIndex + 1);
+
+ // Only include the lines up to the first "Show unchanged lines" block
+ // i.e. not a "match" type
+ const lines = takeRightWhile(upToSelected, notMatchType);
+
+ return lines.map(l => ({
+ value: { lineCode: l.line_code, type: l.type },
+ text: `${getSymbol(l.type)}${l.new_line || l.old_line}`,
+ }));
+}
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index dc514f00801..f1af8be590a 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,9 +1,13 @@
<script>
+import { __ } from '~/locale';
import { mapGetters } from 'vuex';
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
-import resolvedStatusMixin from 'ee_else_ce/batch_comments/mixins/resolved_status';
+import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
import Icon from '~/vue_shared/components/icon.vue';
import ReplyButton from './note_actions/reply_button.vue';
+import eventHub from '~/sidebar/event_hub';
+import Api from '~/api';
+import flash from '~/flash';
export default {
name: 'NoteActions',
@@ -17,6 +21,10 @@ export default {
},
mixins: [resolvedStatusMixin],
props: {
+ author: {
+ type: Object,
+ required: true,
+ },
authorId: {
type: Number,
required: true,
@@ -87,7 +95,7 @@ export default {
},
},
computed: {
- ...mapGetters(['getUserDataByProp']),
+ ...mapGetters(['getUserDataByProp', 'getNoteableData']),
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
@@ -100,6 +108,26 @@ export default {
currentUserId() {
return this.getUserDataByProp('id');
},
+ isUserAssigned() {
+ return this.assignees && this.assignees.some(({ id }) => id === this.author.id);
+ },
+ displayAssignUserText() {
+ return this.isUserAssigned
+ ? __('Unassign from commenting user')
+ : __('Assign to commenting user');
+ },
+ sidebarAction() {
+ return this.isUserAssigned ? 'sidebar.addAssignee' : 'sidebar.removeAssignee';
+ },
+ targetType() {
+ return this.getNoteableData.targetType;
+ },
+ assignees() {
+ return this.getNoteableData.assignees || [];
+ },
+ isIssue() {
+ return this.targetType === 'issue';
+ },
},
methods: {
onEdit() {
@@ -116,6 +144,29 @@ export default {
this.$root.$emit('bv::hide::tooltip');
});
},
+ handleAssigneeUpdate(assignees) {
+ this.$emit('updateAssignees', assignees);
+ eventHub.$emit(this.sidebarAction, this.author);
+ eventHub.$emit('sidebar.saveAssignees');
+ },
+ assignUser() {
+ let { assignees } = this;
+ const { project_id, iid } = this.getNoteableData;
+
+ if (this.isUserAssigned) {
+ assignees = assignees.filter(assignee => assignee.id !== this.author.id);
+ } else {
+ assignees.push({ id: this.author.id });
+ }
+
+ if (this.targetType === 'issue') {
+ Api.updateIssue(project_id, iid, {
+ assignee_ids: assignees.map(assignee => assignee.id),
+ })
+ .then(() => this.handleAssigneeUpdate(assignees))
+ .catch(() => flash(__('Something went wrong while updating assignees')));
+ }
+ },
},
};
</script>
@@ -215,6 +266,16 @@ export default {
<span class="text-danger">{{ __('Delete comment') }}</span>
</button>
</li>
+ <li v-if="isIssue">
+ <button
+ class="btn-default btn-transparent"
+ data-testid="assign-user"
+ type="button"
+ @click="assignUser"
+ >
+ {{ displayAssignUserText }}
+ </button>
+ </li>
</ul>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 358f49deb35..42b78929f8a 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -1,8 +1,7 @@
<script>
-import { mapActions } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
-import getDiscussion from 'ee_else_ce/notes/mixins/get_discussion';
import noteEditedText from './note_edited_text.vue';
import noteAwardsList from './note_awards_list.vue';
import noteAttachment from './note_attachment.vue';
@@ -18,7 +17,7 @@ export default {
noteForm,
Suggestions,
},
- mixins: [autosave, getDiscussion],
+ mixins: [autosave],
props: {
note: {
type: Object,
@@ -45,6 +44,15 @@ export default {
},
},
computed: {
+ ...mapGetters(['getDiscussion']),
+ discussion() {
+ if (!this.note.isDraft) return {};
+
+ return this.getDiscussion(this.note.discussion_id);
+ },
+ ...mapState({
+ batchSuggestionsInfo: state => state.notes.batchSuggestionsInfo,
+ }),
noteBody() {
return this.note.note;
},
@@ -74,7 +82,12 @@ export default {
}
},
methods: {
- ...mapActions(['submitSuggestion']),
+ ...mapActions([
+ 'submitSuggestion',
+ 'submitSuggestionBatch',
+ 'addSuggestionInfoToBatch',
+ 'removeSuggestionInfoFromBatch',
+ ]),
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
@@ -91,6 +104,17 @@ export default {
callback,
);
},
+ applySuggestionBatch({ flashContainer }) {
+ return this.submitSuggestionBatch({ flashContainer });
+ },
+ addSuggestionToBatch(suggestionId) {
+ const { discussion_id: discussionId, id: noteId } = this.note;
+
+ this.addSuggestionInfoToBatch({ suggestionId, discussionId, noteId });
+ },
+ removeSuggestionFromBatch(suggestionId) {
+ this.removeSuggestionInfoFromBatch(suggestionId);
+ },
},
};
</script>
@@ -100,10 +124,14 @@ export default {
<suggestions
v-if="hasSuggestion && !isEditing"
:suggestions="note.suggestions"
+ :batch-suggestions-info="batchSuggestionsInfo"
:note-html="note.note_html"
:line-type="lineType"
:help-page-path="helpPagePath"
@apply="applySuggestion"
+ @applyBatch="applySuggestionBatch"
+ @addToBatch="addSuggestionToBatch"
+ @removeFromBatch="removeSuggestionFromBatch"
/>
<div v-else class="note-text md" v-html="note.note_html"></div>
<note-form
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 21d0bffdf1c..795ee10ca0f 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,6 +1,5 @@
<script>
-import { mapGetters, mapActions } from 'vuex';
-import noteFormMixin from 'ee_else_ce/notes/mixins/note_form';
+import { mapGetters, mapActions, mapState } from 'vuex';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
@@ -16,7 +15,7 @@ export default {
issueWarning,
markdownField,
},
- mixins: [issuableStateMixin, resolvable, noteFormMixin],
+ mixins: [issuableStateMixin, resolvable],
props: {
noteBody: {
type: String,
@@ -82,6 +81,11 @@ export default {
required: false,
default: false,
},
+ isDraft: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
let updatedNoteBody = this.noteBody;
@@ -107,6 +111,16 @@ export default {
'getNotesDataByProp',
'getUserDataByProp',
]),
+ ...mapState({
+ withBatchComments: state => state.batchComments?.withBatchComments,
+ }),
+ ...mapGetters('batchComments', ['hasDrafts']),
+ showBatchCommentsActions() {
+ return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit;
+ },
+ showResolveDiscussionToggle() {
+ return (this.discussion?.id && this.discussion.resolvable) || this.isDraft;
+ },
noteHash() {
if (this.noteId) {
return `#note_${this.noteId}`;
@@ -202,8 +216,6 @@ export default {
methods: {
...mapActions(['toggleResolveNote']),
shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState) {
- // shouldBeResolved() checks the actual resolution state,
- // considering batchComments (EEP), if applicable/enabled.
const newResolvedStateAfterUpdate =
this.shouldBeResolved && this.shouldBeResolved(shouldResolve);
@@ -234,6 +246,50 @@ export default {
updateDraft(autosaveKey, text);
}
},
+ handleKeySubmit() {
+ if (this.showBatchCommentsActions) {
+ this.handleAddToReview();
+ } else {
+ this.handleUpdate();
+ }
+ },
+ handleUpdate(shouldResolve) {
+ const beforeSubmitDiscussionState = this.discussionResolved;
+ this.isSubmitting = true;
+
+ this.$emit(
+ 'handleFormUpdate',
+ this.updatedNoteBody,
+ this.$refs.editNoteForm,
+ () => {
+ this.isSubmitting = false;
+
+ if (this.shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState)) {
+ this.resolveHandler(beforeSubmitDiscussionState);
+ }
+ },
+ this.discussionResolved ? !this.isUnresolving : this.isResolving,
+ );
+ },
+ shouldBeResolved(resolveStatus) {
+ if (this.withBatchComments) {
+ return (
+ (this.discussionResolved && !this.isUnresolving) ||
+ (!this.discussionResolved && this.isResolving)
+ );
+ }
+
+ return resolveStatus;
+ },
+ handleAddToReview() {
+ // check if draft should resolve thread
+ const shouldResolve =
+ (this.discussionResolved && !this.isUnresolving) ||
+ (!this.discussionResolved && this.isResolving);
+ this.isSubmitting = true;
+
+ this.$emit('handleFormUpdateAddToReview', this.updatedNoteBody, shouldResolve);
+ },
},
};
</script>
@@ -293,6 +349,7 @@ export default {
<input
v-model="isUnresolving"
type="checkbox"
+ class="js-unresolve-checkbox"
data-qa-selector="unresolve_review_discussion_checkbox"
/>
{{ __('Unresolve thread') }}
@@ -301,6 +358,7 @@ export default {
<input
v-model="isResolving"
type="checkbox"
+ class="js-resolve-checkbox"
data-qa-selector="resolve_review_discussion_checkbox"
/>
{{ __('Resolve thread') }}
@@ -320,7 +378,7 @@ export default {
<button
:disabled="isDisabled"
type="button"
- class="btn qa-comment-now"
+ class="btn qa-comment-now js-comment-button"
@click="handleUpdate()"
>
{{ __('Add comment now') }}
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 189ff88feb3..7fe50d36c0c 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -1,11 +1,12 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
-import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
+import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import { s__, __ } from '~/locale';
import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
import icon from '~/vue_shared/components/icon.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+import DraftNote from '~/batch_comments/components/draft_note.vue';
import Flash from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import diffDiscussionHeader from './diff_discussion_header.vue';
@@ -26,7 +27,7 @@ export default {
diffDiscussionHeader,
noteSignedOutWidget,
noteForm,
- DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'),
+ DraftNote,
TimelineEntryItem,
DiscussionNotes,
DiscussionActions,
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 37675e20b3d..0e4dd1b9c84 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -2,7 +2,8 @@
import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
import { escape } from 'lodash';
-import draftMixin from 'ee_else_ce/notes/mixins/draft';
+import { GlSprintf } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { __, s__, sprintf } from '../../locale';
@@ -15,17 +16,26 @@ import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
import httpStatusCodes from '~/lib/utils/http_status';
+import {
+ getStartLineNumber,
+ getEndLineNumber,
+ getLineClasses,
+ commentLineOptions,
+} from './multiline_comment_utils';
+import MultilineCommentForm from './multiline_comment_form.vue';
export default {
name: 'NoteableNote',
components: {
+ GlSprintf,
userAvatarLink,
noteHeader,
noteActions,
NoteBody,
TimelineEntryItem,
+ MultilineCommentForm,
},
- mixins: [noteable, resolvable, draftMixin],
+ mixins: [noteable, resolvable, glFeatureFlagsMixin()],
props: {
note: {
type: Object,
@@ -51,6 +61,11 @@ export default {
required: false,
default: false,
},
+ diffLines: {
+ type: Object,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -58,9 +73,14 @@ export default {
isDeleting: false,
isRequesting: false,
isResolving: false,
+ commentLineStart: {
+ line_code: this.line?.line_code,
+ type: this.line?.type,
+ },
};
},
computed: {
+ ...mapGetters('diffs', ['getDiffFileByHash']),
...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData', 'commentsDisabled']),
author() {
return this.note.author;
@@ -105,6 +125,41 @@ export default {
)}</a>`;
return sprintf(s__('MergeRequests|commented on commit %{commitLink}'), { commitLink }, false);
},
+ isDraft() {
+ return this.note.isDraft;
+ },
+ canResolve() {
+ return (
+ this.note.current_user.can_resolve ||
+ (this.note.isDraft && this.note.discussion_id !== null)
+ );
+ },
+ lineRange() {
+ return this.note.position?.line_range;
+ },
+ startLineNumber() {
+ return getStartLineNumber(this.lineRange);
+ },
+ endLineNumber() {
+ return getEndLineNumber(this.lineRange);
+ },
+ showMultiLineComment() {
+ return (
+ this.glFeatures.multilineComments &&
+ this.startLineNumber &&
+ this.endLineNumber &&
+ (this.startLineNumber !== this.endLineNumber || this.isEditing)
+ );
+ },
+ commentLineOptions() {
+ if (this.diffLines) {
+ return commentLineOptions(this.diffLines, this.line.line_code);
+ }
+
+ const diffFile = this.diffFile || this.getDiffFileByHash(this.targetNoteHash);
+ if (!diffFile) return null;
+ return commentLineOptions(diffFile.highlighted_diff_lines, this.line.line_code);
+ },
},
created() {
@@ -129,6 +184,7 @@ export default {
'updateNote',
'toggleResolveNote',
'scrollToNoteIfNeeded',
+ 'updateAssignees',
]),
editHandler() {
this.isEditing = true;
@@ -166,10 +222,20 @@ export default {
this.$emit('updateSuccess');
},
formUpdateHandler(noteText, parentElement, callback, resolveDiscussion) {
+ const position = {
+ ...this.note.position,
+ line_range: {
+ start_line_code: this.commentLineStart?.lineCode,
+ start_line_type: this.commentLineStart?.type,
+ end_line_code: this.line?.line_code,
+ end_line_type: this.line?.type,
+ },
+ };
this.$emit('handleUpdateNote', {
note: this.note,
noteText,
resolveDiscussion,
+ position,
callback: () => this.updateSuccess(),
});
@@ -231,6 +297,12 @@ export default {
noteBody.note.note = noteText;
}
},
+ getLineClasses(lineNumber) {
+ return getLineClasses(lineNumber);
+ },
+ assigneesUpdate(assignees) {
+ this.updateAssignees(assignees);
+ },
},
};
</script>
@@ -243,6 +315,26 @@ export default {
:data-note-id="note.id"
class="note note-wrapper qa-noteable-note-item"
>
+ <div v-if="showMultiLineComment" data-testid="multiline-comment">
+ <multiline-comment-form
+ v-if="isEditing && commentLineOptions && line"
+ v-model="commentLineStart"
+ :line="line"
+ :comment-line-options="commentLineOptions"
+ :line-range="note.position.line_range"
+ class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
+ />
+ <div v-else class="gl-mb-3 gl-text-gray-700">
+ <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
+ <template #startLine>
+ <span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span>
+ </template>
+ <template #endLine>
+ <span :class="getLineClasses(endLineNumber)">{{ endLineNumber }}</span>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
<div v-once class="timeline-icon">
<user-avatar-link
:link-href="author.path"
@@ -267,6 +359,7 @@ export default {
<span v-else-if="note.created_at" class="d-none d-sm-inline">&middot;</span>
</note-header>
<note-actions
+ :author="author"
:author-id="author.id"
:note-id="note.id"
:note-url="note.noteable_note_url"
@@ -289,6 +382,7 @@ export default {
@handleDelete="deleteHandler"
@handleResolve="resolveHandler"
@startReplying="$emit('startReplying')"
+ @updateAssignees="assigneesUpdate"
/>
</div>
<div class="timeline-discussion-body">
diff --git a/app/assets/javascripts/notes/mixins/description_version_history.js b/app/assets/javascripts/notes/mixins/description_version_history.js
index 66e6685cfd8..d1006e37a70 100644
--- a/app/assets/javascripts/notes/mixins/description_version_history.js
+++ b/app/assets/javascripts/notes/mixins/description_version_history.js
@@ -3,7 +3,7 @@
export default {
computed: {
canSeeDescriptionVersion() {},
- canDeleteDescriptionVersion() {},
+ displayDeleteButton() {},
shouldShowDescriptionVersion() {},
descriptionVersionToggleIcon() {},
},
diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
index 188556e8921..5930b5f3321 100644
--- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js
+++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
@@ -1,10 +1,100 @@
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { getDraftReplyFormData, getDraftFormData } from '~/batch_comments/utils';
+import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import { clearDraft } from '~/lib/utils/autosave';
+
export default {
computed: {
- draftForDiscussion: () => () => ({}),
+ ...mapState({
+ noteableData: state => state.notes.noteableData,
+ notesData: state => state.notes.notesData,
+ withBatchComments: state => state.batchComments?.withBatchComments,
+ }),
+ ...mapGetters('diffs', ['getDiffFileByHash']),
+ ...mapGetters('batchComments', ['shouldRenderDraftRowInDiscussion', 'draftForDiscussion']),
+ ...mapState('diffs', ['commit']),
},
methods: {
- showDraft: () => false,
- addReplyToReview: () => {},
- addToReview: () => {},
+ ...mapActions('diffs', ['cancelCommentForm']),
+ ...mapActions('batchComments', ['addDraftToReview', 'saveDraft', 'insertDraftIntoDrafts']),
+ addReplyToReview(noteText, isResolving) {
+ const postData = getDraftReplyFormData({
+ in_reply_to_discussion_id: this.discussion.reply_id,
+ target_type: this.getNoteableData.targetType,
+ notesData: this.notesData,
+ draft_note: {
+ note: noteText,
+ resolve_discussion: isResolving,
+ },
+ });
+
+ if (this.discussion.for_commit) {
+ postData.note_project_id = this.discussion.project_id;
+ }
+
+ this.isReplying = false;
+
+ this.saveDraft(postData)
+ .then(() => {
+ this.handleClearForm(this.discussion.line_code);
+ })
+ .catch(() => {
+ createFlash(s__('MergeRequests|An error occurred while saving the draft comment.'));
+ });
+ },
+ addToReview(note) {
+ const positionType = this.diffFileCommentForm
+ ? IMAGE_DIFF_POSITION_TYPE
+ : TEXT_DIFF_POSITION_TYPE;
+ const selectedDiffFile = this.getDiffFileByHash(this.diffFileHash);
+ const postData = getDraftFormData({
+ note,
+ notesData: this.notesData,
+ noteableData: this.noteableData,
+ noteableType: this.noteableType,
+ noteTargetLine: this.noteTargetLine,
+ diffViewType: this.diffViewType,
+ diffFile: selectedDiffFile,
+ linePosition: this.position,
+ positionType,
+ ...this.diffFileCommentForm,
+ });
+
+ const diffFileHeadSha = this.commit && this?.diffFile?.diff_refs?.head_sha;
+
+ postData.data.note.commit_id = diffFileHeadSha || null;
+
+ return this.saveDraft(postData)
+ .then(() => {
+ if (positionType === IMAGE_DIFF_POSITION_TYPE) {
+ this.closeDiffFileCommentForm(this.diffFileHash);
+ } else {
+ this.handleClearForm(this.line.line_code);
+ }
+ })
+ .catch(() => {
+ createFlash(s__('MergeRequests|An error occurred while saving the draft comment.'));
+ });
+ },
+ handleClearForm(lineCode) {
+ this.cancelCommentForm({
+ lineCode,
+ fileHash: this.diffFileHash,
+ });
+ this.$nextTick(() => {
+ if (this.autosaveKey) {
+ clearDraft(this.autosaveKey);
+ } else {
+ // TODO: remove the following after replacing the autosave mixin
+ // https://gitlab.com/gitlab-org/gitlab-foss/issues/60587
+ this.resetAutoSave();
+ }
+ });
+ },
+ showDraft(replyId) {
+ return this.withBatchComments && this.shouldRenderDraftRowInDiscussion(replyId);
+ },
},
};
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index c9026352d18..9281149d9d3 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,5 +1,5 @@
import { mapGetters, mapActions, mapState } from 'vuex';
-import { scrollToElement } from '~/lib/utils/common_utils';
+import { scrollToElementWithContext } from '~/lib/utils/common_utils';
import eventHub from '../event_hub';
/**
@@ -10,7 +10,7 @@ function scrollTo(selector) {
const el = document.querySelector(selector);
if (el) {
- scrollToElement(el);
+ scrollToElementWithContext(el);
return true;
}
diff --git a/app/assets/javascripts/notes/mixins/draft.js b/app/assets/javascripts/notes/mixins/draft.js
deleted file mode 100644
index 1370f3978df..00000000000
--- a/app/assets/javascripts/notes/mixins/draft.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export default {
- computed: {
- isDraft: () => false,
- canResolve() {
- return this.note.current_user.can_resolve;
- },
- },
-};
diff --git a/app/assets/javascripts/notes/mixins/get_discussion.js b/app/assets/javascripts/notes/mixins/get_discussion.js
deleted file mode 100644
index b5d820fe083..00000000000
--- a/app/assets/javascripts/notes/mixins/get_discussion.js
+++ /dev/null
@@ -1,7 +0,0 @@
-export default {
- computed: {
- discussion() {
- return {};
- },
- },
-};
diff --git a/app/assets/javascripts/notes/mixins/note_form.js b/app/assets/javascripts/notes/mixins/note_form.js
deleted file mode 100644
index b74879f2256..00000000000
--- a/app/assets/javascripts/notes/mixins/note_form.js
+++ /dev/null
@@ -1,24 +0,0 @@
-export default {
- data() {
- return {
- showBatchCommentsActions: false,
- };
- },
- methods: {
- handleKeySubmit() {
- this.handleUpdate();
- },
- handleUpdate(shouldResolve) {
- const beforeSubmitDiscussionState = this.discussionResolved;
- this.isSubmitting = true;
-
- this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
- this.isSubmitting = false;
-
- if (this.shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState)) {
- this.resolveHandler(beforeSubmitDiscussionState);
- }
- });
- },
- },
-};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 0999d0aa7ac..a5b006fc301 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -524,12 +524,55 @@ export const submitSuggestion = (
const defaultMessage = __(
'Something went wrong while applying the suggestion. Please try again.',
);
- const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage;
+
+ const errorMessage = err.response.data?.message;
+
+ const flashMessage = errorMessage || defaultMessage;
Flash(__(flashMessage), 'alert', flashContainer);
});
};
+export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContainer }) => {
+ const suggestionIds = state.batchSuggestionsInfo.map(({ suggestionId }) => suggestionId);
+
+ const applyAllSuggestions = () =>
+ state.batchSuggestionsInfo.map(suggestionInfo =>
+ commit(types.APPLY_SUGGESTION, suggestionInfo),
+ );
+
+ const resolveAllDiscussions = () =>
+ state.batchSuggestionsInfo.map(suggestionInfo => {
+ const { discussionId } = suggestionInfo;
+ return dispatch('resolveDiscussion', { discussionId }).catch(() => {});
+ });
+
+ commit(types.SET_APPLYING_BATCH_STATE, true);
+
+ return Api.applySuggestionBatch(suggestionIds)
+ .then(() => Promise.all(applyAllSuggestions()))
+ .then(() => Promise.all(resolveAllDiscussions()))
+ .then(() => commit(types.CLEAR_SUGGESTION_BATCH))
+ .catch(err => {
+ const defaultMessage = __(
+ 'Something went wrong while applying the batch of suggestions. Please try again.',
+ );
+
+ const errorMessage = err.response.data?.message;
+
+ const flashMessage = errorMessage || defaultMessage;
+
+ Flash(__(flashMessage), 'alert', flashContainer);
+ })
+ .finally(() => commit(types.SET_APPLYING_BATCH_STATE, false));
+};
+
+export const addSuggestionInfoToBatch = ({ commit }, { suggestionId, noteId, discussionId }) =>
+ commit(types.ADD_SUGGESTION_TO_BATCH, { suggestionId, noteId, discussionId });
+
+export const removeSuggestionInfoFromBatch = ({ commit }, suggestionId) =>
+ commit(types.REMOVE_SUGGESTION_FROM_BATCH, suggestionId);
+
export const convertToDiscussion = ({ commit }, noteId) =>
commit(types.CONVERT_TO_DISCUSSION, noteId);
@@ -587,6 +630,10 @@ export const softDeleteDescriptionVersion = (
.catch(error => {
dispatch('receiveDeleteDescriptionVersionError', error);
Flash(__('Something went wrong while deleting description changes. Please try again.'));
+
+ // Throw an error here because a component like SystemNote -
+ // needs to know if the request failed to reset its internal state.
+ throw new Error();
});
};
@@ -600,5 +647,9 @@ export const receiveDeleteDescriptionVersionError = ({ commit }, error) => {
commit(types.RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR, error);
};
+export const updateAssignees = ({ commit }, assignees) => {
+ commit(types.UPDATE_ASSIGNEES, assignees);
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 25f0f546103..329bf5e147e 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -11,6 +11,7 @@ export default () => ({
targetNoteHash: null,
lastFetchedAt: null,
currentDiscussionId: null,
+ batchSuggestionsInfo: [],
// View layer
isToggleStateButtonLoading: false,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index 2f7b2788d8a..538774ee467 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -17,8 +17,13 @@ export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE';
export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE';
export const DISABLE_COMMENTS = 'DISABLE_COMMENTS';
export const APPLY_SUGGESTION = 'APPLY_SUGGESTION';
+export const SET_APPLYING_BATCH_STATE = 'SET_APPLYING_BATCH_STATE';
+export const ADD_SUGGESTION_TO_BATCH = 'ADD_SUGGESTION_TO_BATCH';
+export const REMOVE_SUGGESTION_FROM_BATCH = 'REMOVE_SUGGESTION_FROM_BATCH';
+export const CLEAR_SUGGESTION_BATCH = 'CLEAR_SUGGESTION_BATCH';
export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION';
export const REMOVE_CONVERTED_DISCUSSION = 'REMOVE_CONVERTED_DISCUSSION';
+export const UPDATE_ASSIGNEES = 'UPDATE_ASSIGNEES';
// DISCUSSION
export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index f06874991f0..2aeadcb2da1 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -225,6 +225,39 @@ export default {
}));
},
+ [types.SET_APPLYING_BATCH_STATE](state, isApplyingBatch) {
+ state.batchSuggestionsInfo.forEach(suggestionInfo => {
+ const { discussionId, noteId, suggestionId } = suggestionInfo;
+
+ const noteObj = utils.findNoteObjectById(state.discussions, discussionId);
+ const comment = utils.findNoteObjectById(noteObj.notes, noteId);
+
+ comment.suggestions = comment.suggestions.map(suggestion => ({
+ ...suggestion,
+ is_applying_batch: suggestion.id === suggestionId && isApplyingBatch,
+ }));
+ });
+ },
+
+ [types.ADD_SUGGESTION_TO_BATCH](state, { noteId, discussionId, suggestionId }) {
+ state.batchSuggestionsInfo.push({
+ suggestionId,
+ noteId,
+ discussionId,
+ });
+ },
+
+ [types.REMOVE_SUGGESTION_FROM_BATCH](state, id) {
+ const index = state.batchSuggestionsInfo.findIndex(({ suggestionId }) => suggestionId === id);
+ if (index !== -1) {
+ state.batchSuggestionsInfo.splice(index, 1);
+ }
+ },
+
+ [types.CLEAR_SUGGESTION_BATCH](state) {
+ state.batchSuggestionsInfo.splice(0, state.batchSuggestionsInfo.length);
+ },
+
[types.UPDATE_DISCUSSION](state, noteData) {
const note = noteData;
const selectedDiscussion = state.discussions.find(disc => disc.id === note.id);
@@ -322,4 +355,7 @@ export default {
[types.RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR](state) {
state.isLoadingDescriptionVersion = false;
},
+ [types.UPDATE_ASSIGNEES](state, assignees) {
+ state.noteableData.assignees = assignees;
+ },
};