Commit dce97a1c authored by Tim Zallmann's avatar Tim Zallmann

Merge branch '1984-frontend-for-batch-comments' into 'master'

[Frontend only] Batch comments on merge requests

See merge request gitlab-org/gitlab-ee!7376
parents 26f4a180 d79f8b1c
......@@ -127,7 +127,6 @@ export default {
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
]),
fetchData() {
this.fetchDiffFiles()
.then(() => {
......
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { s__ } from '~/locale';
import batchCommentsDiffLineNoteFormMixin from 'ee/batch_comments/mixins/diff_line_note_form';
import noteForm from '../../notes/components/note_form.vue';
import autosave from '../../notes/mixins/autosave';
import { DIFF_NOTE_TYPE } from '../constants';
......@@ -9,7 +10,7 @@ export default {
components: {
noteForm,
},
mixins: [autosave],
mixins: [autosave, batchCommentsDiffLineNoteFormMixin],
props: {
diffFileHash: {
type: String,
......@@ -97,6 +98,7 @@ export default {
:line-code="line.lineCode"
save-button-title="Comment"
class="diff-comment-form"
@handleFormUpdateAddToReview="addToReview"
@cancelForm="handleCancelCommentForm"
@handleFormUpdate="handleSaveNote"
/>
......
<script>
import { mapGetters, mapState } from 'vuex';
import InlineDraftCommentRow from 'ee/batch_comments/components/inline_draft_comment_row.vue';
import inlineDiffTableRow from './inline_diff_table_row.vue';
import inlineDiffCommentRow from './inline_diff_comment_row.vue';
......@@ -7,6 +8,7 @@ export default {
components: {
inlineDiffCommentRow,
inlineDiffTableRow,
InlineDraftCommentRow,
},
props: {
diffFile: {
......@@ -20,6 +22,7 @@ export default {
},
computed: {
...mapGetters('diffs', ['commitId', 'shouldRenderInlineCommentRow']),
...mapGetters('batchComments', ['shouldRenderDraftRow', 'draftForLine']),
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
......@@ -56,6 +59,11 @@ export default {
:line="line"
:line-index="index"
/>
<inline-draft-comment-row
v-if="shouldRenderDraftRow(diffFile.fileHash, line)"
:key="`draft_${index}`"
:draft="draftForLine(diffFile.fileHash, line)"
/>
</template>
</tbody>
</table>
......
<script>
import { mapState, mapGetters } from 'vuex';
import ParallelDraftCommentRow from 'ee/batch_comments/components/parallel_draft_comment_row.vue';
import parallelDiffTableRow from './parallel_diff_table_row.vue';
import parallelDiffCommentRow from './parallel_diff_comment_row.vue';
......@@ -7,6 +8,7 @@ export default {
components: {
parallelDiffTableRow,
parallelDiffCommentRow,
ParallelDraftCommentRow,
},
props: {
diffFile: {
......@@ -20,6 +22,7 @@ export default {
},
computed: {
...mapGetters('diffs', ['commitId', 'shouldRenderParallelCommentRow']),
...mapGetters('batchComments', ['shouldRenderParallelDraftRow', 'draftForLine']),
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
......@@ -58,6 +61,12 @@ export default {
:diff-file-hash="diffFile.fileHash"
:line-index="index"
/>
<parallel-draft-comment-row
v-if="shouldRenderParallelDraftRow(diffFile.fileHash, line)"
:key="`drafts-${index}`"
:line="line"
:diff-file-content-sha="diffFile.fileHash"
/>
</template>
</tbody>
</table>
......
......@@ -25,7 +25,7 @@ export const getReversePosition = linePosition => {
return LINE_POSITION_RIGHT;
};
export function getNoteFormData(params) {
export function getFormData(params) {
const {
note,
noteableType,
......@@ -70,9 +70,15 @@ export function getNoteFormData(params) {
},
};
return postData;
}
export function getNoteFormData(params) {
const data = getFormData(params);
return {
endpoint: noteableData.create_note_path,
data: postData,
endpoint: params.noteableData.create_note_path,
data,
};
}
......
......@@ -2,14 +2,19 @@ import Vue from 'vue';
import Vuex from 'vuex';
import notesModule from '~/notes/stores/modules';
import diffsModule from '~/diffs/store/modules';
import batchCommentsModule from 'ee/batch_comments/stores/modules/batch_comments';
import mrPageModule from './modules';
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
page: mrPageModule,
notes: notesModule(),
diffs: diffsModule(),
},
});
export const createStore = () =>
new Vuex.Store({
modules: {
page: mrPageModule,
notes: notesModule(),
diffs: diffsModule(),
batchComments: batchCommentsModule(),
},
});
export default createStore();
......@@ -7,10 +7,14 @@ import editSvg from 'icons/_icon_pencil.svg';
import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg';
import ellipsisSvg from 'icons/_ellipsis_v.svg';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
name: 'NoteActions',
components: {
Icon,
},
directives: {
tooltip,
},
......@@ -20,7 +24,7 @@ export default {
required: true,
},
noteId: {
type: String,
type: [String, Number],
required: true,
},
noteUrl: {
......@@ -35,7 +39,8 @@ export default {
},
reportAbusePath: {
type: String,
required: true,
required: false,
default: null,
},
canEdit: {
type: Boolean,
......@@ -84,6 +89,9 @@ export default {
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
showDeleteAction() {
return this.canDelete && !this.canReportAsAbuse && !this.noteUrl;
},
isAuthoredByCurrentUser() {
return this.authorId === this.currentUserId;
},
......@@ -201,7 +209,26 @@ export default {
</button>
</div>
<div
v-if="shouldShowActionsDropdown"
v-if="showDeleteAction"
class="note-actions-item"
>
<button
v-tooltip
type="button"
title="Delete comment"
class="note-action-button js-note-delete btn btn-transparent"
data-container="body"
data-placement="bottom"
@click="onDelete"
>
<icon
name="remove"
class="link-highlight"
/>
</button>
</div>
<div
v-else-if="shouldShowActionsDropdown"
class="dropdown more-actions note-actions-item">
<button
v-tooltip
......
......@@ -109,7 +109,7 @@ export default {
class="note_edited_ago"
/>
<note-awards-list
v-if="note.award_emoji.length"
v-if="note.award_emoji && note.award_emoji.length"
:note-id="note.id"
:note-author-id="note.author.id"
:awards="note.award_emoji"
......
......@@ -6,13 +6,16 @@ import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
// eslint-disable-next-line import/order
import noteFormMixin from 'ee/batch_comments/mixins/note_form';
export default {
name: 'NoteForm',
components: {
issueWarning,
markdownField,
},
mixins: [issuableStateMixin, resolvable],
mixins: [issuableStateMixin, resolvable, noteFormMixin],
props: {
noteBody: {
type: String,
......@@ -20,7 +23,7 @@ export default {
default: '',
},
noteId: {
type: String,
type: [String, Number],
required: false,
default: '',
},
......@@ -55,6 +58,7 @@ export default {
conflictWhileEditing: false,
isSubmitting: false,
isResolving: false,
isUnresolving: false,
resolveAsThread: true,
};
},
......@@ -184,27 +188,88 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
@keydown.esc="cancelHandler(true)">
</textarea>
</markdown-field>
<div class="note-form-actions clearfix">
<button
:disabled="isDisabled"
type="button"
class="js-vue-issue-save btn btn-success js-comment-button "
@click="handleUpdate()">
{{ saveButtonTitle }}
</button>
<button
v-if="discussion.resolvable"
class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
@click.prevent="handleUpdate(true)"
<div
class="note-form-actions clearfix"
>
<template
v-if="withBatchComments && noteId === ''"
>
<p
v-if="discussion && discussion.id"
>
<label>
<template
v-if="discussionResolved"
>
<input
v-model="isUnresolving"
type="checkbox"
/>
{{ __('Unresolve discussion') }}
</template>
<template
v-else
>
<input
v-model="isResolving"
type="checkbox"
/>
{{ __('Resolve discussion') }}
</template>
</label>
</p>
<div>
<button
:disabled="isDisabled"
type="button"
class="btn btn-success"
@click="handleAddToReview()">
<template v-if="hasDrafts">
{{ __('Add to review') }}
</template>
<template v-else>
{{ __('Start a review') }}
</template>
</button>
<button
:disabled="isDisabled"
type="button"
class="btn"
@click="handleUpdate()">
{{ __('Add comment now') }}
</button>
<button
class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
type="button"
@click="cancelHandler()">
{{ __('Cancel') }}
</button>
</div>
</template>
<template
v-else
>
{{ resolveButtonTitle }}
</button>
<button
class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
type="button"
@click="cancelHandler()">
Cancel
</button>
<button
:disabled="isDisabled"
type="button"
class="js-vue-issue-save btn btn-success js-comment-button"
@click="handleUpdate()">
{{ saveButtonTitle }}
</button>
<button
v-if="discussion.resolvable"
class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
@click.prevent="handleUpdate(true)"
>
{{ resolveButtonTitle }}
</button>
<button
class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
type="button"
@click="cancelHandler()">
Cancel
</button>
</template>
</div>
</form>
</div>
......
......@@ -14,7 +14,8 @@ export default {
},
createdAt: {
type: String,
required: true,
required: false,
default: null,
},
actionText: {
type: String,
......@@ -22,8 +23,9 @@ export default {
default: '',
},
noteId: {
type: String,
required: true,
type: [String, Number],
required: false,
default: null,
},
includeToggle: {
type: Boolean,
......@@ -96,18 +98,22 @@ export default {
<span class="system-note-message">
<slot></slot>
</span>
<span class="system-note-separator">
&middot;
</span>
<a
:href="noteTimestampLink"
class="note-timestamp system-note-separator"
@click="updateTargetNoteHash">
<time-ago-tooltip
:time="createdAt"
tooltip-placement="bottom"
/>
</a>
<template
v-if="createdAt"
>
<span class="system-note-separator">
&middot;
</span>
<a
:href="noteTimestampLink"
class="note-timestamp system-note-separator"
@click="updateTargetNoteHash">
<time-ago-tooltip
:time="createdAt"
tooltip-placement="bottom"
/>
</a>
</template>
<i
class="fa fa-spinner fa-spin editing-spinner"
aria-label="Comment is being updated"
......
......@@ -6,6 +6,8 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { truncateSha } from '~/lib/utils/text_utility';
import systemNote from '~/vue_shared/components/notes/system_note.vue';
import { s__ } from '~/locale';
import batchCommentsDiffLineNoteFormMixin from 'ee/batch_comments/mixins/diff_line_note_form';
import DraftNote from 'ee/batch_comments/components/draft_note.vue';
import Flash from '../../flash';
import { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
......@@ -36,11 +38,18 @@ export default {
placeholderNote,
placeholderSystemNote,
systemNote,
DraftNote,
},
directives: {
tooltip,
},
mixins: [autosave, noteable, resolvable, discussionNavigation],
mixins: [
autosave,
noteable,
resolvable,
discussionNavigation,
batchCommentsDiffLineNoteFormMixin,
],
props: {
discussion: {
type: Object,
......@@ -71,6 +80,7 @@ export default {
return {
isReplying: false,
isResolving: false,
isUnresolving: false,
resolveAsThread: true,
};
},
......@@ -356,7 +366,13 @@ Please check your network connection and try again.`;
@handleDeleteNote="deleteNoteHandler"
/>
</ul>
<draft-note
v-if="showDraft(discussion.reply_id)"
:key="`draft_${discussion.id}`"
:draft="draftForDiscussion(discussion.reply_id)"
/>
<div
v-else
:class="{ 'is-replying': isReplying }"
class="discussion-reply-holder"
>
......@@ -433,6 +449,7 @@ Please check your network connection and try again.`;
:discussion="discussion"
:is-editing="false"
save-button-title="Comment"
@handleFormUpdateAddToReview="addReplyToReview"
@handleFormUpdate="saveReply"
@cancelForm="cancelReplyForm"
/>
......
......@@ -52,7 +52,7 @@ export default {
return this.note.resolvable && !!this.getUserData.id;
},
canReportAsAbuse() {
return this.note.report_abuse_path && this.author.id !== this.getUserData.id;
return !!this.note.report_abuse_path && this.author.id !== this.getUserData.id;
},
noteAnchorId() {
return `note_${this.note.id}`;
......@@ -81,13 +81,17 @@ export default {
...mapActions(['deleteNote', 'updateNote', 'toggleResolveNote', 'scrollToNoteIfNeeded']),
editHandler() {
this.isEditing = true;
this.$emit('handleEdit');
},
deleteHandler() {
const typeOfComment = this.note.isDraft ? 'pending comment' : 'comment';
// eslint-disable-next-line no-alert
if (window.confirm('Are you sure you want to delete this comment?')) {
if (window.confirm(`Are you sure you want to delete this ${typeOfComment}?`)) {
this.isDeleting = true;
this.$emit('handleDeleteNote', this.note);
if (this.note.isDraft) return;
this.deleteNote(this.note)
.then(() => {
this.isDeleting = false;
......@@ -98,7 +102,23 @@ export default {
});
}
},
updateSuccess() {
this.isEditing = false;
this.isRequesting = false;
this.oldContent = null;
$(this.$refs.noteBody.$el).renderGFM();
this.$refs.noteBody.resetAutoSave();
this.$emit('updateSuccess');
},
formUpdateHandler(noteText, parentElement, callback) {
this.$emit('handleUpdateNote', {
note: this.note,
noteText,
callback: () => this.updateSuccess(),
});
if (this.note.isDraft) return;
const data = {
endpoint: this.note.path,
note: {
......@@ -113,11 +133,7 @@ export default {
this.updateNote(data)
.then(() => {
this.isEditing = false;
this.isRequesting = false;
this.oldContent = null;
$(this.$refs.noteBody.$el).renderGFM();
this.$refs.noteBody.resetAutoSave();
this.updateSuccess();
callback();
})
.catch(() => {
......@@ -142,6 +158,7 @@ export default {
this.oldContent = null;
}
this.isEditing = false;
this.$emit('cancelForm');
},
recoverNoteContent(noteText) {
// we need to do this to prevent noteForm inconsistent content warning
......
......@@ -150,11 +150,24 @@ export const toggleIssueLocalState = ({ commit }, newState) => {
export const saveNote = ({ commit, dispatch }, noteData) => {
// For MR discussuions we need to post as `note[note]` and issue we use `note.note`.
const note = noteData.data['note[note]'] || noteData.data.note.note;
// For batch comments, we use draft_note
const note = noteData.data.draft_note || noteData.data['note[note]'] || noteData.data.note.note;
let placeholderText = note;
const hasQuickActions = utils.hasQuickActions(placeholderText);
const replyId = noteData.data.in_reply_to_discussion_id;
const methodToDispatch = replyId ? 'replyToDiscussion' : 'createNewNote';
let methodToDispatch;
const postData = Object.assign({}, noteData);
if (postData.isDraft === true) {
methodToDispatch = replyId
? 'batchComments/addDraftToDiscussion'
: 'batchComments/createNewDraft';
if (!postData.draft_note && noteData.note) {
postData.draft_note = postData.note;
delete postData.note;
}
} else {
methodToDispatch = replyId ? 'replyToDiscussion' : 'createNewNote';
}
$('.notes-form .flash-container').hide(); // hide previous flash notification
commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders
......@@ -180,7 +193,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
}
}
return dispatch(methodToDispatch, noteData).then(res => {
return dispatch(methodToDispatch, postData, { root: true }).then(res => {
const { errors } = res;
const commandsChanges = res.commands_changes;
......
......@@ -74,6 +74,9 @@ export const allDiscussions = (state, getters) => {
return Object.values(resolved).concat(unresolved);
};
export const isDiscussionResolved = (state, getters) => discussionId =>
getters.resolvedDiscussionsById[discussionId] !== undefined;
export const allResolvableDiscussions = (state, getters) =>
getters.allDiscussions.filter(d => !d.individual_note && d.resolvable);
......
......@@ -9,8 +9,7 @@
padding-left: $contextual-sidebar-width;
}
.issues-bulk-update.right-sidebar.right-sidebar-expanded
.issuable-sidebar-header {
.issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header {
padding: 10px 0 15px;
}
}
......@@ -75,7 +74,7 @@
.nav-sidebar {
transition: width $sidebar-transition-duration, left $sidebar-transition-duration;
position: fixed;
z-index: 400;
z-index: 600;
width: $contextual-sidebar-width;
top: $header-height;
bottom: 0;
......@@ -86,8 +85,7 @@
&:not(.sidebar-collapsed-desktop) {
@media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) {
box-shadow: inset -1px 0 0 $border-color,
2px 1px 3px $dropdown-shadow-color;
box-shadow: inset -1px 0 0 $border-color, 2px 1px 3px $dropdown-shadow-color;
}
}
......
......@@ -343,6 +343,10 @@ ul.notes {
&.parallel {
border-width: 1px;
&.new {
border-right-width: 0;
}
}
.discussion-notes {
......@@ -738,7 +742,7 @@ ul.notes {
padding-top: 0;
.discussion-wrapper {
border-color: transparent;
border: 0;
}
}
}
......
......@@ -180,7 +180,7 @@ module NotesHelper
notesPath: notes_url,
totalNotes: issuable.discussions.length,
lastFetchedAt: Time.now.to_i
}.to_json
}
end
def discussion_resolved_intro(discussion)
......
......@@ -6,7 +6,7 @@
= link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
%section.js-vue-notes-event
#js-vue-notes{ data: { notes_data: notes_data(@issue),
#js-vue-notes{ data: { notes_data: notes_data(@issue).to_json,
noteable_data: serialize_issuable(@issue),
noteable_type: 'Issue',
target_type: 'issue',
......
......@@ -60,7 +60,7 @@
%section.col-md-12
%script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
.issuable-discussion.js-vue-notes-event
#js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request),
#js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request).to_json,
noteable_data: serialize_issuable(@merge_request),
noteable_type: 'MergeRequest',
target_type: 'merge_request',
......
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import NoteableNote from '~/notes/components/noteable_note.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import PublishButton from './publish_button.vue';
export default {
components: {
NoteableNote,
PublishButton,
Icon,
LoadingButton,
},
props: {
draft: {
type: Object,
required: true,
},
},
data() {
return {
isEditingDraft: false,
};
},
computed: {
...mapState('batchComments', ['isPublishing']),
...mapGetters(['isDiscussionResolved']),
...mapGetters('batchComments', ['isPublishingDraft']),
resolvedStatusMessage() {
let message;
const discussionResolved = this.isDiscussionResolved(this.draft.discussion_id);
const discussionToBeResolved = this.draft.resolve_discussion;
if (discussionToBeResolved) {
if (discussionResolved) {
message = s__('MergeRequests|Discussion stays resolved.');
} else {
message = s__('MergeRequests|Discussion will be resolved.');
}
} else if (discussionResolved) {
message = s__('MergeRequests|Discussion will be unresolved.');
} else {
message = s__('MergeRequests|Discussion stays unresolved.');
}
return message;
},
componentClasses() {
return this.draft.resolve_discussion
? 'is-resolving-discussion'
: 'is-unresolving-discussion';
},
draftCommands() {
return this.draft.references.commands;
},
},
methods: {
...mapActions('batchComments', ['deleteDraft', 'updateDraft', 'publishSingleDraft']),
update(data) {
this.updateDraft(data);
},
publishNow() {
this.publishSingleDraft(this.draft.id);
},
handleEditing() {
this.isEditingDraft = true;
},
handleNotEditing() {
this.isEditingDraft = false;
},
},
};
</script>
<template>
<article
:class="componentClasses"
class="draft-note-component"
>
<header class="draft-note-header">
<strong class="badge draft-pending-label">
{{ __('Pending') }}
</strong>
<p
v-if="draft.discussion_id"
class="draft-note-resolution"
>
<Icon
:size="16"
name="status_success"
/>
{{ __(resolvedStatusMessage) }}
</p>
</header>
<ul class="notes draft-notes">
<noteable-note
:note="draft"
class="draft-note"
@handleEdit="handleEditing"
@cancelForm="handleNotEditing"
@updateSuccess="handleNotEditing"
@handleDeleteNote="deleteDraft"
@handleUpdateNote="update"
/>
</ul>
<template
v-if="!isEditingDraft"
>
<div
v-if="draftCommands"
class="referenced-commands draft-note-commands"
v-html="draftCommands"
>
</div>
<p class="draft-note-actions">
<publish-button
class="btn btn-success btn-inverted"
/>
<loading-button
:loading="isPublishingDraft(draft.id) || isPublishing"
:label="__('Add comment now')"
container-class="btn btn-inverted"
@click="publishNow"
/>
</p>
</template>
</article>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters('batchComments', ['draftsCount']),
},
};
</script>
<template>
<span class="drafts-count-component">
<span class="drafts-count-number">{{ draftsCount }}</span>
<span class="sr-only">
{{ n__("draft", "drafts", draftsCount) }}
</span>
</span>
</template>
<script>
import DraftNote from './draft_note.vue';
export default {
components: {
DraftNote,
},
props: {
draft: {
type: Object,
required: true,
},
},
};
</script>
<template>
<tr
class="notes_holder js-temp-notes-holder"
>
<td
class="notes_content"
colspan="3"
>
<div class="content">
<draft-note
:draft="draft"
/>
</div>
</td>
</tr>
</template>
<script>
import { mapGetters } from 'vuex';
import DraftNote from './draft_note.vue';
export default {
components: {
DraftNote,
},
props: {
line: {
type: Object,
required: true,
},
diffFileContentSha: {
type: String,
required: true,
},
},
computed: {
...mapGetters('batchComments', ['draftForLine']),
className() {
return this.leftDraft > 0 || this.rightDraft > 0 ? '' : 'js-temp-notes-holder';
},
leftDraft() {
return this.draftForLine(this.diffFileContentSha, this.line, 'left');
},
rightDraft() {
return this.draftForLine(this.diffFileContentSha, this.line, 'right');
},
},
};
</script>
<template>
<tr
:class="className"
class="notes_holder"
>
<td class="notes_line old"></td>
<td class="notes_content parallel old">
<div
v-if="leftDraft.isDraft"
class="content"
>
<draft-note
:draft="leftDraft"
/>
</div>
</td>
<td class="notes_line new"></td>
<td class="notes_content parallel new">
<div
v-if="rightDraft.isDraft"
class="content"
>
<draft-note
:draft="rightDraft"
/>
</div>
</td>
</tr>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import DraftsCount from './drafts_count.vue';
export default {
components: {
LoadingButton,
DraftsCount,
},
computed: {
...mapState('batchComments', ['isPublishing']),
},
methods: {
...mapActions('batchComments', ['publishReview']),
},
};
</script>
<template>
<loading-button
:loading="isPublishing"
container-class="btn btn-success"
@click="publishReview"
>
<span>
{{ __('Submit review') }}
<drafts-count />
</span>
</loading-button>
</template>
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { sprintf, s__ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import PublishButton from './publish_button.vue';
export default {
components: {
PublishButton,
LoadingButton,
},
computed: {
...mapState('batchComments', ['isDiscarding']),
...mapGetters('batchComments', ['draftsCount']),
},
methods: {
...mapActions('batchComments', ['discardReview']),
},
modalId: 'discard-draft-review',
text: sprintf(
s__(
`BatchComments|You're about to discard your review which will delete all of your pending comments.
The deleted comments %{strong_start}cannot%{strong_end} be restored.`,
),
{
strong_start: '<strong>',
strong_end: '</strong>',
},
false,
),
};
</script>
<template>
<div v-show="draftsCount > 0">
<nav class="review-bar-component">
<p class="review-bar-content">
<publish-button />
<loading-button
v-gl-modal="$options.modalId"
:loading="isDiscarding"
:label="__('Discard review')"
/>
</p>
</nav>
<gl-ui-modal
:title="s__('BatchComments|Discard review?')"
:ok-title="s__('BatchComments|Delete all pending comments')"
:modal-id="$options.modalId"
title-tag="h4"
ok-variant="danger"
@ok="discardReview"
>
<p v-html="$options.text">
</p>
</gl-ui-modal>
</div>
</template>
import Vue from 'vue';
import { mapState, mapActions } from 'vuex';
import store from '~/mr_notes/stores';
import ReviewBar from './components/review_bar.vue';
// eslint-disable-next-line import/prefer-default-export
export const initReviewBar = () => {
const el = document.getElementById('js-review-bar');
if (el) {
// eslint-disable-next-line no-new
new Vue({
el,
store,
computed: {
...mapState('batchComments', ['withBatchComments']),
},
created() {
this.enableBatchComments();
},
mounted() {
this.fetchDrafts();
},
methods: {
...mapActions('batchComments', ['fetchDrafts', 'enableBatchComments']),
},
render(createElement) {
if (this.withBatchComments) {
return createElement(ReviewBar);
}
return null;
},
});
}
};
import { mapActions, mapGetters, mapState } from 'vuex';
import { getDraftReplyFormData, getDraftFormData } from '../utils';
import createFlash from '~/flash';
import { s__ } from '~/locale';
export default {
computed: {
...mapState({
notesData: state => state.notes.notesData,
withBatchComments: state => state.batchComments && state.batchComments.withBatchComments,
}),
...mapGetters('diffs', ['getDiffFileByHash']),
...mapGetters('batchComments', ['shouldRenderDraftRowInDiscussion', 'draftForDiscussion']),
},
methods: {
...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 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,
});
return this.saveDraft(postData)
.then(() => {
this.handleClearForm(this.line.lineCode);
})
.catch(() => {
createFlash(s__('MergeRequests|An error occurred while saving the draft comment.'));
});
},
handleClearForm(lineCode) {
this.cancelCommentForm({
lineCode,
});
this.$nextTick(() => {
this.resetAutoSave();
});
},
showDraft(replyId) {
if (this.withBatchComments) {
return this.shouldRenderDraftRowInDiscussion(replyId);
}
return false;
},
},
};
import { mapGetters, mapState } from 'vuex';
export default {
computed: {
...mapState({
withBatchComments: state => state.batchComments && state.batchComments.withBatchComments,
}),
...mapGetters('batchComments', ['hasDrafts']),
},
methods: {
shouldBeResolved(resolveStatus) {
if (this.withBatchComments) {
return (
(this.discussionResolved && !this.isUnresolving) ||
(!this.discussionResolved && this.isResolving)
);
}
return resolveStatus;
},
handleUpdate(resolveStatus) {
const beforeSubmitDiscussionState = this.discussionResolved;
this.isSubmitting = true;
const shouldBeResolved = this.shouldBeResolved(resolveStatus) !== beforeSubmitDiscussionState;
this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
this.isSubmitting = false;
if (resolveStatus || (shouldBeResolved && this.withBatchComments)) {
this.resolveHandler(beforeSubmitDiscussionState); // this will toggle the state
}
});
},
handleAddToReview() {
// check if draft should resolve discussion
const shouldResolve =
(this.discussionResolved && !this.isUnresolving) ||
(!this.discussionResolved && this.isResolving);
this.isSubmitting = true;
this.$emit('handleFormUpdateAddToReview', this.updatedNoteBody, shouldResolve);
},
},
};
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default {
createNewDraft(endpoint, data) {
const postData = Object.assign({}, data, { draft_note: data.note });
delete postData.note;
return Vue.http.post(endpoint, postData, { emulateJSON: true });
},
deleteDraft(endpoint, draftId) {
return Vue.http.delete(`${endpoint}/${draftId}`, { emulateJSON: true });
},
publishDraft(endpoint, draftId) {
return Vue.http.post(endpoint, { id: draftId }, { emulateJSON: true });
},
addDraftToDiscussion(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
fetchDrafts(endpoint) {
return Vue.http.get(endpoint);
},
publish(endpoint) {
return Vue.http.post(endpoint);
},
discard(endpoint) {
return Vue.http.delete(endpoint);
},
update(endpoint, { draftId, note }) {
return Vue.http.put(`${endpoint}/${draftId}`, { draft_note: { note } }, { emulateJSON: true });
},
};
import Vue from 'vue';
import Vuex from 'vuex';
import batchComments from './modules/batch_comments';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
modules: {
batchComments: batchComments(),
},
});
export default createStore();
import flash from '~/flash';
import { __ } from '~/locale';
import service from '../../../services/drafts_service';
import * as types from './mutation_types';
export const enableBatchComments = ({ commit }) => {
commit(types.ENABLE_BATCH_COMMENTS);
};
export const saveDraft = ({ dispatch }, draft) =>
dispatch('saveNote', { ...draft, isDraft: true }, { root: true });
export const addDraftToDiscussion = ({ commit }, { endpoint, data }) =>
service
.addDraftToDiscussion(endpoint, data)
.then(res => res.json())
.then(res => {
commit(types.ADD_NEW_DRAFT, res);
return res;
})
.catch(() => {
flash(__('An error occurred adding a draft to the discussion.'));
});
export const createNewDraft = ({ commit }, { endpoint, data }) =>
service
.createNewDraft(endpoint, data)
.then(res => res.json())
.then(res => {
commit(types.ADD_NEW_DRAFT, res);
return res;
})
.catch(() => {
flash(__('An error occurred adding a new draft.'));
});
export const deleteDraft = ({ commit, getters }, draft) =>
service
.deleteDraft(getters.getNotesData.draftsPath, draft.id)
.then(() => {
commit(types.DELETE_DRAFT, draft.id);
})
.catch(() => flash(__('An error occurred while deleting the comment')));
export const fetchDrafts = ({ commit, getters }) =>
service
.fetchDrafts(getters.getNotesData.draftsPath)
.then(res => res.json())
.then(data => commit(types.SET_BATCH_COMMENTS_DRAFTS, data))
.catch(() => flash(__('An error occurred while fetching pending comments')));
export const publishSingleDraft = ({ commit, dispatch, getters }, draftId) => {
commit(types.REQUEST_PUBLISH_DRAFT, draftId);
service
.publishDraft(getters.getNotesData.draftsPublishPath, draftId)
.then(() => dispatch('updateDiscussionsAfterPublish'))
.then(() => commit(types.RECEIVE_PUBLISH_DRAFT_SUCCESS, draftId))
.catch(() => commit(types.RECEIVE_PUBLISH_DRAFT_ERROR, draftId));
};
export const publishReview = ({ commit, dispatch, getters }) => {
commit(types.REQUEST_PUBLISH_REVIEW);
return service
.publish(getters.getNotesData.draftsPublishPath)
.then(() => dispatch('updateDiscussionsAfterPublish'))
.then(() => commit(types.RECEIVE_PUBLISH_REVIEW_SUCCESS))
.catch(() => commit(types.RECEIVE_PUBLISH_REVIEW_ERROR));
};
export const updateDiscussionsAfterPublish = ({ dispatch, getters, rootGetters }) =>
dispatch('fetchDiscussions', getters.getNotesData.discussionsPath, { root: true }).then(() =>
dispatch('diffs/assignDiscussionsToDiff', rootGetters.discussionsStructuredByLineCode, {
root: true,
}),
);
export const discardReview = ({ commit, getters }) => {
commit(types.REQUEST_DISCARD_REVIEW);
return service
.discard(getters.getNotesData.draftsDiscardPath)
.then(() => commit(types.RECEIVE_DISCARD_REVIEW_SUCCESS))
.catch(() => commit(types.RECEIVE_DISCARD_REVIEW_ERROR));
};
export const updateDraft = ({ commit, getters }, { note, noteText, callback }) =>
service
.update(getters.getNotesData.draftsPath, { draftId: note.id, note: noteText })
.then(res => res.json())
.then(data => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data))
.then(callback)
.catch(() => flash(__('An error occurred while updating the comment')));
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import { parallelLineKey, showDraftOnSide } from '../../../utils';
export const draftsCount = state => state.drafts.length;
export const getNotesData = (state, getters, rootState, rootGetters) => rootGetters.getNotesData;
export const hasDrafts = state => state.drafts.length > 0;
export const draftsPerDiscussionId = state =>
state.drafts.reduce((acc, draft) => {
if (draft.discussion_id) {
acc[draft.discussion_id] = draft;
}
return acc;
}, {});
export const draftsPerFileHashAndLine = state =>
state.drafts.reduce((acc, draft) => {
if (draft.file_hash) {
if (!acc[draft.file_hash]) {
acc[draft.file_hash] = {};
}
acc[draft.file_hash][draft.line_code] = draft;
}
return acc;
}, {});
export const shouldRenderDraftRow = (state, getters) => (diffFileSha, line) =>
!!(
diffFileSha in getters.draftsPerFileHashAndLine &&
getters.draftsPerFileHashAndLine[diffFileSha][line.lineCode]
);
export const shouldRenderParallelDraftRow = (state, getters) => (diffFileSha, line) => {
const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha];
const [lkey, rkey] = [parallelLineKey(line, 'left'), parallelLineKey(line, 'right')];
return draftsForFile ? !!(draftsForFile[lkey] || draftsForFile[rkey]) : false;
};
export const shouldRenderDraftRowInDiscussion = (state, getters) => discussionId =>
typeof getters.draftsPerDiscussionId[discussionId] !== 'undefined';
export const draftForDiscussion = (state, getters) => discussionId =>
getters.draftsPerDiscussionId[discussionId] || {};
export const draftForLine = (state, getters) => (diffFileSha, line, side = null) => {
const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha];
const key = side !== null ? parallelLineKey(line, side) : line.lineCode;
if (draftsForFile) {
const draft = draftsForFile[key];
if (draft && showDraftOnSide(line, side)) {
return draft;
}
}
return {};
};
export const isPublishingDraft = state => draftId =>
state.currentlyPublishingDrafts.indexOf(draftId) !== -1;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import state from './state';
import mutations from './mutations';
import * as actions from './actions';
import * as getters from './getters';
export default () => ({
namespaced: true,
state: state(),
mutations,
actions,
getters,
});
export const ENABLE_BATCH_COMMENTS = 'ENABLE_BATCH_COMMENTS';
export const ADD_NEW_DRAFT = 'ADD_NEW_DRAFT';
export const DELETE_DRAFT = 'DELETE_DRAFT';
export const SET_BATCH_COMMENTS_DRAFTS = 'SET_BATCH_COMMENTS_DRAFTS';
export const REQUEST_PUBLISH_DRAFT = 'REQUEST_PUBLISH_DRAFT';
export const RECEIVE_PUBLISH_DRAFT_SUCCESS = 'RECEIVE_PUBLISH_DRAFT_SUCCESS';
export const RECEIVE_PUBLISH_DRAFT_ERROR = 'RECEIVE_PUBLISH_DRAFT_ERROR';
export const REQUEST_PUBLISH_REVIEW = 'REQUEST_PUBLISH_REVIEW';
export const RECEIVE_PUBLISH_REVIEW_SUCCESS = 'RECEIVE_PUBLISH_REVIEW_SUCCESS';
export const RECEIVE_PUBLISH_REVIEW_ERROR = 'RECEIVE_PUBLISH_REVIEW_ERROR';
export const REQUEST_DISCARD_REVIEW = 'REQUEST_DISCARD_REVIEW';
export const RECEIVE_DISCARD_REVIEW_SUCCESS = 'RECEIVE_DISCARD_REVIEW_SUCCESS';
export const RECEIVE_DISCARD_REVIEW_ERROR = 'RECEIVE_DISCARD_REVIEW_ERROR';
export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS';
import * as types from './mutation_types';
const processDraft = draft => ({
...draft,
isDraft: true,
});
export default {
[types.ENABLE_BATCH_COMMENTS](state) {
state.withBatchComments = true;
},
[types.ADD_NEW_DRAFT](state, draft) {
state.drafts.push(processDraft(draft));
},
[types.DELETE_DRAFT](state, draftId) {
state.drafts = state.drafts.filter(draft => draft.id !== draftId);
},
[types.SET_BATCH_COMMENTS_DRAFTS](state, drafts) {
state.drafts = drafts.map(processDraft);
},
[types.REQUEST_PUBLISH_DRAFT](state, draftId) {
state.currentlyPublishingDrafts.push(draftId);
},
[types.RECEIVE_PUBLISH_DRAFT_SUCCESS](state, draftId) {
state.currentlyPublishingDrafts = state.currentlyPublishingDrafts.filter(
publishingDraftId => publishingDraftId !== draftId,
);
state.drafts = state.drafts.filter(d => d.id !== draftId);
},
[types.RECEIVE_PUBLISH_DRAFT_ERROR](state, draftId) {
state.currentlyPublishingDrafts = state.currentlyPublishingDrafts.filter(
publishingDraftId => publishingDraftId !== draftId,
);
},
[types.REQUEST_PUBLISH_REVIEW](state) {
state.isPublishing = true;
},
[types.RECEIVE_PUBLISH_REVIEW_SUCCESS](state) {
state.isPublishing = false;
state.drafts = [];
},
[types.RECEIVE_PUBLISH_REVIEW_ERROR](state) {
state.isPublishing = false;
},
[types.REQUEST_DISCARD_REVIEW](state) {
state.isDiscarding = true;
},
[types.RECEIVE_DISCARD_REVIEW_SUCCESS](state) {
state.isDiscarding = false;
state.drafts = [];
},
[types.RECEIVE_DISCARD_REVIEW_ERROR](state) {
state.isDiscarding = false;
},
[types.RECEIVE_DRAFT_UPDATE_SUCCESS](state, data) {
const index = state.drafts.findIndex(draft => draft.id === data.id);
if (index >= 0) {
state.drafts.splice(index, 1, processDraft(data));
}
},
};
export default () => ({
withBatchComments: false,
isDraftsFetched: false,
drafts: [],
isPublishing: false,
currentlyPublishingDrafts: [],
isDiscarding: false,
});
import { getFormData } from '~/diffs/store/utils';
export const getDraftReplyFormData = data => ({
endpoint: data.notesData.draftsPath,
data,
});
export const getDraftFormData = params => ({
endpoint: params.notesData.draftsPath,
data: getFormData(params),
});
export const parallelLineKey = (line, side) => (line[side] ? line[side].lineCode : '');
export const showDraftOnSide = (line, side) => {
// inline mode
if (side === null) {
return true;
}
// parallel
if (side === 'left' || side === 'right') {
const otherSide = side === 'left' ? 'right' : 'left';
const thisCode = (line[side] && line[side].lineCode) || '';
const otherCode = (line[otherSide] && line[otherSide].lineCode) || '';
// either the lineCodes are different
// or if they're the same, only show on the left side
if (thisCode !== otherCode || (side === 'left' && thisCode === otherCode)) {
return true;
}
}
return false;
};
import initMrNotes from '~/mr_notes';
import initShow from '~/pages/projects/merge_requests/init_merge_request_show';
import initSidebarBundle from 'ee/sidebar/sidebar_bundle';
import { initReviewBar } from 'ee/batch_comments';
document.addEventListener('DOMContentLoaded', () => {
initShow();
initSidebarBundle();
initMrNotes();
initReviewBar();
});
.draft-note-component {
padding: $gl-padding-8 $gl_padding;
margin: 0;
background: $orange-50;
.note-actions {
margin-top: -26px;
}
p {
margin: 0;
}
.drafts-count-component {
@include transition(background-color);
background: rgba($green-500, 0.2);
}
button:focus,
button:hover {
.drafts-count-component {
background: $black-transparent;
}
}
&.is-resolving-discussion {
.draft-note-resolution svg {
color: $green-600;
}
}
&.is-unresolving-discussion {
.draft-note-resolution svg {
color: $gray-darkest;
}
}
.referenced-commands.draft-note-commands {
background: $orange-100;
font-size: $label-font-size;
margin-top: $gl-padding;
margin-left: 40px + $gl-padding;
}
.timeline-entry {
padding-left: 40px;
background-color: transparent;
}
}
button[disabled] {
&,
&:focus,
&:hover {
.drafts-count-component {
background: $gl-gray-100;
}
}
}
.draft-note-header {
padding: $gl-padding-8 0;
display: flex;
justify-content: space-between;
align-items: center;
.draft-note-resolution {
padding: $gl-padding-4 $gl-padding;
line-height: 1;
font-size: $label-font-size;
color: $theme-gray-700;
flex-grow: 1;
svg {
vertical-align: text-bottom;
display: inline-block;
}
}
}
.draft-pending-label {
background: $orange-600;
color: $white-light;
vertical-align: text-top;
}
.draft-note-actions {
padding: $gl-padding 56px $gl-padding-8;
}
.discussion-body,
.diff-file {
.notes .note {
&.draft-note {
border-bottom: 0;
.timeline-entry-inner {
padding-top: 0;
padding-bottom: 0;
padding-right: 0;
}
}
}
.notes_holder {
.notes_content {
&.parallel ul.draft-notes > li {
padding-left: 40px;
}
.notes {
&.draft-notes {
background-color: transparent;
}
}
}
}
}
$drafts-count-size: 1.6;
.drafts-count-component {
font-size: $label-font-size;
display: inline-block;
min-width: $drafts-count-size * 1em;
height: $drafts-count-size * 1em;
line-height: $drafts-count-size;
background: $black-transparent;
border-radius: 50%;
margin-left: 0.5em;
padding: 0 $gl-padding-4;
}
.review-bar-component {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: $white-light;
z-index: 100;
padding: 7px 0 6px; // to keep aligned with "collapse sidebar" button on the left sidebar
border-top: 1px solid $border-color;
padding-left: $contextual-sidebar-width;
padding-right: $gutter_collapsed_width;
transition: padding $sidebar-transition-duration;
.page-with-icon-sidebar & {
padding-left: $contextual-sidebar-collapsed-width;
}
.right-sidebar-expanded & {
padding-right: $gutter_width;
}
@media (max-width: map-get($grid-breakpoints, sm)-1) {
padding-left: 0;
padding-right: 0;
}
p {
@include clearfix;
margin: 0 auto;
text-align: right;
}
.btn {
float: right;
+ .btn {
margin-right: $grid-size;
}
}
}
.review-bar-content {
max-width: $limited-layout-width;
padding: 0 $gl-padding;
width: 100%;
margin: 0 auto;
}
module BatchCommentsHelper
def batch_comments_enabled?
current_user.present? && License.feature_available?(:batch_comments) && Feature.enabled?(:batch_comments, current_user, default_enabled: false)
end
end
......@@ -15,5 +15,20 @@ module EE
super
end
override :notes_data
def notes_data(issuable)
data = super
if issuable.is_a?(MergeRequest)
data.merge!(
draftsPath: project_merge_request_drafts_path(@project, issuable),
draftsPublishPath: publish_project_merge_request_drafts_path(@project, issuable),
draftsDiscardPath: discard_project_merge_request_drafts_path(@project, issuable)
)
end
data
end
end
end
- @gfm_form = true
%section.js-vue-notes-event
#js-vue-notes{ data: { notes_data: notes_data(@epic),
#js-vue-notes{ data: { notes_data: notes_data(@epic).to_json,
noteable_data: EpicSerializer.new(current_user: current_user).represent(@epic).to_json,
current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json, noteable_type: 'epic',
markdown_version: @issuable.cached_markdown_version } }
= render_ce "projects/merge_requests/show"
- if batch_comments_enabled?
#js-review-bar
-# haml-lint:disable InlineJavaScript
:javascript
// Append static, server-generated data not included in merge request entity (EE-Only)
......
---
title: Adds Batch Comments to Merge Requests [EEP]
merge_request:
author:
type: added
require 'rails_helper'
describe 'Merge request > Batch comments', :js do
include MergeRequestDiffHelpers
include RepoHelpers
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:merge_request) do
create(:merge_request_with_diffs, source_project: project, target_project: project, source_branch: 'merge-test')
end
before do
project.add_maintainer(user)
sign_in(user)
end
context 'Feature is disabled' do
before do
stub_feature_flags(batch_comments: false)
visit_diffs
end
it 'does not have review bar' do
expect(page).not_to have_css('.review-bar-component')
end
end
context 'Feature is enabled' do
before do
stub_licensed_features(batch_comments: true)
visit_diffs
end
it 'has review bar' do
expect(page).to have_css('.review-bar-component', visible: false)
end
it 'adds draft note' do
write_comment
expect(find('.draft-note-component')).to have_content('Line is wrong')
expect(page).to have_css('.review-bar-component')
expect(find('.review-bar-content .btn-success')).to have_content('1')
end
it 'publishes review' do
write_comment
page.within('.review-bar-content') do
click_button 'Submit review'
end
wait_for_requests
expect(page).not_to have_selector('.draft-note-component', text: 'Line is wrong')
expect(page).to have_selector('.note:not(.draft-note)', text: 'Line is wrong')
end
it 'publishes single comment' do
write_comment
click_button 'Add comment now'
wait_for_requests
expect(page).not_to have_selector('.draft-note-component', text: 'Line is wrong')
expect(page).to have_selector('.note:not(.draft-note)', text: 'Line is wrong')
end
it 'discards review' do
write_comment
click_button 'Discard review'
click_button 'Delete all pending comments'
wait_for_requests
expect(page).not_to have_selector('.draft-note-component')
end
it 'deletes draft note' do
write_comment
accept_alert { find('.js-note-delete').click }
wait_for_requests
expect(page).not_to have_selector('.draft-note-component', text: 'Line is wrong')
end
it 'edits draft note' do
write_comment
find('.js-note-edit').click
# make sure comment form is in view
execute_script("window.scrollBy(0, 200)")
page.within('.js-discussion-note-form') do
fill_in('note_note', with: 'Testing update')
click_button('Save comment')
end
wait_for_requests
expect(page).to have_selector('.draft-note-component', text: 'Testing update')
end
context 'in parallel diff' do
before do
click_button 'Side-by-side'
end
it 'adds draft comments to both sides' do
write_parallel_comment('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9')
write_parallel_comment('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9', button_text: 'Add to review', text: 'Another wrong line')
expect(find('.new .draft-note-component')).to have_content('Line is wrong')
expect(find('.old .draft-note-component')).to have_content('Another wrong line')
expect(find('.review-bar-content .btn-success')).to have_content('2')
end
end
end
def visit_diffs
visit diffs_project_merge_request_path(merge_request.project, merge_request)
wait_for_requests
end
def write_comment(button_text: 'Start a review', text: 'Line is wrong')
click_diff_line(find("[id='#{sample_compare.changes[0][:line_code]}']"))
page.within('.js-discussion-note-form') do
fill_in('note_note', with: text)
click_button(button_text)
end
wait_for_requests
end
def write_parallel_comment(line, button_text: 'Start a review', text: 'Line is wrong')
find("td[id='#{line}']").hover
find(".is-over button").click
page.within("form[data-line-code='#{line}']") do
fill_in('note_note', with: text)
click_button(button_text)
end
wait_for_requests
end
end
import Vue from 'vue';
import DraftNote from 'ee/batch_comments/components/draft_note.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createStore } from '~/mr_notes/stores';
import '~/behaviors/markdown/render_gfm';
import { createDraft } from '../mock_data';
describe('Batch comments draft note component', () => {
let vm;
let Component;
let draft;
beforeAll(() => {
Component = Vue.extend(DraftNote);
});
beforeEach(() => {
const store = createStore();
draft = createDraft();
vm = mountComponentWithStore(Component, { store, props: { draft } });
spyOn(vm.$store, 'dispatch').and.stub();
});
afterEach(() => {
vm.$destroy();
});
it('renders template', () => {
expect(vm.$el.querySelector('.draft-pending-label')).not.toBe(null);
expect(vm.$el.querySelector('.draft-notes').textContent).toContain('Test');
});
describe('in discussion', () => {
beforeEach(done => {
vm.draft.discussion_id = 123;
vm.$nextTick(done);
});
it('renders resolution status', () => {
expect(vm.$el.querySelector('.draft-note-resolution')).not.toBe(null);
});
describe('resolvedStatusMessage', () => {
describe('resolve discussion', () => {
it('return will be resolved text', () => {
vm.draft.resolve_discussion = true;
expect(vm.resolvedStatusMessage).toBe('Discussion will be resolved.');
});
it('return will be stays resolved text', () => {
spyOnProperty(vm, 'isDiscussionResolved').and.returnValue(() => true);
vm.draft.resolve_discussion = true;
expect(vm.resolvedStatusMessage).toBe('Discussion stays resolved.');
});
});
describe('unresolve discussion', () => {
it('return will be stays unresolved text', () => {
expect(vm.resolvedStatusMessage).toBe('Discussion stays unresolved.');
});
it('return will be unresolved text', () => {
spyOnProperty(vm, 'isDiscussionResolved').and.returnValue(() => true);
vm.$forceUpdate();
expect(vm.resolvedStatusMessage).toBe('Discussion stays unresolved.');
});
});
it('adds resolving class to element', done => {
vm.draft.resolve_discussion = true;
vm.$nextTick(() => {
expect(vm.$el.classList).toContain('is-resolving-discussion');
done();
});
});
it('adds unresolving class to element', () => {
expect(vm.$el.classList).toContain('is-unresolving-discussion');
});
});
});
describe('add comment now', () => {
it('dispatches publishSingleDraft when clicking', () => {
vm.$el.querySelectorAll('.btn-inverted')[1].click();
expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/publishSingleDraft', 1);
});
it('sets as loading when draft is publishing', done => {
vm.$store.state.batchComments.currentlyPublishingDrafts.push(1);
vm.$nextTick(() => {
expect(vm.$el.querySelectorAll('.btn-inverted')[1].getAttribute('disabled')).toBe(
'disabled',
);
done();
});
});
});
describe('update', () => {
it('dispatches updateDraft', done => {
vm.$el.querySelector('.js-note-edit').click();
vm
.$nextTick()
.then(() => {
vm.$el.querySelector('.js-vue-issue-save').click();
expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/updateDraft', {
note: draft,
noteText: 'a',
callback: jasmine.any(Function),
});
})
.then(done)
.catch(done.fail);
});
});
describe('deleteDraft', () => {
it('dispatches deleteDraft', () => {
spyOn(window, 'confirm').and.callFake(() => true);
vm.$el.querySelector('.js-note-delete').click();
expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/deleteDraft', draft);
});
});
describe('quick actions', () => {
it('renders referenced commands', done => {
vm.draft.references.commands = 'test command';
vm.$nextTick(() => {
expect(vm.$el.querySelector('.referenced-commands')).not.toBe(null);
expect(vm.$el.querySelector('.referenced-commands').textContent).toContain('test command');
done();
});
});
});
});
import Vue from 'vue';
import DraftsCount from 'ee/batch_comments/components/drafts_count.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createStore } from '~/mr_notes/stores';
describe('Batch comments drafts count component', () => {
let vm;
let Component;
beforeAll(() => {
Component = Vue.extend(DraftsCount);
});
beforeEach(() => {
const store = createStore();
store.state.batchComments.drafts.push('comment');
vm = mountComponentWithStore(Component, { store });
});
afterEach(() => {
vm.$destroy();
});
it('renders count', () => {
expect(vm.$el.querySelector('.drafts-count-number').textContent).toBe('1');
});
it('renders screen reader text', done => {
const el = vm.$el.querySelector('.sr-only');
expect(el.textContent).toContain('draft');
vm.$store.state.batchComments.drafts.push('comment 2');
vm.$nextTick(() => {
expect(el.textContent).toContain('drafts');
done();
});
});
});
import Vue from 'vue';
import InlineDraftCommentRow from 'ee/batch_comments/components/inline_draft_comment_row.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createStore } from 'ee/batch_comments/stores';
import '~/behaviors/markdown/render_gfm';
import { createDraft } from '../mock_data';
describe('Batch comments inline draft row component', () => {
let vm;
let Component;
let draft;
beforeAll(() => {
Component = Vue.extend(InlineDraftCommentRow);
});
beforeEach(() => {
const store = createStore();
draft = createDraft();
vm = mountComponentWithStore(Component, { store, props: { draft } });
});
afterEach(() => {
vm.$destroy();
});
it('renders draft', () => {
expect(vm.$el.querySelector('.draft-note-component')).not.toBe(null);
});
});
import Vue from 'vue';
import ParallelDraftCommentRow from 'ee/batch_comments/components/parallel_draft_comment_row.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createStore } from '~/mr_notes/stores';
import '~/behaviors/markdown/render_gfm';
import { createDraft } from '../mock_data';
describe('Batch comments parallel draft row component', () => {
let vm;
let Component;
let draft;
beforeAll(() => {
Component = Vue.extend(ParallelDraftCommentRow);
});
beforeEach(() => {
draft = createDraft();
});
afterEach(() => {
vm.$destroy();
});
['left', 'right'].forEach(side => {
describe(`${side} side of diff`, () => {
beforeEach(() => {
const store = createStore();
vm = createComponentWithStore(Component, store, {
line: { code: '1' },
diffFileContentSha: 'test',
});
spyOnProperty(vm, 'draftForLine').and.returnValue((sha, line, draftSide) => {
if (draftSide === side) return draft;
return {};
});
vm.$mount();
});
it(`it renders draft on ${side} side`, () => {
const sideClass = side === 'left' ? '.old' : '.new';
const oppositeSideClass = side === 'left' ? '.new' : '.old';
expect(vm.$el.querySelector(`.parallel${sideClass} .draft-note-component`)).not.toBe(null);
expect(vm.$el.querySelector(`.parallel${oppositeSideClass} .draft-note-component`)).toBe(
null,
);
});
});
});
});
import Vue from 'vue';
import PublishButton from 'ee/batch_comments/components/publish_button.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createStore } from 'ee/batch_comments/stores';
describe('Batch comments drafts count component', () => {
let vm;
let Component;
beforeAll(() => {
Component = Vue.extend(PublishButton);
});
beforeEach(() => {
const store = createStore();
vm = mountComponentWithStore(Component, { store });
spyOn(vm.$store, 'dispatch').and.stub();
});
afterEach(() => {
vm.$destroy();
});
it('dispatches publishReview on click', () => {
vm.$el.click();
expect(vm.$store.dispatch).toHaveBeenCalledWith(
'batchComments/publishReview',
jasmine.anything(),
);
});
it('sets loading when isPublishing is true', done => {
vm.$store.state.batchComments.isPublishing = true;
vm.$nextTick(() => {
expect(vm.$el.getAttribute('disabled')).toBe('disabled');
done();
});
});
});
import Vue from 'vue';
import ReviewBar from 'ee/batch_comments/components/review_bar.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createStore } from 'ee/batch_comments/stores';
describe('Batch comments review bar component', () => {
let vm;
let Component;
beforeAll(() => {
Component = Vue.extend(ReviewBar);
});
beforeEach(() => {
const store = createStore();
vm = mountComponentWithStore(Component, { store });
spyOn(vm.$store, 'dispatch').and.stub();
});
afterEach(() => {
vm.$destroy();
});
it('hides when no drafts exist', () => {
expect(vm.$el.style.display).toBe('none');
});
describe('with batch comments', () => {
beforeEach(done => {
vm.$store.state.batchComments.drafts.push('comment');
vm.$nextTick(done);
});
it('shows bar', () => {
expect(vm.$el.style.display).not.toBe('none');
});
it('calls discardReview when clicking modal button', done => {
vm.$el.querySelector('.btn.btn-align-content').click();
vm.$nextTick(() => {
vm.$el.querySelector('.modal .btn-danger').click();
expect(vm.$store.dispatch).toHaveBeenCalled();
done();
});
});
it('sets discard button as loading when isDiscarding is true', done => {
vm.$store.state.batchComments.isDiscarding = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.btn-align-content').getAttribute('disabled')).toBe(
'disabled',
);
done();
});
});
});
});
// eslint-disable-next-line import/prefer-default-export
export const createDraft = () => ({
author: {
id: 1,
name: 'Test',
username: 'test',
state: 'active',
avatar_url: gl.TEST_HOST,
},
current_user: { can_edit: true, can_award_emoji: false, can_resolve: false },
discussion_id: null,
file_hash: null,
id: 1,
line_code: null,
merge_request_id: 1,
note: 'a',
note_html: '<p>Test</p>',
noteable_type: 'MergeRequest',
references: { users: [], commands: '' },
resolve_discussion: false,
isDraft: true,
});
import Vue from 'vue';
import VueResource from 'vue-resource';
import _ from 'underscore';
import testAction from 'spec/helpers/vuex_action_helper';
import * as actions from 'ee/batch_comments/stores/modules/batch_comments/actions';
Vue.use(VueResource);
describe('Batch comments store actions', () => {
let interceptor;
let res = {};
let status = 200;
beforeEach(() => {
interceptor = (request, next) => {
next(
request.respondWith(JSON.stringify(res), {
status,
}),
);
};
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
res = {};
status = 200;
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
describe('enableBatchComments', () => {
it('commits ENABLE_BATCH_COMMENTS', done => {
testAction(
actions.enableBatchComments,
null,
null,
[{ type: 'ENABLE_BATCH_COMMENTS' }],
[],
done,
);
});
});
describe('saveDraft', () => {
it('dispatches saveNote on root', () => {
const dispatch = jasmine.createSpy();
actions.saveDraft({ dispatch }, { id: 1 });
expect(dispatch).toHaveBeenCalledWith('saveNote', { id: 1, isDraft: true }, { root: true });
});
});
describe('addDraftToDiscussion', () => {
it('commits ADD_NEW_DRAFT if no errors returned', done => {
res = { id: 1 };
testAction(
actions.addDraftToDiscussion,
{ endpoint: gl.TEST_HOST, data: 'test' },
null,
[{ type: 'ADD_NEW_DRAFT', payload: res }],
[],
done,
);
});
it('does not commit ADD_NEW_DRAFT if errors returned', done => {
status = 500;
testAction(
actions.addDraftToDiscussion,
{ endpoint: gl.TEST_HOST, data: 'test' },
null,
[],
[],
done,
);
});
});
describe('createNewDraft', () => {
it('commits ADD_NEW_DRAFT if no errors returned', done => {
res = { id: 1 };
testAction(
actions.createNewDraft,
{ endpoint: gl.TEST_HOST, data: 'test' },
null,
[{ type: 'ADD_NEW_DRAFT', payload: res }],
[],
done,
);
});
it('does not commit ADD_NEW_DRAFT if errors returned', done => {
status = 500;
testAction(
actions.createNewDraft,
{ endpoint: gl.TEST_HOST, data: 'test' },
null,
[],
[],
done,
);
});
});
describe('deleteDraft', () => {
let getters;
beforeEach(() => {
getters = {
getNotesData: {
draftsDiscardPath: gl.TEST_HOST,
},
};
});
it('commits DELETE_DRAFT if no errors returned', done => {
const commit = jasmine.createSpy('commit');
const context = {
getters,
commit,
};
res = { id: 1 };
actions
.deleteDraft(context, { id: 1 })
.then(() => {
expect(commit).toHaveBeenCalledWith('DELETE_DRAFT', 1);
})
.then(done)
.catch(done.fail);
});
it('does not commit DELETE_DRAFT if errors returned', done => {
const commit = jasmine.createSpy('commit');
const context = {
getters,
commit,
};
res = '';
status = '500';
actions
.deleteDraft(context, { id: 1 })
.then(() => {
expect(commit).not.toHaveBeenCalledWith('DELETE_DRAFT', 1);
})
.then(done)
.catch(done.fail);
});
});
describe('fetchDrafts', () => {
let getters;
beforeEach(() => {
getters = {
getNotesData: {
draftsPath: gl.TEST_HOST,
},
};
});
it('commits SET_BATCH_COMMENTS_DRAFTS with returned data', done => {
const commit = jasmine.createSpy('commit');
const context = {
getters,
commit,
};
res = { id: 1 };
actions
.fetchDrafts(context)
.then(() => {
expect(commit).toHaveBeenCalledWith('SET_BATCH_COMMENTS_DRAFTS', { id: 1 });
})
.then(done)
.catch(done.fail);
});
});
describe('publishReview', () => {
let dispatch;
let commit;
let getters;
let rootGetters;
beforeEach(() => {
dispatch = jasmine.createSpy('dispatch');
commit = jasmine.createSpy('commit');
getters = {
getNotesData: { draftsPublishPath: gl.TEST_HOST, discussionsPath: gl.TEST_HOST },
};
rootGetters = { discussionsStructuredByLineCode: 'discussions' };
});
it('dispatches actions & commits', done => {
actions
.publishReview({ dispatch, commit, getters, rootGetters })
.then(() => {
expect(commit.calls.argsFor(0)).toEqual(['REQUEST_PUBLISH_REVIEW']);
expect(commit.calls.argsFor(1)).toEqual(['RECEIVE_PUBLISH_REVIEW_SUCCESS']);
expect(dispatch.calls.argsFor(0)).toEqual(['updateDiscussionsAfterPublish']);
})
.then(done)
.catch(done.fail);
});
it('dispatches error commits', done => {
status = 500;
actions
.publishReview({ dispatch, commit, getters, rootGetters })
.then(() => {
expect(commit.calls.argsFor(0)).toEqual(['REQUEST_PUBLISH_REVIEW']);
expect(commit.calls.argsFor(1)).toEqual(['RECEIVE_PUBLISH_REVIEW_ERROR']);
})
.then(done)
.catch(done.fail);
});
});
describe('discardReview', () => {
it('commits mutations', done => {
const getters = {
getNotesData: { draftsDiscardPath: gl.TEST_HOST },
};
const commit = jasmine.createSpy('commit');
actions
.discardReview({ getters, commit })
.then(() => {
expect(commit.calls.argsFor(0)).toEqual(['REQUEST_DISCARD_REVIEW']);
expect(commit.calls.argsFor(1)).toEqual(['RECEIVE_DISCARD_REVIEW_SUCCESS']);
})
.then(done)
.catch(done.fail);
});
it('commits error mutations', done => {
const getters = {
getNotesData: { draftsDiscardPath: gl.TEST_HOST },
};
const commit = jasmine.createSpy('commit');
status = 500;
actions
.discardReview({ getters, commit })
.then(() => {
expect(commit.calls.argsFor(0)).toEqual(['REQUEST_DISCARD_REVIEW']);
expect(commit.calls.argsFor(1)).toEqual(['RECEIVE_DISCARD_REVIEW_ERROR']);
})
.then(done)
.catch(done.fail);
});
});
describe('updateDraft', () => {
let getters;
beforeEach(() => {
getters = {
getNotesData: {
draftsPath: gl.TEST_HOST,
},
};
});
it('commits RECEIVE_DRAFT_UPDATE_SUCCESS with returned data', done => {
const commit = jasmine.createSpy('commit');
const context = {
getters,
commit,
};
res = { id: 1 };
actions
.updateDraft(context, { note: { id: 1 }, noteText: 'test', callback() {} })
.then(() => {
expect(commit).toHaveBeenCalledWith('RECEIVE_DRAFT_UPDATE_SUCCESS', { id: 1 });
})
.then(done)
.catch(done.fail);
});
it('calls passed callback', done => {
const commit = jasmine.createSpy('commit');
const context = {
getters,
commit,
};
const callback = jasmine.createSpy('callback');
res = { id: 1 };
actions
.updateDraft(context, { note: { id: 1 }, noteText: 'test', callback })
.then(() => {
expect(callback).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
});
});
import createState from 'ee/batch_comments/stores/modules/batch_comments/state';
import mutations from 'ee/batch_comments/stores/modules/batch_comments/mutations';
import * as types from 'ee/batch_comments/stores/modules/batch_comments/mutation_types';
describe('Batch comments mutations', () => {
let state;
beforeEach(() => {
state = createState();
});
describe(types.ENABLE_BATCH_COMMENTS, () => {
it('sets withBatchComments to true', () => {
mutations[types.ENABLE_BATCH_COMMENTS](state);
expect(state.withBatchComments).toBe(true);
});
});
describe(types.ADD_NEW_DRAFT, () => {
it('adds processed object into drafts array', () => {
const draft = { id: 1, note: 'test' };
mutations[types.ADD_NEW_DRAFT](state, draft);
expect(state.drafts).toEqual([
{
...draft,
isDraft: true,
},
]);
});
});
describe(types.DELETE_DRAFT, () => {
it('removes draft from array by ID', () => {
state.drafts.push({ id: 1 }, { id: 2 });
mutations[types.DELETE_DRAFT](state, 1);
expect(state.drafts).toEqual([{ id: 2 }]);
});
});
describe(types.SET_BATCH_COMMENTS_DRAFTS, () => {
it('adds to processed drafts in state', () => {
const drafts = [{ id: 1 }, { id: 2 }];
mutations[types.SET_BATCH_COMMENTS_DRAFTS](state, drafts);
expect(state.drafts).toEqual([
{
id: 1,
isDraft: true,
},
{
id: 2,
isDraft: true,
},
]);
});
});
describe(types.REQUEST_PUBLISH_REVIEW, () => {
it('sets isPublishing to true', () => {
mutations[types.REQUEST_PUBLISH_REVIEW](state);
expect(state.isPublishing).toBe(true);
});
});
describe(types.RECEIVE_PUBLISH_REVIEW_SUCCESS, () => {
it('resets drafts', () => {
state.drafts.push('test');
mutations[types.RECEIVE_PUBLISH_REVIEW_SUCCESS](state);
expect(state.drafts).toEqual([]);
});
it('sets isPublishing to false', () => {
state.isPublishing = true;
mutations[types.RECEIVE_PUBLISH_REVIEW_SUCCESS](state);
expect(state.isPublishing).toBe(false);
});
});
describe(types.RECEIVE_PUBLISH_REVIEW_ERROR, () => {
it('updates isPublishing to false', () => {
state.isPublishing = true;
mutations[types.RECEIVE_PUBLISH_REVIEW_ERROR](state);
expect(state.isPublishing).toBe(false);
});
});
describe(types.REQUEST_DISCARD_REVIEW, () => {
it('sets isDiscarding to true', () => {
mutations[types.REQUEST_DISCARD_REVIEW](state);
expect(state.isDiscarding).toBe(true);
});
});
describe(types.RECEIVE_DISCARD_REVIEW_SUCCESS, () => {
it('emptys drafts array', () => {
state.drafts.push('test');
mutations[types.RECEIVE_DISCARD_REVIEW_SUCCESS](state);
expect(state.drafts).toEqual([]);
});
it('sets isDiscarding to false', () => {
state.isDiscarding = true;
mutations[types.RECEIVE_DISCARD_REVIEW_SUCCESS](state);
expect(state.isDiscarding).toBe(false);
});
});
describe(types.RECEIVE_DISCARD_REVIEW_ERROR, () => {
it('updates isDiscarding to false', () => {
state.isDiscarding = true;
mutations[types.RECEIVE_DISCARD_REVIEW_ERROR](state);
expect(state.isDiscarding).toBe(false);
});
});
describe(types.RECEIVE_DRAFT_UPDATE_SUCCESS, () => {
it('updates draft in store', () => {
state.drafts.push({ id: 1 });
mutations[types.RECEIVE_DRAFT_UPDATE_SUCCESS](state, { id: 1, note: 'test' });
expect(state.drafts).toEqual([
{
id: 1,
note: 'test',
isDraft: true,
},
]);
});
});
});
......@@ -407,6 +407,9 @@ msgstr ""
msgid "Add additional text to appear in all email communications. %{character_limit} character limit"
msgstr ""
msgid "Add comment now"
msgstr ""
msgid "Add license"
msgstr ""
......@@ -419,6 +422,9 @@ msgstr ""
msgid "Add reaction"
msgstr ""
msgid "Add to review"
msgstr ""
msgid "Add todo"
msgstr ""
......@@ -593,6 +599,12 @@ msgstr ""
msgid "An error occured whilst loading the pipelines jobs."
msgstr ""
msgid "An error occurred adding a draft to the discussion."
msgstr ""
msgid "An error occurred adding a new draft."
msgstr ""
msgid "An error occurred previewing the blob"
msgstr ""
......@@ -605,6 +617,9 @@ msgstr ""
msgid "An error occurred while adding approver"
msgstr ""
msgid "An error occurred while deleting the comment"
msgstr ""
msgid "An error occurred while detecting host keys"
msgstr ""
......@@ -617,6 +632,9 @@ msgstr ""
msgid "An error occurred while fetching markdown preview"
msgstr ""
msgid "An error occurred while fetching pending comments"
msgstr ""
msgid "An error occurred while fetching sidebar data"
msgstr ""
......@@ -686,6 +704,9 @@ msgstr ""
msgid "An error occurred while unsubscribing to notifications."
msgstr ""
msgid "An error occurred while updating the comment"
msgstr ""
msgid "An error occurred while validating username"
msgstr ""
......@@ -1007,6 +1028,15 @@ msgstr ""
msgid "Badges|e.g. %{exampleUrl}"
msgstr ""
msgid "BatchComments|Delete all pending comments"
msgstr ""
msgid "BatchComments|Discard review?"
msgstr ""
msgid "BatchComments|You're about to discard your review which will delete all of your pending comments. The deleted comments %{strong_start}cannot%{strong_end} be restored."
msgstr ""
msgid "Begin with the selected commit"
msgstr ""
......@@ -2734,6 +2764,9 @@ msgstr ""
msgid "Discard draft"
msgstr ""
msgid "Discard review"
msgstr ""
msgid "Discover GitLab Geo."
msgstr ""
......@@ -4836,6 +4869,21 @@ msgstr ""
msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
msgstr ""
msgid "MergeRequests|An error occurred while saving the draft comment."
msgstr ""
msgid "MergeRequests|Discussion stays resolved."
msgstr ""
msgid "MergeRequests|Discussion stays unresolved."
msgstr ""
msgid "MergeRequests|Discussion will be resolved."
msgstr ""
msgid "MergeRequests|Discussion will be unresolved."
msgstr ""
msgid "MergeRequests|Resolve this discussion in a new issue"
msgstr ""
......@@ -7333,6 +7381,9 @@ msgstr ""
msgid "Start a %{new_merge_request} with these changes"
msgstr ""
msgid "Start a review"
msgstr ""
msgid "Start date"
msgstr ""
......@@ -7375,6 +7426,9 @@ msgstr ""
msgid "Submit as spam"
msgstr ""
msgid "Submit review"
msgstr ""
msgid "Submit search"
msgstr ""
......@@ -9131,6 +9185,11 @@ msgstr ""
msgid "done"
msgstr ""
msgid "draft"
msgid_plural "drafts"
msgstr[0] ""
msgstr[1] ""
msgid "enabled"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment