diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-18 11:18:50 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-18 11:18:50 +0000 |
commit | 8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781 (patch) | |
tree | a77e7fe7a93de11213032ed4ab1f33a3db51b738 /app/assets/javascripts/batch_comments | |
parent | 00b35af3db1abfe813a778f643dad221aad51fca (diff) | |
download | gitlab-ce-8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781.tar.gz |
Add latest changes from gitlab-org/gitlab@13-1-stable-ee
Diffstat (limited to 'app/assets/javascripts/batch_comments')
21 files changed, 1149 insertions, 2 deletions
diff --git a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue new file mode 100644 index 00000000000..570954c7200 --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue @@ -0,0 +1,41 @@ +<script> +import { mapGetters } from 'vuex'; +import imageDiff from '~/diffs/mixins/image_diff'; +import DraftNote from './draft_note.vue'; + +export default { + components: { + DraftNote, + }, + mixins: [imageDiff], + props: { + fileHash: { + type: String, + required: true, + }, + }, + computed: { + ...mapGetters('batchComments', ['draftsForFile']), + drafts() { + return this.draftsForFile(this.fileHash); + }, + }, +}; +</script> + +<template> + <div> + <div + v-for="(draft, index) in drafts" + :key="draft.id" + class="discussion-notes diff-discussions position-relative" + > + <div class="notes"> + <span class="d-block btn-transparent badge badge-pill is-draft js-diff-notes-index"> + {{ toggleText(draft, index) }} + </span> + <draft-note :draft="draft" /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue new file mode 100644 index 00000000000..963d104b6b3 --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -0,0 +1,113 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import NoteableNote from '~/notes/components/noteable_note.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import PublishButton from './publish_button.vue'; + +export default { + components: { + NoteableNote, + PublishButton, + LoadingButton, + }, + props: { + draft: { + type: Object, + required: true, + }, + diffFile: { + type: Object, + required: false, + default: () => ({}), + }, + line: { + type: Object, + required: false, + default: null, + }, + }, + data() { + return { + isEditingDraft: false, + }; + }, + computed: { + ...mapState('batchComments', ['isPublishing']), + ...mapGetters('batchComments', ['isPublishingDraft']), + draftCommands() { + return this.draft.references.commands; + }, + }, + mounted() { + if (window.location.hash && window.location.hash === `#note_${this.draft.id}`) { + this.scrollToDraft(this.draft); + } + }, + methods: { + ...mapActions('batchComments', [ + 'deleteDraft', + 'updateDraft', + 'publishSingleDraft', + 'scrollToDraft', + 'toggleResolveDiscussion', + ]), + update(data) { + this.updateDraft(data); + }, + publishNow() { + this.publishSingleDraft(this.draft.id); + }, + handleEditing() { + this.isEditingDraft = true; + }, + handleNotEditing() { + this.isEditingDraft = false; + }, + }, +}; +</script> +<template> + <article class="draft-note-component note-wrapper"> + <ul class="notes draft-notes"> + <noteable-note + :note="draft" + :diff-lines="diffFile.highlighted_diff_lines" + :line="line" + class="draft-note" + @handleEdit="handleEditing" + @cancelForm="handleNotEditing" + @updateSuccess="handleNotEditing" + @handleDeleteNote="deleteDraft" + @handleUpdateNote="update" + @toggleResolveStatus="toggleResolveDiscussion(draft.id)" + > + <strong slot="note-header-info" class="badge draft-pending-label append-right-4"> + {{ __('Pending') }} + </strong> + </noteable-note> + </ul> + + <template v-if="!isEditingDraft"> + <div + v-if="draftCommands" + class="referenced-commands draft-note-commands" + v-html="draftCommands" + ></div> + + <p class="draft-note-actions d-flex"> + <publish-button + :show-count="true" + :should-publish="false" + class="btn btn-success btn-inverted gl-mr-3" + /> + <loading-button + ref="publishNowButton" + :loading="isPublishingDraft(draft.id) || isPublishing" + :label="__('Add comment now')" + container-class="btn btn-inverted" + @click="publishNow" + /> + </p> + </template> + </article> +</template> diff --git a/app/assets/javascripts/batch_comments/components/drafts_count.vue b/app/assets/javascripts/batch_comments/components/drafts_count.vue new file mode 100644 index 00000000000..f1180760c4d --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/drafts_count.vue @@ -0,0 +1,15 @@ +<script> +import { mapGetters } from 'vuex'; + +export default { + computed: { + ...mapGetters('batchComments', ['draftsCount']), + }, +}; +</script> +<template> + <span class="drafts-count-component"> + <span class="drafts-count-number">{{ draftsCount }}</span> + <span class="sr-only"> {{ n__('draft', 'drafts', draftsCount) }} </span> + </span> +</template> diff --git a/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue new file mode 100644 index 00000000000..385725cd109 --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue @@ -0,0 +1,32 @@ +<script> +import DraftNote from './draft_note.vue'; + +export default { + components: { + DraftNote, + }, + props: { + draft: { + type: Object, + required: true, + }, + diffFile: { + type: Object, + required: true, + }, + line: { + type: Object, + required: false, + default: null, + }, + }, +}; +</script> + +<template> + <tr class="notes_holder js-temp-notes-holder"> + <td class="notes-content" colspan="4"> + <div class="content"><draft-note :draft="draft" :diff-file="diffFile" :line="line" /></div> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue new file mode 100644 index 00000000000..68fd20e56bc --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue @@ -0,0 +1,45 @@ +<script> +import { mapGetters } from 'vuex'; +import DraftNote from './draft_note.vue'; + +export default { + components: { + DraftNote, + }, + props: { + line: { + type: Object, + required: true, + }, + diffFileContentSha: { + type: String, + required: true, + }, + }, + computed: { + ...mapGetters('batchComments', ['draftForLine']), + className() { + return this.leftDraft > 0 || this.rightDraft > 0 ? '' : 'js-temp-notes-holder'; + }, + leftDraft() { + return this.draftForLine(this.diffFileContentSha, this.line, 'left'); + }, + rightDraft() { + return this.draftForLine(this.diffFileContentSha, this.line, 'right'); + }, + }, +}; +</script> + +<template> + <tr :class="className" class="notes_holder"> + <td class="notes_line old"></td> + <td class="notes-content parallel old" colspan="2"> + <div v-if="leftDraft.isDraft" class="content"><draft-note :draft="leftDraft" /></div> + </td> + <td class="notes_line new"></td> + <td class="notes-content parallel new" colspan="2"> + <div v-if="rightDraft.isDraft" class="content"><draft-note :draft="rightDraft" /></div> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue new file mode 100644 index 00000000000..195e1b7ec5c --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -0,0 +1,111 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { sprintf, n__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import DraftsCount from './drafts_count.vue'; +import PublishButton from './publish_button.vue'; +import PreviewItem from './preview_item.vue'; + +export default { + components: { + GlLoadingIcon, + Icon, + DraftsCount, + PublishButton, + PreviewItem, + }, + computed: { + ...mapGetters(['isNotesFetched']), + ...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']), + ...mapState('batchComments', ['showPreviewDropdown']), + dropdownTitle() { + return sprintf( + n__('%{count} pending comment', '%{count} pending comments', this.draftsCount), + { count: this.draftsCount }, + ); + }, + }, + watch: { + showPreviewDropdown() { + if (this.showPreviewDropdown && this.$refs.dropdown) { + this.$nextTick(() => this.$refs.dropdown.focus()); + } + }, + }, + mounted() { + document.addEventListener('click', this.onClickDocument); + }, + beforeDestroy() { + document.removeEventListener('click', this.onClickDocument); + }, + methods: { + ...mapActions('batchComments', ['toggleReviewDropdown']), + isLast(index) { + return index === this.sortedDrafts.length - 1; + }, + onClickDocument({ target }) { + if ( + this.showPreviewDropdown && + !target.closest('.review-preview-dropdown, .js-publish-draft-button') + ) { + this.toggleReviewDropdown(); + } + }, + }, +}; +</script> + +<template> + <div + class="dropdown float-right review-preview-dropdown" + :class="{ + show: showPreviewDropdown, + }" + > + <button + ref="dropdown" + type="button" + class="btn btn-success review-preview-dropdown-toggle qa-review-preview-toggle" + @click="toggleReviewDropdown" + > + {{ __('Finish review') }} + <drafts-count /> + <icon name="angle-up" /> + </button> + <div + class="dropdown-menu dropdown-menu-large dropdown-menu-right dropdown-open-top" + :class="{ + show: showPreviewDropdown, + }" + > + <div class="dropdown-title"> + {{ dropdownTitle }} + <button + :aria-label="__('Close')" + type="button" + class="dropdown-title-button dropdown-menu-close" + @click="toggleReviewDropdown" + > + <icon name="close" /> + </button> + </div> + <div class="dropdown-content"> + <ul v-if="isNotesFetched"> + <li v-for="(draft, index) in sortedDrafts" :key="draft.id"> + <preview-item :draft="draft" :is-last="isLast(index)" /> + </li> + </ul> + <gl-loading-icon v-else size="lg" class="prepend-top-default append-bottom-default" /> + </div> + <div class="dropdown-footer"> + <publish-button + :show-count="false" + :should-publish="true" + :label="__('Submit review')" + class="float-right gl-mr-3" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue new file mode 100644 index 00000000000..22495eb4d7d --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/preview_item.vue @@ -0,0 +1,143 @@ +<script> +import { mapActions, mapGetters } from 'vuex'; +import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; +import { sprintf, __ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import resolvedStatusMixin from '../mixins/resolved_status'; +import { GlSprintf } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + getStartLineNumber, + getEndLineNumber, + getLineClasses, +} from '~/notes/components/multiline_comment_utils'; + +export default { + components: { + Icon, + GlSprintf, + }, + mixins: [resolvedStatusMixin, glFeatureFlagsMixin()], + props: { + draft: { + type: Object, + required: true, + }, + isLast: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapGetters('diffs', ['getDiffFileByHash']), + ...mapGetters(['getDiscussion']), + iconName() { + return this.isDiffDiscussion || this.draft.line_code ? 'doc-text' : 'comment'; + }, + discussion() { + return this.getDiscussion(this.draft.discussion_id); + }, + isDiffDiscussion() { + return this.discussion && this.discussion.diff_discussion; + }, + titleText() { + const file = this.discussion ? this.discussion.diff_file : this.draft; + + if (file) { + return file.file_path; + } + + return sprintf(__("%{authorsName}'s thread"), { + authorsName: this.discussion.notes.find(note => !note.system).author.name, + }); + }, + linePosition() { + if (this.draft.position && this.draft.position.position_type === IMAGE_DIFF_POSITION_TYPE) { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${this.draft.position.x}x ${this.draft.position.y}y`; + } + + const position = this.discussion ? this.discussion.position : this.draft.position; + + return position?.new_line || position?.old_line; + }, + content() { + const el = document.createElement('div'); + el.innerHTML = this.draft.note_html; + + return el.textContent; + }, + showLinePosition() { + return this.draft.file_hash || this.isDiffDiscussion; + }, + startLineNumber() { + return getStartLineNumber(this.draft.position?.line_range); + }, + endLineNumber() { + return getEndLineNumber(this.draft.position?.line_range); + }, + }, + methods: { + ...mapActions('batchComments', ['scrollToDraft']), + getLineClasses(lineNumber) { + return getLineClasses(lineNumber); + }, + }, + showStaysResolved: false, +}; +</script> + +<template> + <button + type="button" + class="review-preview-item menu-item" + :class="[ + componentClasses, + { + 'is-last': isLast, + }, + ]" + @click="scrollToDraft(draft)" + > + <span class="review-preview-item-header"> + <icon class="flex-shrink-0" :name="iconName" /> + <span + class="bold text-nowrap" + :class="{ 'gl-align-items-center': glFeatures.multilineComments }" + > + <span class="review-preview-item-header-text block-truncated"> + {{ titleText }} + </span> + <template v-if="showLinePosition"> + <template v-if="!glFeatures.multilineComments" + >:{{ linePosition }}</template + > + <template v-else-if="startLineNumber === endLineNumber"> + :<span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span> + </template> + <gl-sprintf v-else :message="__(':%{startLine} to %{endLine}')"> + <template #startLine> + <span class="gl-mr-2" :class="getLineClasses(startLineNumber)">{{ + startLineNumber + }}</span> + </template> + <template #endLine> + <span class="gl-ml-2" :class="getLineClasses(endLineNumber)">{{ + endLineNumber + }}</span> + </template> + </gl-sprintf> + </template> + </span> + </span> + <span class="review-preview-item-content"> + <p>{{ content }}</p> + </span> + <span + v-if="draft.discussion_id && resolvedStatusMessage" + class="review-preview-item-footer draft-note-resolution p-0" + > + <icon class="gl-mr-3" name="status_success" /> {{ resolvedStatusMessage }} + </span> + </button> +</template> diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue new file mode 100644 index 00000000000..f4dc0f04dc3 --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/publish_button.vue @@ -0,0 +1,55 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import DraftsCount from './drafts_count.vue'; + +export default { + components: { + LoadingButton, + DraftsCount, + }, + props: { + showCount: { + type: Boolean, + required: false, + default: false, + }, + label: { + type: String, + required: false, + default: __('Finish review'), + }, + shouldPublish: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapState('batchComments', ['isPublishing']), + }, + methods: { + ...mapActions('batchComments', ['publishReview', 'toggleReviewDropdown']), + onClick() { + if (this.shouldPublish) { + this.publishReview(); + } else { + this.toggleReviewDropdown(); + } + }, + }, +}; +</script> + +<template> + <loading-button + :loading="isPublishing" + container-class="btn btn-success js-publish-draft-button qa-submit-review" + @click="onClick" + > + <span> + {{ label }} + <drafts-count v-if="showCount" /> + </span> + </loading-button> +</template> diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue new file mode 100644 index 00000000000..b0e8b806701 --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -0,0 +1,70 @@ +<script> +import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlModal, GlModalDirective } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import PreviewDropdown from './preview_dropdown.vue'; + +export default { + components: { + LoadingButton, + GlModal, + PreviewDropdown, + }, + directives: { + 'gl-modal': GlModalDirective, + }, + computed: { + ...mapGetters(['isNotesFetched']), + ...mapState('batchComments', ['isDiscarding']), + ...mapGetters('batchComments', ['draftsCount']), + }, + watch: { + isNotesFetched() { + if (this.isNotesFetched) { + this.expandAllDiscussions(); + } + }, + }, + methods: { + ...mapActions('batchComments', ['discardReview', 'expandAllDiscussions']), + }, + modalId: 'discard-draft-review', + text: sprintf( + s__( + `BatchComments|You're about to discard your review which will delete all of your pending comments. + The deleted comments %{strong_start}cannot%{strong_end} be restored.`, + ), + { + strong_start: '<strong>', + strong_end: '</strong>', + }, + false, + ), +}; +</script> +<template> + <div v-show="draftsCount > 0"> + <nav class="review-bar-component"> + <div class="review-bar-content qa-review-bar"> + <preview-dropdown /> + <loading-button + v-gl-modal="$options.modalId" + :loading="isDiscarding" + :label="__('Discard review')" + class="qa-discard-review float-right" + /> + </div> + </nav> + <gl-modal + :title="s__('BatchComments|Discard review?')" + :ok-title="s__('BatchComments|Delete all pending comments')" + :modal-id="$options.modalId" + title-tag="h4" + ok-variant="danger qa-modal-delete-pending-comments" + @ok="discardReview" + > + <p v-html="$options.text"></p> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/batch_comments/constants.js b/app/assets/javascripts/batch_comments/constants.js new file mode 100644 index 00000000000..b309c339fc8 --- /dev/null +++ b/app/assets/javascripts/batch_comments/constants.js @@ -0,0 +1,3 @@ +export const CHANGES_TAB = 'diffs'; +export const DISCUSSION_TAB = 'notes'; +export const SHOW_TAB = 'show'; diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js new file mode 100644 index 00000000000..e06285c0b37 --- /dev/null +++ b/app/assets/javascripts/batch_comments/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import { 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'); + + // eslint-disable-next-line no-new + new Vue({ + el, + store, + mounted() { + this.fetchDrafts(); + }, + methods: { + ...mapActions('batchComments', ['fetchDrafts']), + }, + render(createElement) { + return createElement(ReviewBar); + }, + }); +}; diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js index 3bbbaa86b51..2517fb198f0 100644 --- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js +++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js @@ -1,9 +1,58 @@ -import { sprintf, __ } from '~/locale'; +import { mapGetters } from 'vuex'; +import { sprintf, s__, __ } from '~/locale'; export default { + props: { + discussionId: { + type: String, + required: false, + default: null, + }, + resolveDiscussion: { + type: Boolean, + required: false, + default: false, + }, + isDraft: { + type: Boolean, + required: false, + default: false, + }, + }, computed: { + ...mapGetters(['isDiscussionResolved']), + resolvedStatusMessage() { + let message; + const discussionResolved = this.isDiscussionResolved( + this.draft ? this.draft.discussion_id : this.discussionId, + ); + const discussionToBeResolved = this.draft + ? this.draft.resolve_discussion + : this.resolveDiscussion; + + if (discussionToBeResolved && discussionResolved && !this.$options.showStaysResolved) { + return undefined; + } + + if (discussionToBeResolved) { + message = discussionResolved + ? s__('MergeRequests|Thread stays resolved') + : s__('MergeRequests|Thread will be resolved'); + } else if (discussionResolved) { + message = s__('MergeRequests|Thread will be unresolved'); + } else if (this.$options.showStaysResolved) { + message = s__('MergeRequests|Thread stays unresolved'); + } + + return message; + }, + componentClasses() { + return this.resolveDiscussion ? 'is-resolving-discussion' : 'is-unresolving-discussion'; + }, resolveButtonTitle() { - let title = __('Mark comment as resolved'); + if (this.isDraft || this.discussionId) return this.resolvedStatusMessage; + + let title = __('Mark as resolved'); if (this.resolvedBy) { title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name }); @@ -12,4 +61,5 @@ export default { return title; }, }, + showStaysResolved: true, }; diff --git a/app/assets/javascripts/batch_comments/services/drafts_service.js b/app/assets/javascripts/batch_comments/services/drafts_service.js new file mode 100644 index 00000000000..36d2f8df612 --- /dev/null +++ b/app/assets/javascripts/batch_comments/services/drafts_service.js @@ -0,0 +1,33 @@ +import axios from '~/lib/utils/axios_utils'; + +export default { + createNewDraft(endpoint, data) { + const postData = { ...data, draft_note: data.note }; + delete postData.note; + + return axios.post(endpoint, postData); + }, + deleteDraft(endpoint, draftId) { + return axios.delete(`${endpoint}/${draftId}`); + }, + publishDraft(endpoint, draftId) { + return axios.post(endpoint, { id: draftId }); + }, + addDraftToDiscussion(endpoint, data) { + return axios.post(endpoint, data); + }, + fetchDrafts(endpoint) { + return axios.get(endpoint); + }, + publish(endpoint) { + return axios.post(endpoint); + }, + discard(endpoint) { + return axios.delete(endpoint); + }, + update(endpoint, { draftId, note, resolveDiscussion, position }) { + return axios.put(`${endpoint}/${draftId}`, { + draft_note: { note, resolve_discussion: resolveDiscussion, position }, + }); + }, +}; diff --git a/app/assets/javascripts/batch_comments/stores/index.js b/app/assets/javascripts/batch_comments/stores/index.js new file mode 100644 index 00000000000..08dc9ea70f8 --- /dev/null +++ b/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/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js new file mode 100644 index 00000000000..1ef012696c5 --- /dev/null +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -0,0 +1,151 @@ +import flash from '~/flash'; +import { __ } from '~/locale'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import service from '../../../services/drafts_service'; +import * as types from './mutation_types'; +import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants'; + +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.data) + .then(res => { + commit(types.ADD_NEW_DRAFT, res); + return res; + }) + .catch(() => { + flash(__('An error occurred adding a draft to the thread.')); + }); + +export const createNewDraft = ({ commit }, { endpoint, data }) => + service + .createNewDraft(endpoint, data) + .then(res => res.data) + .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.data) + .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', { path: 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, resolveDiscussion, position, callback }, +) => + service + .update(getters.getNotesData.draftsPath, { + draftId: note.id, + note: noteText, + resolveDiscussion, + position: JSON.stringify(position), + }) + .then(res => res.data) + .then(data => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data)) + .then(callback) + .catch(() => flash(__('An error occurred while updating the comment'))); + +export const scrollToDraft = ({ dispatch, rootGetters }, draft) => { + const discussion = draft.discussion_id && rootGetters.getDiscussion(draft.discussion_id); + const tab = + draft.file_hash || (discussion && discussion.diff_discussion) ? CHANGES_TAB : SHOW_TAB; + const tabEl = tab === CHANGES_TAB ? CHANGES_TAB : DISCUSSION_TAB; + const draftID = `note_${draft.id}`; + const el = document.querySelector(`#${tabEl} #${draftID}`); + + dispatch('closeReviewDropdown'); + + window.location.hash = draftID; + + if (window.mrTabs.currentAction !== tab) { + window.mrTabs.tabShown(tab); + } + + if (discussion) { + dispatch('expandDiscussion', { discussionId: discussion.id }, { root: true }); + } + + if (el) { + setTimeout(() => scrollToElement(el.closest('.draft-note-component'))); + } +}; + +export const toggleReviewDropdown = ({ dispatch, state }) => { + if (state.showPreviewDropdown) { + dispatch('closeReviewDropdown'); + } else { + dispatch('openReviewDropdown'); + } +}; + +export const openReviewDropdown = ({ commit }) => commit(types.OPEN_REVIEW_DROPDOWN); +export const closeReviewDropdown = ({ commit }) => commit(types.CLOSE_REVIEW_DROPDOWN); + +export const expandAllDiscussions = ({ dispatch, state }) => + state.drafts + .filter(draft => draft.discussion_id) + .forEach(draft => { + dispatch('expandDiscussion', { discussionId: draft.discussion_id }, { root: true }); + }); + +export const toggleResolveDiscussion = ({ commit }, draftId) => { + commit(types.TOGGLE_RESOLVE_DISCUSSION, draftId); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js new file mode 100644 index 00000000000..43f43c983aa --- /dev/null +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js @@ -0,0 +1,87 @@ +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) => + Boolean( + diffFileSha in getters.draftsPerFileHashAndLine && + getters.draftsPerFileHashAndLine[diffFileSha][line.line_code], + ); + +export const shouldRenderParallelDraftRow = (state, getters) => (diffFileSha, line) => { + const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha]; + const [lkey, rkey] = [parallelLineKey(line, 'left'), parallelLineKey(line, 'right')]; + + return draftsForFile ? Boolean(draftsForFile[lkey] || draftsForFile[rkey]) : false; +}; + +export const hasParallelDraftLeft = (state, getters) => (diffFileSha, line) => { + const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha]; + const lkey = parallelLineKey(line, 'left'); + + return draftsForFile ? Boolean(draftsForFile[lkey]) : false; +}; + +export const hasParallelDraftRight = (state, getters) => (diffFileSha, line) => { + const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha]; + const rkey = parallelLineKey(line, 'left'); + + return draftsForFile ? Boolean(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.line_code; + + if (draftsForFile) { + const draft = draftsForFile[key]; + if (draft && showDraftOnSide(line, side)) { + return draft; + } + } + return {}; +}; + +export const draftsForFile = state => diffFileSha => + state.drafts.filter(draft => draft.file_hash === diffFileSha); + +export const isPublishingDraft = state => draftId => + state.currentlyPublishingDrafts.indexOf(draftId) !== -1; + +export const sortedDrafts = state => [...state.drafts].sort((a, b) => a.id > b.id); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js new file mode 100644 index 00000000000..81dab0566c1 --- /dev/null +++ b/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/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js new file mode 100644 index 00000000000..c8f0658c21c --- /dev/null +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js @@ -0,0 +1,23 @@ +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'; + +export const OPEN_REVIEW_DROPDOWN = 'OPEN_REVIEW_DROPDOWN'; +export const CLOSE_REVIEW_DROPDOWN = 'CLOSE_REVIEW_DROPDOWN'; + +export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION'; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js new file mode 100644 index 00000000000..81ceef7b160 --- /dev/null +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js @@ -0,0 +1,81 @@ +import * as types from './mutation_types'; + +const processDraft = draft => ({ + ...draft, + isDraft: true, +}); + +export default { + [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)); + } + }, + [types.OPEN_REVIEW_DROPDOWN](state) { + state.showPreviewDropdown = true; + }, + [types.CLOSE_REVIEW_DROPDOWN](state) { + state.showPreviewDropdown = false; + }, + [types.TOGGLE_RESOLVE_DISCUSSION](state, draftId) { + state.drafts = state.drafts.map(draft => { + if (draft.id === draftId) { + return { + ...draft, + resolve_discussion: !draft.resolve_discussion, + }; + } + + return draft; + }); + }, +}; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js new file mode 100644 index 00000000000..80c710deab0 --- /dev/null +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js @@ -0,0 +1,9 @@ +export default () => ({ + withBatchComments: true, + isDraftsFetched: false, + drafts: [], + isPublishing: false, + currentlyPublishingDrafts: [], + isDiscarding: false, + showPreviewDropdown: false, +}); diff --git a/app/assets/javascripts/batch_comments/utils.js b/app/assets/javascripts/batch_comments/utils.js new file mode 100644 index 00000000000..cf4f7af0ebb --- /dev/null +++ b/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].line_code : ''); + +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].line_code) || ''; + const otherCode = (line[otherSide] && line[otherSide].line_code) || ''; + + // 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; +}; |