Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-06-18 14:18:50 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-06-18 14:18:50 +0300
commit8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781 (patch)
treea77e7fe7a93de11213032ed4ab1f33a3db51b738 /app/assets/javascripts/batch_comments
parent00b35af3db1abfe813a778f643dad221aad51fca (diff)
Add latest changes from gitlab-org/gitlab@13-1-stable-ee
Diffstat (limited to 'app/assets/javascripts/batch_comments')
-rw-r--r--app/assets/javascripts/batch_comments/components/diff_file_drafts.vue41
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue113
-rw-r--r--app/assets/javascripts/batch_comments/components/drafts_count.vue15
-rw-r--r--app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue32
-rw-r--r--app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue45
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue111
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_item.vue143
-rw-r--r--app/assets/javascripts/batch_comments/components/publish_button.vue55
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue70
-rw-r--r--app/assets/javascripts/batch_comments/constants.js3
-rw-r--r--app/assets/javascripts/batch_comments/index.js24
-rw-r--r--app/assets/javascripts/batch_comments/mixins/resolved_status.js54
-rw-r--r--app/assets/javascripts/batch_comments/services/drafts_service.js33
-rw-r--r--app/assets/javascripts/batch_comments/stores/index.js14
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js151
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js87
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js12
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js23
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js81
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js9
-rw-r--r--app/assets/javascripts/batch_comments/utils.js35
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;
+};