From 2d39b3337425b69f775fa65592aefc297ccd0a26 Mon Sep 17 00:00:00 2001 From: Thomas Randolph Date: Mon, 22 Nov 2021 20:49:58 -0700 Subject: [PATCH 1/3] Update scroll position to synchronize the Jump To Next button When you scroll a discussion halfway up the page, the Jump To Next Unresolved Discussion button will go to the discussion after that one. When you scroll the page such that a discussion scrolls down through the middle of the page and below it, the Jump button considers that discussion the "next" in line to be seen, and will scroll to it. Changelog: added --- .../javascripts/diffs/components/app.vue | 8 + .../javascripts/diffs/utils/discussions.js | 91 +++++++++++ .../notes/components/discussion_notes.vue | 46 +++++- .../notes/components/notes_app.vue | 8 + .../javascripts/notes/stores/getters.js | 34 ++-- .../diffs/components/diff_discussions_spec.js | 8 + spec/frontend/diffs/utils/discussions_spec.js | 151 ++++++++++++++++++ .../notes/components/discussion_notes_spec.js | 8 + .../components/noteable_discussion_spec.js | 16 ++ .../notes/components/notes_app_spec.js | 8 + spec/frontend/notes/stores/getters_spec.js | 2 +- 11 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 app/assets/javascripts/diffs/utils/discussions.js create mode 100644 spec/frontend/diffs/utils/discussions_spec.js diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 66d06a3a1b6096..ef26f160ad2792 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -44,6 +44,10 @@ import { TRACKING_MULTIPLE_FILES_MODE, } from '../constants'; +import { + discussionIntersectionObserver, + discussionIntersectionObserverHandlerFactory, +} from '../utils/discussions'; import diffsEventHub from '../event_hub'; import { reviewStatuses } from '../utils/file_reviews'; import { diffsApp } from '../utils/performance'; @@ -86,6 +90,10 @@ export default { ALERT_MERGE_CONFLICT, ALERT_COLLAPSED_FILES, }, + provide: { + discussionObserver: discussionIntersectionObserver(), + discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), + }, props: { endpoint: { type: String, diff --git a/app/assets/javascripts/diffs/utils/discussions.js b/app/assets/javascripts/diffs/utils/discussions.js new file mode 100644 index 00000000000000..ecb22db7e030ed --- /dev/null +++ b/app/assets/javascripts/diffs/utils/discussions.js @@ -0,0 +1,91 @@ +import { create } from '~/lib/utils/intersection_observer'; + +function normalize(processable) { + const { entry } = processable; + const offset = entry.rootBounds.bottom - entry.boundingClientRect.top; + const direction = + offset < 0 ? 'Up' : 'Down'; /* eslint-disable-line @gitlab/require-i18n-strings */ + + return { + ...processable, + entry: { + time: entry.time, + type: entry.isIntersecting ? 'intersection' : `scroll${direction}`, + }, + }; +} + +function sort( + { discussionIds, entry: alpha, currentDiscussion: alphaDiscussion }, + { entry: beta, currentDiscussion: betaDiscussion }, +) { + const diff = alpha.time - beta.time; + const bottomUpDiscussions = discussionIds.reverse(); + let order = 0; + + if (diff < 0) { + order = -1; + } else if (diff > 0) { + order = 1; + } else if (alpha.type === 'intersection' && beta.type === 'scrollUp') { + order = 2; + } else if (alpha.type === 'scrollUp' && beta.type === 'intersection') { + order = -2; + } else if (alpha.type === 'scrollUp' && beta.type === 'scrollUp') { + order = + bottomUpDiscussions.indexOf(alphaDiscussion.id) - + bottomUpDiscussions.indexOf(betaDiscussion.id); + } + + return order; +} + +function filter(entry) { + return entry.type !== 'scrollDown'; +} + +export function discussionIntersectionObserver() { + return create({ + threshold: 0, + rootMargin: '0px 0px -50% 0px', + }); +} + +export function discussionIntersectionObserverHandlerFactory() { + let unprocessed = []; + let timer = null; + + return (processable) => { + unprocessed.push(processable); + + if (timer) { + clearTimeout(timer); + } + + timer = setTimeout(() => { + unprocessed + .map(normalize) + .filter(filter) + .sort(sort) + .forEach((discussionObservationContainer) => { + const { + entry: { type }, + currentDiscussion: { id }, + isFirstUnresolved, + isDiffsPage, + functions: { setCurrentDiscussionId, getPreviousUnresolvedDiscussionId }, + } = discussionObservationContainer; + + if (type === 'intersection') { + setCurrentDiscussionId(id); + } else if (type === 'scrollUp') { + setCurrentDiscussionId( + isFirstUnresolved ? null : getPreviousUnresolvedDiscussionId(id, isDiffsPage), + ); + } + }); + + unprocessed = []; + }, 0); + }; +} diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 6fcfa66ea49413..c596468b0e6c8a 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -17,6 +17,7 @@ export default { NoteEditedText, DiscussionNotesRepliesWrapper, }, + inject: ['discussionObserver', 'discussionObserverHandler'], props: { discussion: { type: Object, @@ -54,7 +55,12 @@ export default { }, }, computed: { - ...mapGetters(['userCanReply']), + ...mapGetters([ + 'userCanReply', + 'previousUnresolvedDiscussionId', + 'firstUnresolvedDiscussionId', + 'unresolvedDiscussionsIdsOrdered', + ]), hasReplies() { return Boolean(this.replies.length); }, @@ -77,9 +83,44 @@ export default { url: this.discussion.discussion_path, }; }, + isFirstUnresolved() { + return this.firstUnresolvedDiscussionId() === this.discussion.id; + }, + discussionIds() { + return this.unresolvedDiscussionsIdsOrdered(!this.isOverviewTab); + }, + }, + mounted() { + const { observer, id } = this.discussionObserver; + + if (this.$refs.firstDiscussionNote) { + const note = this.$refs.firstDiscussionNote.$el; + + observer.observe(note); + + note.addEventListener(`${id}IntersectionUpdate`, (event) => { + const observerEntry = event.detail; + + this.discussionObserverHandler({ + entry: observerEntry, + isFirstUnresolved: this.isFirstUnresolved, + currentDiscussion: { ...this.discussion }, + discussionIds: this.discussionIds, + isDiffsPage: !this.isOverviewTab, + functions: { + setCurrentDiscussionId: this.setCurrentDiscussionId, + getPreviousUnresolvedDiscussionId: this.previousUnresolvedDiscussionId, + }, + }); + }); + } }, methods: { - ...mapActions(['toggleDiscussion', 'setSelectedCommentPositionHover']), + ...mapActions([ + 'toggleDiscussion', + 'setSelectedCommentPositionHover', + 'setCurrentDiscussionId', + ]), componentName(note) { if (note.isPlaceholderNote) { if (note.placeholderType === SYSTEM_NOTE) { @@ -124,6 +165,7 @@ export default {