diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index e60c53338fefcab2ba7ad1dcbd1d73d68c12ee19..edca45f22f9358df72f92264f79ef00adf653129 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -127,7 +127,6 @@ export default {
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
]),
-
fetchData() {
this.fetchDiffFiles()
.then(() => {
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index bb9bb821de3fb2005c2b1e4601164989c8c1a7c7..6e36e402e608b572db4ed7ab1590c1186e3a6cf7 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -1,6 +1,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ee/app/assets/javascripts/batch_comments/components/drafts_count.vue b/ee/app/assets/javascripts/batch_comments/components/drafts_count.vue
new file mode 100644
index 0000000000000000000000000000000000000000..76e7aeabb7ae8a462f65c1b253fbc1cf61e784d9
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/components/drafts_count.vue
@@ -0,0 +1,17 @@
+
+
+
+ {{ draftsCount }}
+
+ {{ n__("draft", "drafts", draftsCount) }}
+
+
+
diff --git a/ee/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue b/ee/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue
new file mode 100644
index 0000000000000000000000000000000000000000..cb5e9c35f01be0f3d44693e3670dab4fb0a7ab95
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue
@@ -0,0 +1,32 @@
+
+
+
+
+ |
+
+
+
+ |
+
+
diff --git a/ee/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue b/ee/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6359eef24980fcb684cc7c3c6279fafc8506a27e
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue
@@ -0,0 +1,62 @@
+
+
+
+
+ |
+
+
+
+
+ |
+ |
+
+
+
+
+ |
+
+
diff --git a/ee/app/assets/javascripts/batch_comments/components/publish_button.vue b/ee/app/assets/javascripts/batch_comments/components/publish_button.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2972d9cb073cab507d68653ee29c286cb0427dea
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/components/publish_button.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+ {{ __('Submit review') }}
+
+
+
+
diff --git a/ee/app/assets/javascripts/batch_comments/components/review_bar.vue b/ee/app/assets/javascripts/batch_comments/components/review_bar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f853bdfd3099a2415b82259ec9a6cb8616160674
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -0,0 +1,57 @@
+
+
+
+
diff --git a/ee/app/assets/javascripts/batch_comments/index.js b/ee/app/assets/javascripts/batch_comments/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..b5b6efa2a19e724e54bb574229c624202e889597
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/index.js
@@ -0,0 +1,36 @@
+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;
+ },
+ });
+ }
+};
diff --git a/ee/app/assets/javascripts/batch_comments/mixins/diff_line_note_form.js b/ee/app/assets/javascripts/batch_comments/mixins/diff_line_note_form.js
new file mode 100644
index 0000000000000000000000000000000000000000..ef438a8e68c35d3c84c05f956a52018d44923d41
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/mixins/diff_line_note_form.js
@@ -0,0 +1,80 @@
+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;
+ },
+ },
+};
diff --git a/ee/app/assets/javascripts/batch_comments/mixins/note_form.js b/ee/app/assets/javascripts/batch_comments/mixins/note_form.js
new file mode 100644
index 0000000000000000000000000000000000000000..8a9b83e6c6a716948222e119829fb06f4c6f7e37
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/mixins/note_form.js
@@ -0,0 +1,45 @@
+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);
+ },
+ },
+};
diff --git a/ee/app/assets/javascripts/batch_comments/services/drafts_service.js b/ee/app/assets/javascripts/batch_comments/services/drafts_service.js
new file mode 100644
index 0000000000000000000000000000000000000000..00f3f24f6243e3ecadf62007a8084be86bb4f83c
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/services/drafts_service.js
@@ -0,0 +1,34 @@
+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 });
+ },
+};
diff --git a/ee/app/assets/javascripts/batch_comments/stores/index.js b/ee/app/assets/javascripts/batch_comments/stores/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..08dc9ea70f870a614dd9a52777bf361ecb599ef0
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/stores/index.js
@@ -0,0 +1,14 @@
+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();
diff --git a/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
new file mode 100644
index 0000000000000000000000000000000000000000..a9100047a45328a50d7388051072d167e302a4a4
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -0,0 +1,97 @@
+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 () => {};
diff --git a/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js
new file mode 100644
index 0000000000000000000000000000000000000000..91cd46450ea6c3a75f58c4b58a5d1aa1adb9dbe2
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js
@@ -0,0 +1,68 @@
+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 () => {};
diff --git a/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..81dab0566c1a8a9204d7250388946cf17a84b03a
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js
@@ -0,0 +1,12 @@
+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,
+});
diff --git a/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
new file mode 100644
index 0000000000000000000000000000000000000000..af1c94814d00b8ccf695b29c04d65d48d98b8fbe
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
@@ -0,0 +1,18 @@
+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';
diff --git a/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
new file mode 100644
index 0000000000000000000000000000000000000000..49ed0c6d34aac87080a5d2798053b20b7c39879f
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
@@ -0,0 +1,67 @@
+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));
+ }
+ },
+};
diff --git a/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
new file mode 100644
index 0000000000000000000000000000000000000000..3b20aabfe520d8887f97f27d399183283ec4f031
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
@@ -0,0 +1,8 @@
+export default () => ({
+ withBatchComments: false,
+ isDraftsFetched: false,
+ drafts: [],
+ isPublishing: false,
+ currentlyPublishingDrafts: [],
+ isDiscarding: false,
+});
diff --git a/ee/app/assets/javascripts/batch_comments/utils.js b/ee/app/assets/javascripts/batch_comments/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..3f1ac37fb73ca924420a25593ecdadca60f9be12
--- /dev/null
+++ b/ee/app/assets/javascripts/batch_comments/utils.js
@@ -0,0 +1,35 @@
+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;
+};
diff --git a/ee/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/ee/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index 07f0b75d7017dfa34a66455b015252910a6dbaa8..4494b107f49086c47b874976ba1474cb19c5dbf7 100644
--- a/ee/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/ee/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -1,9 +1,11 @@
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();
});
diff --git a/ee/app/assets/stylesheets/components/batch_comments/draft_note.scss b/ee/app/assets/stylesheets/components/batch_comments/draft_note.scss
new file mode 100644
index 0000000000000000000000000000000000000000..869e3ed462a2ee99b093aff63d647fca0a5d665e
--- /dev/null
+++ b/ee/app/assets/stylesheets/components/batch_comments/draft_note.scss
@@ -0,0 +1,118 @@
+.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;
+ }
+ }
+ }
+ }
+}
diff --git a/ee/app/assets/stylesheets/components/batch_comments/drafts_count.scss b/ee/app/assets/stylesheets/components/batch_comments/drafts_count.scss
new file mode 100644
index 0000000000000000000000000000000000000000..bb2900110d1991ed1ff598b3fdb180884a9572ff
--- /dev/null
+++ b/ee/app/assets/stylesheets/components/batch_comments/drafts_count.scss
@@ -0,0 +1,13 @@
+$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;
+}
diff --git a/ee/app/assets/stylesheets/components/batch_comments/review_bar.scss b/ee/app/assets/stylesheets/components/batch_comments/review_bar.scss
new file mode 100644
index 0000000000000000000000000000000000000000..690e33b42daaba1d22b3ce139a77e0de30630be2
--- /dev/null
+++ b/ee/app/assets/stylesheets/components/batch_comments/review_bar.scss
@@ -0,0 +1,47 @@
+.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;
+}
diff --git a/ee/app/helpers/batch_comments_helper.rb b/ee/app/helpers/batch_comments_helper.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5b53f130aee40c67bd774722dcdc3e4c592ee84e
--- /dev/null
+++ b/ee/app/helpers/batch_comments_helper.rb
@@ -0,0 +1,5 @@
+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
diff --git a/ee/app/helpers/ee/notes_helper.rb b/ee/app/helpers/ee/notes_helper.rb
index 82170f4984b4253466e6c8cb986844465dbcee28..b73671f78e04208c594eae727d60b489e7d666de 100644
--- a/ee/app/helpers/ee/notes_helper.rb
+++ b/ee/app/helpers/ee/notes_helper.rb
@@ -15,5 +15,20 @@ def discussions_path(issuable)
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
diff --git a/ee/app/views/groups/epics/_discussion.html.haml b/ee/app/views/groups/epics/_discussion.html.haml
index 598479cb846161f4c9b489747f488fb38c50ed44..d78c4e84e6aab8dc4adf1eef26df4cffecb5d197 100644
--- a/ee/app/views/groups/epics/_discussion.html.haml
+++ b/ee/app/views/groups/epics/_discussion.html.haml
@@ -1,7 +1,7 @@
- @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 } }
diff --git a/ee/app/views/projects/merge_requests/show.html.haml b/ee/app/views/projects/merge_requests/show.html.haml
index db4c726739eed22607149879bf4caadec808c43b..fbc4aaa8ddb0781e118147e07d41867f3a874758 100644
--- a/ee/app/views/projects/merge_requests/show.html.haml
+++ b/ee/app/views/projects/merge_requests/show.html.haml
@@ -1,5 +1,8 @@
= 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)
diff --git a/ee/changelogs/unreleased/1984-frontend-for-batch-comments.yml b/ee/changelogs/unreleased/1984-frontend-for-batch-comments.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5f729bdf9ec68473bcd2dd27366becc0d96b67eb
--- /dev/null
+++ b/ee/changelogs/unreleased/1984-frontend-for-batch-comments.yml
@@ -0,0 +1,5 @@
+---
+title: Adds Batch Comments to Merge Requests [EEP]
+merge_request:
+author:
+type: added
diff --git a/ee/spec/features/merge_request/batch_comments_spec.rb b/ee/spec/features/merge_request/batch_comments_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..935a7f8ebc457bf2b3e72155733ffce3fca45e42
--- /dev/null
+++ b/ee/spec/features/merge_request/batch_comments_spec.rb
@@ -0,0 +1,163 @@
+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
diff --git a/ee/spec/javascripts/batch_comments/components/draft_note_spec.js b/ee/spec/javascripts/batch_comments/components/draft_note_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..713cc872ad09969bb35847b54040db9e6707677c
--- /dev/null
+++ b/ee/spec/javascripts/batch_comments/components/draft_note_spec.js
@@ -0,0 +1,155 @@
+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();
+ });
+ });
+ });
+});
diff --git a/ee/spec/javascripts/batch_comments/components/drafts_count_spec.js b/ee/spec/javascripts/batch_comments/components/drafts_count_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..c0cde3656a7846bbe9ceb3b377505732d2114d2a
--- /dev/null
+++ b/ee/spec/javascripts/batch_comments/components/drafts_count_spec.js
@@ -0,0 +1,43 @@
+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();
+ });
+ });
+});
diff --git a/ee/spec/javascripts/batch_comments/components/inline_draft_comment_row_spec.js b/ee/spec/javascripts/batch_comments/components/inline_draft_comment_row_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..886affaf806527ac8152d2f445a54bed0484aae6
--- /dev/null
+++ b/ee/spec/javascripts/batch_comments/components/inline_draft_comment_row_spec.js
@@ -0,0 +1,32 @@
+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);
+ });
+});
diff --git a/ee/spec/javascripts/batch_comments/components/parallel_draft_comment_row_spec.js b/ee/spec/javascripts/batch_comments/components/parallel_draft_comment_row_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..a3044b5e7fc7017b6b2b9c76ada099d45ec58ab9
--- /dev/null
+++ b/ee/spec/javascripts/batch_comments/components/parallel_draft_comment_row_spec.js
@@ -0,0 +1,55 @@
+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,
+ );
+ });
+ });
+ });
+});
diff --git a/ee/spec/javascripts/batch_comments/components/publish_button_spec.js b/ee/spec/javascripts/batch_comments/components/publish_button_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..eb34e32cc48a588fa5ecb43cf5afbf24839ce8f5
--- /dev/null
+++ b/ee/spec/javascripts/batch_comments/components/publish_button_spec.js
@@ -0,0 +1,44 @@
+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();
+ });
+ });
+});
diff --git a/ee/spec/javascripts/batch_comments/components/review_bar_spec.js b/ee/spec/javascripts/batch_comments/components/review_bar_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..ff220d3ce9f18e3fcbb4bdebfdaa9084148762cd
--- /dev/null
+++ b/ee/spec/javascripts/batch_comments/components/review_bar_spec.js
@@ -0,0 +1,64 @@
+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();
+ });
+ });
+ });
+});
diff --git a/ee/spec/javascripts/batch_comments/mock_data.js b/ee/spec/javascripts/batch_comments/mock_data.js
new file mode 100644
index 0000000000000000000000000000000000000000..0b9bf777ee46bc7aeedd1b5a525739ea6f7dc1e4
--- /dev/null
+++ b/ee/spec/javascripts/batch_comments/mock_data.js
@@ -0,0 +1,22 @@
+// 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: '
Test
',
+ noteable_type: 'MergeRequest',
+ references: { users: [], commands: '' },
+ resolve_discussion: false,
+ isDraft: true,
+});
diff --git a/ee/spec/javascripts/batch_comments/stores/modules/batch_comments/actions_spec.js b/ee/spec/javascripts/batch_comments/stores/modules/batch_comments/actions_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..50a5c807e9b4c671c4aad7304e4d668da16bd0d8
--- /dev/null
+++ b/ee/spec/javascripts/batch_comments/stores/modules/batch_comments/actions_spec.js
@@ -0,0 +1,312 @@
+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);
+ });
+ });
+});
diff --git a/ee/spec/javascripts/batch_comments/stores/modules/batch_comments/mutations_spec.js b/ee/spec/javascripts/batch_comments/stores/modules/batch_comments/mutations_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..235b26c06fb0aa97c62282eacdb4224d3ef91816
--- /dev/null
+++ b/ee/spec/javascripts/batch_comments/stores/modules/batch_comments/mutations_spec.js
@@ -0,0 +1,151 @@
+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,
+ },
+ ]);
+ });
+ });
+});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index a25d0669ec92618193152547ab5e0c480556879b..86f870c2447aacdc6e5ebbf271ac9039db352237 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""