From 4217b8e5af1e5ccd87e75e10b4f39903f9425113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Lu=C3=ADs?= Date: Fri, 20 Jul 2018 21:31:30 +0100 Subject: [PATCH 1/3] Adds Batch Comments to Merge Requests [EEP] This includes all the Frontend work to add Batch Comments / Reviews in Merge Requests. This feature is limited to EEP licenses and currently behind a feature flag. See issue ee#7892 for more info. --- .../javascripts/diffs/components/app.vue | 1 - .../diffs/components/diff_line_note_form.vue | 4 +- .../diffs/components/inline_diff_view.vue | 8 + .../diffs/components/parallel_diff_view.vue | 9 + app/assets/javascripts/diffs/store/utils.js | 12 +- .../javascripts/mr_notes/stores/index.js | 19 +- .../notes/components/note_actions.vue | 33 +- .../notes/components/note_body.vue | 2 +- .../notes/components/note_form.vue | 110 ++++-- .../notes/components/note_header.vue | 36 +- .../notes/components/noteable_discussion.vue | 19 +- .../notes/components/noteable_note.vue | 31 +- .../javascripts/notes/stores/actions.js | 19 +- .../javascripts/notes/stores/getters.js | 3 + .../framework/contextual_sidebar.scss | 8 +- app/assets/stylesheets/pages/notes.scss | 6 +- app/helpers/notes_helper.rb | 2 +- .../projects/issues/_discussion.html.haml | 2 +- .../projects/merge_requests/show.html.haml | 2 +- .../batch_comments/components/draft_note.vue | 131 ++++++++ .../components/drafts_count.vue | 17 + .../components/inline_draft_comment_row.vue | 32 ++ .../components/parallel_draft_comment_row.vue | 62 ++++ .../components/publish_button.vue | 31 ++ .../batch_comments/components/review_bar.vue | 57 ++++ .../javascripts/batch_comments/index.js | 36 ++ .../mixins/diff_line_note_form.js | 80 +++++ .../batch_comments/mixins/note_form.js | 45 +++ .../batch_comments/services/drafts_service.js | 34 ++ .../batch_comments/stores/index.js | 14 + .../stores/modules/batch_comments/actions.js | 91 +++++ .../stores/modules/batch_comments/getters.js | 68 ++++ .../stores/modules/batch_comments/index.js | 12 + .../modules/batch_comments/mutation_types.js | 18 + .../modules/batch_comments/mutations.js | 67 ++++ .../stores/modules/batch_comments/state.js | 8 + .../javascripts/batch_comments/utils.js | 35 ++ .../projects/merge_requests/show/index.js | 2 + .../components/batch_comments/draft_note.scss | 118 +++++++ .../batch_comments/drafts_count.scss | 13 + .../components/batch_comments/review_bar.scss | 47 +++ ee/app/helpers/batch_comments_helper.rb | 5 + ee/app/helpers/ee/notes_helper.rb | 15 + .../views/groups/epics/_discussion.html.haml | 2 +- .../projects/merge_requests/show.html.haml | 3 + .../1984-frontend-for-batch-comments.yml | 5 + .../merge_request/batch_comments_spec.rb | 163 +++++++++ .../components/draft_note_spec.js | 155 +++++++++ .../components/drafts_count_spec.js | 43 +++ .../inline_draft_comment_row_spec.js | 32 ++ .../parallel_draft_comment_row_spec.js | 55 +++ .../components/publish_button_spec.js | 44 +++ .../components/review_bar_spec.js | 64 ++++ .../javascripts/batch_comments/mock_data.js | 22 ++ .../modules/batch_comments/actions_spec.js | 312 ++++++++++++++++++ .../modules/batch_comments/mutations_spec.js | 151 +++++++++ locale/gitlab.pot | 59 ++++ 57 files changed, 2400 insertions(+), 74 deletions(-) create mode 100644 ee/app/assets/javascripts/batch_comments/components/draft_note.vue create mode 100644 ee/app/assets/javascripts/batch_comments/components/drafts_count.vue create mode 100644 ee/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue create mode 100644 ee/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue create mode 100644 ee/app/assets/javascripts/batch_comments/components/publish_button.vue create mode 100644 ee/app/assets/javascripts/batch_comments/components/review_bar.vue create mode 100644 ee/app/assets/javascripts/batch_comments/index.js create mode 100644 ee/app/assets/javascripts/batch_comments/mixins/diff_line_note_form.js create mode 100644 ee/app/assets/javascripts/batch_comments/mixins/note_form.js create mode 100644 ee/app/assets/javascripts/batch_comments/services/drafts_service.js create mode 100644 ee/app/assets/javascripts/batch_comments/stores/index.js create mode 100644 ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js create mode 100644 ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js create mode 100644 ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js create mode 100644 ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js create mode 100644 ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js create mode 100644 ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js create mode 100644 ee/app/assets/javascripts/batch_comments/utils.js create mode 100644 ee/app/assets/stylesheets/components/batch_comments/draft_note.scss create mode 100644 ee/app/assets/stylesheets/components/batch_comments/drafts_count.scss create mode 100644 ee/app/assets/stylesheets/components/batch_comments/review_bar.scss create mode 100644 ee/app/helpers/batch_comments_helper.rb create mode 100644 ee/changelogs/unreleased/1984-frontend-for-batch-comments.yml create mode 100644 ee/spec/features/merge_request/batch_comments_spec.rb create mode 100644 ee/spec/javascripts/batch_comments/components/draft_note_spec.js create mode 100644 ee/spec/javascripts/batch_comments/components/drafts_count_spec.js create mode 100644 ee/spec/javascripts/batch_comments/components/inline_draft_comment_row_spec.js create mode 100644 ee/spec/javascripts/batch_comments/components/parallel_draft_comment_row_spec.js create mode 100644 ee/spec/javascripts/batch_comments/components/publish_button_spec.js create mode 100644 ee/spec/javascripts/batch_comments/components/review_bar_spec.js create mode 100644 ee/spec/javascripts/batch_comments/mock_data.js create mode 100644 ee/spec/javascripts/batch_comments/stores/modules/batch_comments/actions_spec.js create mode 100644 ee/spec/javascripts/batch_comments/stores/modules/batch_comments/mutations_spec.js diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index e60c53338fefca..edca45f22f9358 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 bb9bb821de3fb2..6e36e402e608b5 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 00000000000000..1edb71e63ddae4 --- /dev/null +++ b/ee/app/assets/javascripts/batch_comments/components/drafts_count.vue @@ -0,0 +1,17 @@ + + 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 00000000000000..cb5e9c35f01be0 --- /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 00000000000000..6359eef24980fc --- /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 00000000000000..2972d9cb073cab --- /dev/null +++ b/ee/app/assets/javascripts/batch_comments/components/publish_button.vue @@ -0,0 +1,31 @@ + + + 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 00000000000000..f853bdfd3099a2 --- /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 00000000000000..b5b6efa2a19e72 --- /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 00000000000000..ef438a8e68c35d --- /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 00000000000000..8a9b83e6c6a716 --- /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 00000000000000..00f3f24f6243e3 --- /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 00000000000000..08dc9ea70f870a --- /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 00000000000000..da74790316e6e6 --- /dev/null +++ b/ee/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -0,0 +1,91 @@ +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)) + .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)) + .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 00000000000000..91cd46450ea6c3 --- /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 00000000000000..81dab0566c1a8a --- /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 00000000000000..af1c94814d00b8 --- /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 00000000000000..49ed0c6d34aac8 --- /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 00000000000000..3b20aabfe520d8 --- /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 00000000000000..3f1ac37fb73ca9 --- /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 07f0b75d7017df..4494b107f49086 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 00000000000000..869e3ed462a2ee --- /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 00000000000000..bb2900110d1991 --- /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 00000000000000..690e33b42daaba --- /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 00000000000000..5b53f130aee40c --- /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 82170f4984b425..b73671f78e0420 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 598479cb846161..d78c4e84e6aab8 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 db4c726739eed2..fbc4aaa8ddb078 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 00000000000000..5f729bdf9ec684 --- /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 00000000000000..935a7f8ebc457b --- /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 00000000000000..713cc872ad0996 --- /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 00000000000000..7fa2a9be476921 --- /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.textContent).toContain('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 00000000000000..886affaf806527 --- /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 00000000000000..a3044b5e7fc701 --- /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 00000000000000..eb34e32cc48a58 --- /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 00000000000000..ff220d3ce9f18e --- /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 00000000000000..0b9bf777ee46bc --- /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 00000000000000..50a5c807e9b4c6 --- /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 00000000000000..235b26c06fb0aa --- /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 a25d0669ec9261..86f870c2447aac 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 "" -- GitLab From 2908803e3f9cc082cec115a976ac1410323aaafc Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 5 Oct 2018 21:44:02 +0100 Subject: [PATCH 2/3] Fixed form not hiding --- .../stores/modules/batch_comments/actions.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 index da74790316e6e6..a9100047a45328 100644 --- 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 @@ -14,7 +14,10 @@ export const addDraftToDiscussion = ({ commit }, { endpoint, data }) => service .addDraftToDiscussion(endpoint, data) .then(res => res.json()) - .then(res => commit(types.ADD_NEW_DRAFT, res)) + .then(res => { + commit(types.ADD_NEW_DRAFT, res); + return res; + }) .catch(() => { flash(__('An error occurred adding a draft to the discussion.')); }); @@ -23,7 +26,10 @@ export const createNewDraft = ({ commit }, { endpoint, data }) => service .createNewDraft(endpoint, data) .then(res => res.json()) - .then(res => commit(types.ADD_NEW_DRAFT, res)) + .then(res => { + commit(types.ADD_NEW_DRAFT, res); + return res; + }) .catch(() => { flash(__('An error occurred adding a new draft.')); }); -- GitLab From 3bad2f19baf9a4e5c471085dacf731a1553e5409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Lu=C3=ADs?= Date: Sat, 6 Oct 2018 12:56:27 +0100 Subject: [PATCH 3/3] Address final FE review comments This commit is ok to be squash, kept for reviewer's convenience. --- app/assets/javascripts/notes/components/note_form.vue | 3 +-- .../javascripts/batch_comments/components/drafts_count.vue | 2 +- .../javascripts/batch_comments/components/drafts_count_spec.js | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 24742698b86e1e..cb4b4f03d667fa 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -217,7 +217,6 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" {{ __('Resolve discussion') }} -

diff --git a/ee/app/assets/javascripts/batch_comments/components/drafts_count.vue b/ee/app/assets/javascripts/batch_comments/components/drafts_count.vue index 1edb71e63ddae4..76e7aeabb7ae8a 100644 --- a/ee/app/assets/javascripts/batch_comments/components/drafts_count.vue +++ b/ee/app/assets/javascripts/batch_comments/components/drafts_count.vue @@ -9,7 +9,7 @@ export default {