389 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
			
		
		
	
	
			389 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
| <script>
 | |
| // eslint-disable-next-line no-restricted-imports
 | |
| import { mapGetters, mapActions } from 'vuex';
 | |
| import { v4 as uuidv4 } from 'uuid';
 | |
| import { InternalEvents } from '~/tracking';
 | |
| import { getDraft, getAutoSaveKeyFromDiscussion } from '~/lib/utils/autosave';
 | |
| import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
 | |
| import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
 | |
| import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
 | |
| import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
 | |
| import DraftNote from '~/batch_comments/components/draft_note.vue';
 | |
| import { getLocationHash } from '~/lib/utils/url_utility';
 | |
| import NotePreview from '~/notes/components/note_preview.vue';
 | |
| import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
 | |
| import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
 | |
| import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue';
 | |
| import SystemNote from '~/vue_shared/components/notes/system_note.vue';
 | |
| import { Mousetrap } from '~/lib/mousetrap';
 | |
| import { ISSUABLE_COMMENT_OR_REPLY, keysFor } from '~/behaviors/shortcuts/keybindings';
 | |
| import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
 | |
| import * as constants from '../constants';
 | |
| import eventHub from '../event_hub';
 | |
| import CommentForm from './comment_form.vue';
 | |
| import DiscussionFilterNote from './discussion_filter_note.vue';
 | |
| import NoteableDiscussion from './noteable_discussion.vue';
 | |
| import NoteableNote from './noteable_note.vue';
 | |
| import OrderedLayout from './ordered_layout.vue';
 | |
| import SidebarSubscription from './sidebar_subscription.vue';
 | |
| import NotesActivityHeader from './notes_activity_header.vue';
 | |
| 
 | |
| export default {
 | |
|   name: 'NotesApp',
 | |
|   components: {
 | |
|     NotePreview,
 | |
|     NotesActivityHeader,
 | |
|     NoteableNote,
 | |
|     NoteableDiscussion,
 | |
|     SystemNote,
 | |
|     CommentForm,
 | |
|     PlaceholderNote,
 | |
|     PlaceholderSystemNote,
 | |
|     SkeletonLoadingContainer,
 | |
|     DiscussionFilterNote,
 | |
|     OrderedLayout,
 | |
|     SidebarSubscription,
 | |
|     DraftNote,
 | |
|     TimelineEntryItem,
 | |
|     AiSummary: () => import('ee_component/notes/components/ai_summary.vue'),
 | |
|   },
 | |
|   mixins: [glFeatureFlagsMixin(), InternalEvents.mixin()],
 | |
|   provide() {
 | |
|     return {
 | |
|       summarizeClientSubscriptionId: uuidv4(),
 | |
|     };
 | |
|   },
 | |
|   props: {
 | |
|     noteableData: {
 | |
|       type: Object,
 | |
|       required: true,
 | |
|     },
 | |
|     notesData: {
 | |
|       type: Object,
 | |
|       required: true,
 | |
|     },
 | |
|     notesFilters: {
 | |
|       type: Array,
 | |
|       required: true,
 | |
|     },
 | |
|     notesFilterValue: {
 | |
|       type: Number,
 | |
|       default: undefined,
 | |
|       required: false,
 | |
|     },
 | |
|     shouldShow: {
 | |
|       type: Boolean,
 | |
|       required: false,
 | |
|       default: true,
 | |
|     },
 | |
|     helpPagePath: {
 | |
|       type: String,
 | |
|       required: false,
 | |
|       default: '',
 | |
|     },
 | |
|   },
 | |
|   data() {
 | |
|     return {
 | |
|       currentFilter: null,
 | |
|       renderSkeleton: !this.shouldShow,
 | |
|       aiLoading: null,
 | |
|       isInitialEventTriggered: false,
 | |
|     };
 | |
|   },
 | |
|   computed: {
 | |
|     ...mapGetters([
 | |
|       'isNotesFetched',
 | |
|       'discussions',
 | |
|       'convertedDisscussionIds',
 | |
|       'getNotesDataByProp',
 | |
|       'isLoading',
 | |
|       'isFetching',
 | |
|       'commentsDisabled',
 | |
|       'getNoteableData',
 | |
|       'userCanReply',
 | |
|       'sortDirection',
 | |
|       'timelineEnabled',
 | |
|       'targetNoteHash',
 | |
|     ]),
 | |
|     sortDirDesc() {
 | |
|       return this.sortDirection === constants.DESC;
 | |
|     },
 | |
|     noteableType() {
 | |
|       return this.noteableData.noteableType;
 | |
|     },
 | |
|     previewNoteId() {
 | |
|       if (!this.isLoading || !this.targetNoteHash?.startsWith('note_')) return null;
 | |
|       return this.targetNoteHash.replace('note_', '');
 | |
|     },
 | |
|     allDiscussions() {
 | |
|       let skeletonNotes = [];
 | |
| 
 | |
|       if (this.renderSkeleton || this.isLoading) {
 | |
|         const prerenderedNotesCount = parseInt(this.notesData.prerenderedNotesCount, 10) || 0;
 | |
| 
 | |
|         skeletonNotes = new Array(prerenderedNotesCount).fill({
 | |
|           isSkeletonNote: true,
 | |
|         });
 | |
| 
 | |
|         if (
 | |
|           this.previewNoteId &&
 | |
|           !this.discussions.find((d) => d.notes[0].id === this.previewNoteId)
 | |
|         ) {
 | |
|           const previewNote = {
 | |
|             id: this.previewNoteId,
 | |
|             isPreviewNote: true,
 | |
|           };
 | |
|           skeletonNotes.splice(prerenderedNotesCount / 2, 0, previewNote);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       if (this.sortDirDesc) {
 | |
|         return skeletonNotes.concat(this.discussions);
 | |
|       }
 | |
| 
 | |
|       return this.discussions.concat(skeletonNotes);
 | |
|     },
 | |
|     canReply() {
 | |
|       return this.userCanReply && !this.commentsDisabled && !this.timelineEnabled;
 | |
|     },
 | |
|     slotKeys() {
 | |
|       return this.sortDirDesc ? ['form', 'comments'] : ['comments', 'form'];
 | |
|     },
 | |
|     isAppReady() {
 | |
|       return !this.isLoading && !this.renderSkeleton && this.shouldShow;
 | |
|     },
 | |
|   },
 | |
|   watch: {
 | |
|     async isFetching() {
 | |
|       if (!this.isFetching) {
 | |
|         await this.$nextTick();
 | |
|         await this.startTaskList();
 | |
|         await this.checkLocationHash();
 | |
|       }
 | |
|     },
 | |
|     shouldShow() {
 | |
|       if (!this.isNotesFetched) {
 | |
|         this.fetchNotes();
 | |
|       }
 | |
| 
 | |
|       setTimeout(() => {
 | |
|         this.renderSkeleton = !this.shouldShow;
 | |
|       });
 | |
|     },
 | |
|     isAppReady: {
 | |
|       handler(isReady) {
 | |
|         if (!isReady) return;
 | |
|         this.$nextTick(() => {
 | |
|           window.mrTabs?.eventHub.$emit('NotesAppReady');
 | |
|         });
 | |
|       },
 | |
|       immediate: true,
 | |
|     },
 | |
|   },
 | |
|   mounted() {
 | |
|     const { parentElement } = this.$el;
 | |
|     if (parentElement && parentElement.classList.contains('js-vue-notes-event')) {
 | |
|       parentElement.addEventListener('toggleAward', (event) => {
 | |
|         const { awardName, noteId } = event.detail;
 | |
|         this.toggleAward({ awardName, noteId });
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     eventHub.$on('noteFormAddToReview', this.handleReviewTracking);
 | |
|     eventHub.$on('noteFormStartReview', this.handleReviewTracking);
 | |
| 
 | |
|     window.addEventListener('hashchange', this.handleHashChanged);
 | |
| 
 | |
|     if (this.targetNoteHash && this.targetNoteHash.startsWith('note_')) {
 | |
|       scrollToTargetOnResize();
 | |
|     }
 | |
| 
 | |
|     eventHub.$on('notesApp.updateIssuableConfidentiality', this.setConfidentiality);
 | |
|     Mousetrap.bind(keysFor(ISSUABLE_COMMENT_OR_REPLY), (e) => this.quoteReply(e));
 | |
|   },
 | |
|   updated() {
 | |
|     this.$nextTick(() => {
 | |
|       highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
 | |
|     });
 | |
|   },
 | |
|   beforeDestroy() {
 | |
|     window.removeEventListener('hashchange', this.handleHashChanged);
 | |
|     eventHub.$off('notesApp.updateIssuableConfidentiality', this.setConfidentiality);
 | |
|     eventHub.$off('noteFormStartReview', this.handleReviewTracking);
 | |
|     eventHub.$off('noteFormAddToReview', this.handleReviewTracking);
 | |
|     Mousetrap.unbind(keysFor(ISSUABLE_COMMENT_OR_REPLY), this.quoteReply);
 | |
|   },
 | |
|   methods: {
 | |
|     ...mapActions([
 | |
|       'toggleAward',
 | |
|       'setLastFetchedAt',
 | |
|       'setTargetNoteHash',
 | |
|       'toggleDiscussion',
 | |
|       'expandDiscussion',
 | |
|       'startTaskList',
 | |
|       'convertToDiscussion',
 | |
|       'setConfidentiality',
 | |
|       'fetchNotes',
 | |
|     ]),
 | |
|     getDiscussionInSelection() {
 | |
|       const selection = window.getSelection();
 | |
|       if (selection.rangeCount <= 0) return null;
 | |
| 
 | |
|       const el = selection.getRangeAt(0).startContainer;
 | |
|       const node = el.nodeType === Node.TEXT_NODE ? el.parentNode : el;
 | |
|       return node.closest('.js-noteable-discussion');
 | |
|     },
 | |
|     async quoteReply(e) {
 | |
|       const discussionEl = this.getDiscussionInSelection();
 | |
|       const text = await CopyAsGFM.selectionToGfm();
 | |
| 
 | |
|       // Prevent 'r' being written.
 | |
|       if (e && typeof e.preventDefault === 'function') {
 | |
|         e.preventDefault();
 | |
|       }
 | |
| 
 | |
|       if (!discussionEl) {
 | |
|         this.replyInMainEditor(text);
 | |
|       } else {
 | |
|         const instance = this.$refs.discussions.find(({ $el }) => $el === discussionEl);
 | |
|         // prevent hotkey input from going into the form
 | |
|         requestAnimationFrame(() => {
 | |
|           instance.showReplyForm(text);
 | |
|         });
 | |
|       }
 | |
|     },
 | |
|     replyInMainEditor(text) {
 | |
|       this.$refs.commentForm.append(text);
 | |
|     },
 | |
|     discussionIsIndividualNoteAndNotConverted(discussion) {
 | |
|       return (
 | |
|         discussion.individual_note &&
 | |
|         !this.convertedDisscussionIds.includes(discussion.id) &&
 | |
|         !this.hasDraft(discussion)
 | |
|       );
 | |
|     },
 | |
|     handleHashChanged() {
 | |
|       const noteId = this.checkLocationHash();
 | |
| 
 | |
|       if (noteId) {
 | |
|         this.setTargetNoteHash(getLocationHash());
 | |
|       }
 | |
|     },
 | |
|     checkLocationHash() {
 | |
|       const hash = getLocationHash();
 | |
|       const noteId = (hash && hash.startsWith('note_') && hash.replace(/^note_/, '')) ?? null;
 | |
| 
 | |
|       if (noteId) {
 | |
|         const discussion = this.discussions.find((d) => d.notes.some(({ id }) => id === noteId));
 | |
| 
 | |
|         if (discussion) {
 | |
|           this.expandDiscussion({ discussionId: discussion.id });
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       return noteId;
 | |
|     },
 | |
|     startReplying(discussionId) {
 | |
|       return this.convertToDiscussion(discussionId)
 | |
|         .then(this.$nextTick)
 | |
|         .then(() => eventHub.$emit('startReplying', discussionId));
 | |
|     },
 | |
|     setAiLoading(loading) {
 | |
|       this.aiLoading = loading;
 | |
|     },
 | |
|     handleReviewTracking(event) {
 | |
|       const types = {
 | |
|         noteFormStartReview: 'merge_request_click_start_review_on_overview_tab',
 | |
|         noteFormAddToReview: 'merge_request_click_add_to_review_on_overview_tab',
 | |
|       };
 | |
| 
 | |
|       if (this.shouldShow && window.mrTabs && types[event.name]) {
 | |
|         this.trackEvent(types[event.name]);
 | |
|       }
 | |
|     },
 | |
|     hasDraft(discussion) {
 | |
|       const autoSaveKey = getAutoSaveKeyFromDiscussion(discussion);
 | |
|       return Boolean(getDraft(autoSaveKey));
 | |
|     },
 | |
|   },
 | |
|   systemNote: constants.SYSTEM_NOTE,
 | |
| };
 | |
| </script>
 | |
| 
 | |
| <template>
 | |
|   <div v-show="shouldShow" id="notes">
 | |
|     <sidebar-subscription :iid="noteableData.iid" :noteable-data="noteableData" />
 | |
|     <notes-activity-header
 | |
|       :notes-filters="notesFilters"
 | |
|       :notes-filter-value="notesFilterValue"
 | |
|       :ai-loading="aiLoading"
 | |
|       @set-ai-loading="setAiLoading"
 | |
|     />
 | |
|     <ai-summary v-if="aiLoading !== null" :ai-loading="aiLoading" @set-ai-loading="setAiLoading" />
 | |
|     <ordered-layout :slot-keys="slotKeys">
 | |
|       <template #form>
 | |
|         <comment-form
 | |
|           v-if="!(commentsDisabled || timelineEnabled)"
 | |
|           ref="commentForm"
 | |
|           class="js-comment-form"
 | |
|           :noteable-type="noteableType"
 | |
|         />
 | |
|       </template>
 | |
|       <template #comments>
 | |
|         <ul id="notes-list" class="notes main-notes-list timeline">
 | |
|           <template v-for="discussion in allDiscussions">
 | |
|             <skeleton-loading-container
 | |
|               v-if="discussion.isSkeletonNote"
 | |
|               :key="discussion.id"
 | |
|               class="note-skeleton"
 | |
|             />
 | |
|             <timeline-entry-item
 | |
|               v-else-if="discussion.isPreviewNote"
 | |
|               :key="discussion.id"
 | |
|               class="target note note-wrapper note-comment"
 | |
|             >
 | |
|               <note-preview :note-id="previewNoteId" />
 | |
|             </timeline-entry-item>
 | |
|             <timeline-entry-item v-else-if="discussion.isDraft" :key="discussion.id">
 | |
|               <draft-note :draft="discussion" />
 | |
|             </timeline-entry-item>
 | |
|             <template v-else-if="discussion.isPlaceholderNote">
 | |
|               <placeholder-system-note
 | |
|                 v-if="discussion.placeholderType === $options.systemNote"
 | |
|                 :key="discussion.id"
 | |
|                 :note="discussion.notes[0]"
 | |
|               />
 | |
|               <placeholder-note v-else :key="discussion.id" :note="discussion.notes[0]" />
 | |
|             </template>
 | |
|             <template v-else-if="discussionIsIndividualNoteAndNotConverted(discussion)">
 | |
|               <system-note
 | |
|                 v-if="discussion.notes[0].system"
 | |
|                 :key="discussion.id"
 | |
|                 :note="discussion.notes[0]"
 | |
|               />
 | |
|               <noteable-note
 | |
|                 v-else
 | |
|                 :key="discussion.id"
 | |
|                 :note="discussion.notes[0]"
 | |
|                 :show-reply-button="canReply"
 | |
|                 @startReplying="startReplying(discussion.id)"
 | |
|               />
 | |
|             </template>
 | |
|             <noteable-discussion
 | |
|               v-else
 | |
|               ref="discussions"
 | |
|               :key="discussion.id"
 | |
|               class="js-noteable-discussion"
 | |
|               :discussion="discussion"
 | |
|               :render-diff-file="true"
 | |
|               is-overview-tab
 | |
|               :help-page-path="helpPagePath"
 | |
|             />
 | |
|           </template>
 | |
|           <discussion-filter-note v-if="commentsDisabled" />
 | |
|         </ul>
 | |
|       </template>
 | |
|     </ordered-layout>
 | |
|   </div>
 | |
| </template>
 |