diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index daa04abb72f691fb4b72206fbd447bd33ed7f99e..03522b4f309932248a622f434f363615fe7db828 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -15,6 +15,7 @@ import { import isExpandedHierarchyTreeChildQuery from '~/work_items/graphql/client/is_expanded_hierarchy_tree_child.query.graphql'; import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; +import activeDiscussionQuery from '~/work_items/components/design_management/graphql/client/active_design_discussion.query.graphql'; import { updateNewWorkItemCache, workItemBulkEdit } from '~/work_items/graphql/resolvers'; export const config = { @@ -274,6 +275,17 @@ export const resolvers = { }, }); }, + updateActiveDesignDiscussion: (_, { id = null, source }, { cache }) => { + const data = { + activeDesignDiscussion: { + __typename: 'ActiveDesignDiscussion', + id, + source, + }, + }; + + cache.writeQuery({ query: activeDiscussionQuery, data }); + }, }, }; diff --git a/app/assets/javascripts/work_items/components/design_management/constants.js b/app/assets/javascripts/work_items/components/design_management/constants.js index 71b595a11fb80d7baa8ca5a9095790ce7a98239d..2d810c2d9ed66439cef8dfe95747ff02c72068e6 100644 --- a/app/assets/javascripts/work_items/components/design_management/constants.js +++ b/app/assets/javascripts/work_items/components/design_management/constants.js @@ -3,3 +3,9 @@ export const DESIGN_DETAIL_LAYOUT_CLASSLIST = [ 'gl-overflow-hidden', 'gl-m-0', ]; + +export const ACTIVE_DISCUSSION_SOURCE_TYPES = { + pin: 'pin', + discussion: 'discussion', + url: 'url', +}; diff --git a/app/assets/javascripts/work_items/components/design_management/design_notes/design_discussion.vue b/app/assets/javascripts/work_items/components/design_management/design_notes/design_discussion.vue index c5ca6d880383d9f12f6c8b2728971c48a1a88f12..ad66b1f28436dfb43ae2e036c0aa24e988373795 100644 --- a/app/assets/javascripts/work_items/components/design_management/design_notes/design_discussion.vue +++ b/app/assets/javascripts/work_items/components/design_management/design_notes/design_discussion.vue @@ -4,6 +4,8 @@ import { s__ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; import { isLoggedIn } from '~/lib/utils/common_utils'; +import activeDiscussionQuery from '../graphql/client/active_design_discussion.query.graphql'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; import DesignNote from './design_note.vue'; import ToggleRepliesWidget from './toggle_replies_widget.vue'; @@ -25,10 +27,32 @@ export default { required: true, }, }, + apollo: { + activeDesignDiscussion: { + query: activeDiscussionQuery, + result({ data }) { + if (this.discussion.resolved && !this.resolvedDiscussionsExpanded) { + return; + } + + this.$nextTick(() => { + // We watch any changes to the active discussion from the design pins and scroll to this discussion if it exists. + // We don't want scrollIntoView to be triggered from the discussion click itself. + if (this.$el && this.shouldScrollToDiscussion(data.activeDesignDiscussion)) { + this.$el.scrollIntoView({ + behavior: 'smooth', + inline: 'start', + }); + } + }); + }, + }, + }, data() { return { areRepliesCollapsed: this.discussion.resolved, isLoggedIn: isLoggedIn(), + activeDesignDiscussion: {}, }; }, computed: { @@ -52,6 +76,19 @@ export default { isRepliesWidgetVisible() { return this.discussionReplies.length > 0; }, + isDiscussionActive() { + return this.discussion.notes.some(({ id }) => id === this.activeDesignDiscussion.id); + }, + }, + methods: { + shouldScrollToDiscussion(activeDesignDiscussion) { + const ALLOWED_ACTIVE_DISCUSSION_SOURCES = [ + ACTIVE_DISCUSSION_SOURCE_TYPES.pin, + ACTIVE_DISCUSSION_SOURCE_TYPES.url, + ]; + const { source } = activeDesignDiscussion; + return ALLOWED_ACTIVE_DISCUSSION_SOURCES.includes(source) && this.isDiscussionActive; + }, }, }; </script> @@ -59,7 +96,11 @@ export default { <template> <div class="design-discussion-wrapper" @click="$emit('update-active-discussion')"> <design-note-pin :is-resolved="discussion.resolved" :label="discussion.index" /> - <ul class="design-discussion bordered-box gl-relative gl-list-none gl-p-0"> + <ul + class="design-discussion bordered-box gl-relative gl-list-none gl-p-0" + :class="{ 'gl-bg-blue-50': isDiscussionActive }" + data-testid="design-discussion-content" + > <design-note :note="firstNote"> <template v-if="isLoggedIn && discussion.resolvable" #resolve-discussion> <gl-button diff --git a/app/assets/javascripts/work_items/components/design_management/design_preview/design_details.vue b/app/assets/javascripts/work_items/components/design_management/design_preview/design_details.vue index f37370beed16eea5cfd99faedc1603be83678b21..ce74e88add5eefac99b1a0a34d9b855238a4da63 100644 --- a/app/assets/javascripts/work_items/components/design_management/design_preview/design_details.vue +++ b/app/assets/javascripts/work_items/components/design_management/design_preview/design_details.vue @@ -7,7 +7,7 @@ import { Mousetrap } from '~/lib/mousetrap'; import { keysFor, ISSUE_CLOSE_DESIGN } from '~/behaviors/shortcuts/keybindings'; import { ROUTES } from '../../../constants'; import getDesignQuery from '../graphql/design_details.query.graphql'; -import { extractDesign, getPageLayoutElement } from '../utils'; +import { extractDesign, extractDiscussions, getPageLayoutElement } from '../utils'; import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants'; import { DESIGN_NOT_FOUND_ERROR, DESIGN_VERSION_NOT_EXIST_ERROR } from '../error_messages'; import DesignPresentation from './design_presentation.vue'; @@ -66,7 +66,6 @@ export default { resolvedDiscussionsExpanded: false, prevCurrentUserTodos: null, maxScale: DEFAULT_MAX_SCALE, - discussions: [], workItemId: '', workItemTitle: '', isSidebarOpen: true, @@ -111,6 +110,22 @@ export default { ? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}` : null; }, + discussions() { + if (!this.design.discussions) { + return []; + } + return extractDiscussions(this.design.discussions); + }, + resolvedDiscussions() { + return this.discussions.filter((discussion) => discussion.resolved); + }, + }, + watch: { + resolvedDiscussions(val) { + if (!val.length) { + this.resolvedDiscussionsExpanded = false; + } + }, }, mounted() { Mousetrap.bind(keysFor(ISSUE_CLOSE_DESIGN), this.closeDesign); @@ -155,6 +170,9 @@ export default { toggleSidebar() { this.isSidebarOpen = !this.isSidebarOpen; }, + toggleResolvedComments(newValue) { + this.resolvedDiscussionsExpanded = newValue; + }, }, }; </script> @@ -191,7 +209,13 @@ export default { @setMaxScale="setMaxScale" /> </div> - <design-sidebar :design="design" :is-loading="isLoading" :is-open="isSidebarOpen" /> + <design-sidebar + :design="design" + :is-loading="isLoading" + :is-open="isSidebarOpen" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + @toggleResolvedComments="toggleResolvedComments" + /> </div> </div> </div> diff --git a/app/assets/javascripts/work_items/components/design_management/design_preview/design_overlay.vue b/app/assets/javascripts/work_items/components/design_management/design_preview/design_overlay.vue new file mode 100644 index 0000000000000000000000000000000000000000..2cc5ff9d29093d646dd62e42a938e4fcbe7b5239 --- /dev/null +++ b/app/assets/javascripts/work_items/components/design_management/design_preview/design_overlay.vue @@ -0,0 +1,311 @@ +<script> +import { __ } from '~/locale'; +import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; +import updateActiveDiscussionMutation from '../graphql/client/update_active_design_discussion.mutation.graphql'; +import activeDiscussionQuery from '../graphql/client/active_design_discussion.query.graphql'; + +export default { + name: 'DesignOverlay', + components: { + DesignNotePin, + }, + props: { + dimensions: { + type: Object, + required: true, + }, + position: { + type: Object, + required: true, + }, + notes: { + type: Array, + required: false, + default: () => [], + }, + currentCommentForm: { + type: Object, + required: false, + default: null, + }, + disableCommenting: { + type: Boolean, + required: false, + default: false, + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + disableNotes: { + type: Boolean, + required: false, + default: false, + }, + }, + apollo: { + activeDesignDiscussion: { + query: activeDiscussionQuery, + }, + }, + data() { + return { + movingNoteNewPosition: null, + movingNoteStartPosition: null, + activeDesignDiscussion: {}, + }; + }, + computed: { + overlayStyle() { + const cursor = this.disableCommenting ? 'unset' : undefined; + + return { + cursor, + width: `${this.dimensions.width}px`, + height: `${this.dimensions.height}px`, + ...this.position, + }; + }, + isMovingCurrentComment() { + return Boolean(this.movingNoteStartPosition && !this.movingNoteStartPosition.noteId); + }, + currentCommentPositionStyle() { + return this.isMovingCurrentComment && this.movingNoteNewPosition + ? this.getNotePositionStyle(this.movingNoteNewPosition) + : this.getNotePositionStyle(this.currentCommentForm); + }, + visibleNotes() { + if (this.resolvedDiscussionsExpanded) { + return this.notes; + } + + return this.notes.filter((note) => !note.resolved); + }, + }, + methods: { + setNewNoteCoordinates({ x, y }) { + this.$emit('openCommentForm', { x, y }); + }, + getNoteRelativePosition(position) { + const { x, y, width, height } = position; + const widthRatio = this.dimensions.width / width; + const heightRatio = this.dimensions.height / height; + return { + left: Math.round(x * widthRatio), + top: Math.round(y * heightRatio), + }; + }, + getNotePositionStyle(position) { + const { left, top } = this.getNoteRelativePosition(position); + return { + left: `${left}px`, + top: `${top}px`, + }; + }, + getMovingNotePositionDelta(e) { + let deltaX = 0; + let deltaY = 0; + + if (this.movingNoteStartPosition) { + const { clientX, clientY } = this.movingNoteStartPosition; + deltaX = e.clientX - clientX; + deltaY = e.clientY - clientY; + } + + return { + deltaX, + deltaY, + }; + }, + isMovingNote(noteId) { + const movingNoteId = this.movingNoteStartPosition?.noteId; + return Boolean(movingNoteId && movingNoteId === noteId); + }, + canMoveNote(note) { + const { userPermissions } = note; + const { repositionNote } = userPermissions || {}; + + return Boolean(repositionNote); + }, + isPositionInOverlay(position) { + const { top, left } = this.getNoteRelativePosition(position); + const { height, width } = this.dimensions; + + return top >= 0 && top <= height && left >= 0 && left <= width; + }, + onNewNoteMove(e) { + if (!this.isMovingCurrentComment) return; + + const { deltaX, deltaY } = this.getMovingNotePositionDelta(e); + const x = this.currentCommentForm.x + deltaX; + const y = this.currentCommentForm.y + deltaY; + + const movingNoteNewPosition = { + x, + y, + width: this.dimensions.width, + height: this.dimensions.height, + }; + + if (!this.isPositionInOverlay(movingNoteNewPosition)) { + this.onNewNoteMouseup(); + return; + } + + this.movingNoteNewPosition = movingNoteNewPosition; + }, + onExistingNoteMove(e) { + const note = this.notes.find(({ id }) => id === this.movingNoteStartPosition.noteId); + if (!note || !this.canMoveNote(note)) return; + + const { position } = note; + const { width, height } = position; + const widthRatio = this.dimensions.width / width; + const heightRatio = this.dimensions.height / height; + + const { deltaX, deltaY } = this.getMovingNotePositionDelta(e); + const x = position.x * widthRatio + deltaX; + const y = position.y * heightRatio + deltaY; + + const movingNoteNewPosition = { + x, + y, + width: this.dimensions.width, + height: this.dimensions.height, + }; + + if (!this.isPositionInOverlay(movingNoteNewPosition)) { + this.onExistingNoteMouseup(); + return; + } + + this.movingNoteNewPosition = movingNoteNewPosition; + }, + onNewNoteMouseup() { + if (!this.movingNoteNewPosition) return; + + const { x, y } = this.movingNoteNewPosition; + this.setNewNoteCoordinates({ x, y }); + }, + onExistingNoteMouseup(note) { + if (!this.movingNoteStartPosition || !this.movingNoteNewPosition) { + this.updateActiveDesignDiscussion(note.id); + this.$emit('closeCommentForm'); + return; + } + + const { x, y } = this.movingNoteNewPosition; + this.$emit('moveNote', { + noteId: this.movingNoteStartPosition.noteId, + discussionId: this.movingNoteStartPosition.discussionId, + coordinates: { x, y }, + }); + }, + onNoteMousedown({ clientX, clientY }, note) { + this.movingNoteStartPosition = { + noteId: note?.id, + discussionId: note?.discussion.id, + clientX, + clientY, + }; + }, + onOverlayMousemove(e) { + if (!this.movingNoteStartPosition) return; + + if (this.isMovingCurrentComment) { + this.onNewNoteMove(e); + } else { + this.onExistingNoteMove(e); + } + }, + onNoteMouseup(note) { + if (!this.movingNoteStartPosition) return; + + if (this.isMovingCurrentComment) { + this.onNewNoteMouseup(); + } else { + this.onExistingNoteMouseup(note); + } + + this.movingNoteStartPosition = null; + this.movingNoteNewPosition = null; + }, + onAddCommentMouseup({ offsetX, offsetY }) { + if (this.disableCommenting) return; + if (this.activeDesignDiscussion.id) { + this.updateActiveDesignDiscussion(); + } + + this.setNewNoteCoordinates({ x: offsetX, y: offsetY }); + }, + updateActiveDesignDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin, + }, + }); + }, + isNoteInactive(note) { + const discussionNotes = note.discussion.notes.nodes || []; + + return ( + this.activeDesignDiscussion.id && + !discussionNotes.some(({ id }) => id === this.activeDesignDiscussion.id) + ); + }, + }, + i18n: { + newCommentButtonLabel: __('Add comment to design'), + }, +}; +</script> + +<template> + <div + class="frame gl-absolute gl-left-0 gl-top-0" + :style="overlayStyle" + data-testid="design-overlay" + @mousemove="onOverlayMousemove" + @mouseleave="onNoteMouseup" + > + <button + v-show="!disableCommenting" + type="button" + role="button" + :aria-label="$options.i18n.newCommentButtonLabel" + class="btn-transparent gl-absolute gl-left-0 gl-top-0 gl-h-full gl-w-full gl-p-0 gl-outline-none hover:gl-cursor-crosshair" + data-testid="design-image-button" + @mouseup="onAddCommentMouseup" + ></button> + + <template v-if="!disableNotes"> + <design-note-pin + v-for="note in visibleNotes" + :key="note.id" + :label="note.index" + :position=" + isMovingNote(note.id) && movingNoteNewPosition + ? getNotePositionStyle(movingNoteNewPosition) + : getNotePositionStyle(note.position) + " + :is-inactive="isNoteInactive(note)" + :is-resolved="note.resolved" + is-on-image + data-testid="note-pin" + @mousedown.stop="onNoteMousedown($event, note)" + @mouseup.stop="onNoteMouseup(note)" + /> + + <design-note-pin + v-if="currentCommentForm" + :position="currentCommentPositionStyle" + data-testid="comment-badge" + @mousedown.stop="onNoteMousedown" + @mouseup.stop="onNoteMouseup" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/design_management/design_preview/design_presentation.vue b/app/assets/javascripts/work_items/components/design_management/design_preview/design_presentation.vue index 014295d42d73f28ce2acb73f04299b9dc5a46366..0ce4c1c2ea9c47d9c262c96341a14e0cab8e1e7a 100644 --- a/app/assets/javascripts/work_items/components/design_management/design_preview/design_presentation.vue +++ b/app/assets/javascripts/work_items/components/design_management/design_preview/design_presentation.vue @@ -3,12 +3,14 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { throttle } from 'lodash'; import { isLoggedIn } from '~/lib/utils/common_utils'; import DesignImage from './image.vue'; +import DesignOverlay from './design_overlay.vue'; const CLICK_DRAG_BUFFER_PX = 2; export default { components: { DesignImage, + DesignOverlay, GlLoadingIcon, }, props: { @@ -296,6 +298,7 @@ export default { <template> <div ref="presentationViewport" + data-testid="presentation-viewport" class="overflow-auto gl-relative gl-h-full gl-w-full gl-p-5" :style="presentationStyle" @mousedown="onPresentationMousedown" @@ -316,6 +319,19 @@ export default { :scale="scale" @resize="onImageResize" /> + <design-overlay + v-if="overlayDimensions && overlayPosition" + :dimensions="overlayDimensions" + :position="overlayPosition" + :notes="discussionStartingNotes" + :current-comment-form="currentCommentForm" + :disable-commenting="!isLoggedIn || isDraggingDesign || disableCommenting" + :disable-notes="false" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + @openCommentForm="openCommentForm" + @closeCommentForm="closeCommentForm" + @moveNote="moveNote" + /> </div> </div> </template> diff --git a/app/assets/javascripts/work_items/components/design_management/design_preview/design_sidebar.vue b/app/assets/javascripts/work_items/components/design_management/design_preview/design_sidebar.vue index df6ec9173537e55a3d37fbd51addb7dfa31fb540..ce6fce06960f3386e6777b1c4e7b27f6b8485222 100644 --- a/app/assets/javascripts/work_items/components/design_management/design_preview/design_sidebar.vue +++ b/app/assets/javascripts/work_items/components/design_management/design_preview/design_sidebar.vue @@ -4,7 +4,9 @@ import EMPTY_DISCUSSION_URL from '@gitlab/svgs/dist/illustrations/empty-state/em import { isLoggedIn } from '~/lib/utils/common_utils'; import { s__, n__ } from '~/locale'; import DesignDisclosure from '~/vue_shared/components/design_management/design_disclosure.vue'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; import { extractDiscussions } from '../utils'; +import updateActiveDiscussionMutation from '../graphql/client/update_active_design_discussion.mutation.graphql'; import DesignDiscussion from '../design_notes/design_discussion.vue'; import DesignDescription from './design_description.vue'; @@ -31,11 +33,14 @@ export default { type: Boolean, required: true, }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, }, data() { return { isLoggedIn: isLoggedIn(), - resolvedDiscussionsExpanded: false, }; }, computed: { @@ -60,6 +65,28 @@ export default { resolvedDiscussionsTitle() { return `${this.$options.i18n.resolveCommentsToggleText} (${this.resolvedDiscussions.length})`; }, + isResolvedDiscussionsExpanded: { + get() { + return this.resolvedDiscussionsExpanded; + }, + set(isExpanded) { + this.$emit('toggleResolvedComments', isExpanded); + }, + }, + }, + methods: { + handleSidebarClick() { + this.updateActiveDesignDiscussion(); + }, + updateActiveDesignDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion, + }, + }); + }, }, i18n: { resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'), @@ -71,7 +98,7 @@ export default { <template> <design-disclosure :open="isOpen"> <template #default> - <div class="image-notes gl-h-full gl-pt-0"> + <div class="image-notes gl-h-full gl-pt-0" @click.self="handleSidebarClick"> <design-description v-if="showDescription" :design="design" class="gl-border-b gl-my-5" /> <div v-if="isLoading" class="gl-my-5"> <gl-skeleton-loader /> @@ -92,10 +119,11 @@ export default { :design-id="$route.params.id" :noteable-id="design.id" data-testid="unresolved-discussion" + @update-active-discussion="updateActiveDesignDiscussion(discussion.notes[0].id)" /> <gl-accordion v-if="hasResolvedDiscussions" :header-level="3" class="gl-mb-5"> <gl-accordion-item - v-model="resolvedDiscussionsExpanded" + v-model="isResolvedDiscussionsExpanded" :title="resolvedDiscussionsTitle" header-class="!gl-mb-5" > @@ -107,6 +135,7 @@ export default { :noteable-id="design.id" :resolved-discussions-expanded="resolvedDiscussionsExpanded" data-testid="resolved-discussion" + @update-active-discussion="updateActiveDesignDiscussion(discussion.notes[0].id)" /> </gl-accordion-item> </gl-accordion> diff --git a/app/assets/javascripts/work_items/components/design_management/graphql/client/active_design_discussion.query.graphql b/app/assets/javascripts/work_items/components/design_management/graphql/client/active_design_discussion.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..f88e740cdff398293152030aa113baca8e519b04 --- /dev/null +++ b/app/assets/javascripts/work_items/components/design_management/graphql/client/active_design_discussion.query.graphql @@ -0,0 +1,6 @@ +query activeDesignDiscussion { + activeDesignDiscussion @client { + id + source + } +} diff --git a/app/assets/javascripts/work_items/components/design_management/graphql/client/update_active_design_discussion.mutation.graphql b/app/assets/javascripts/work_items/components/design_management/graphql/client/update_active_design_discussion.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..9a8408ed78cbb16d642a135c2b3b5dca9e70ad02 --- /dev/null +++ b/app/assets/javascripts/work_items/components/design_management/graphql/client/update_active_design_discussion.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateActiveDesignDiscussion($id: String, $source: String) { + updateActiveDesignDiscussion(id: $id, source: $source) @client +} diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index 26506ad70bfe77da6b16fac0c25ed63b5ef50743..3d67d764d70e18927a339d084b02b325ab434f7a 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -88,6 +88,16 @@ extend type Mutation { localWorkItemBulkUpdate(input: LocalWorkItemBulkUpdateInput!): LocalWorkItemBulkUpdatePayload updateNewWorkItem(input: LocalUpdateNewWorkItemInput!): LocalWorkItemPayload localUpdateWorkItem(input: LocalUpdateWorkItemInput!): LocalWorkItemPayload + updateActiveDesignDiscussion(id: ID!, source: String!): Boolean +} + +type ActiveDesignDiscussion { + id: ID + source: String +} + +extend type Query { + activeDesignDiscussion: ActiveDesignDiscussion } type LocalWorkItemChildIsExpanded { diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 5f4fa097e8cb5e7a95f26b66f0d51447a9ea0796..3ad74ce219f87ed26bdfcb8dc85076dc442ded2e 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -11,6 +11,7 @@ import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import App from './components/app.vue'; import WorkItemBreadcrumb from './components/work_item_breadcrumb.vue'; +import activeDiscussionQuery from './components/design_management/graphql/client/active_design_discussion.query.graphql'; import { createRouter } from './router'; Vue.use(VueApollo); @@ -65,6 +66,17 @@ export const initWorkItemsRoot = ({ workItemType, workspaceType } = {}) => { workItemType: listWorkItemType, }); + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: activeDiscussionQuery, + data: { + activeDesignDiscussion: { + __typename: 'ActiveDesignDiscussion', + id: null, + source: null, + }, + }, + }); + return new Vue({ el, name: 'WorkItemsRoot', diff --git a/spec/frontend/work_items/components/design_management/design_notes/design_discussion_spec.js b/spec/frontend/work_items/components/design_management/design_notes/design_discussion_spec.js index e60a32620ccd8531a27a773d09f122e083485957..f0e856c9f50d0dcb17de78c6201c2d229219ac87 100644 --- a/spec/frontend/work_items/components/design_management/design_notes/design_discussion_spec.js +++ b/spec/frontend/work_items/components/design_management/design_notes/design_discussion_spec.js @@ -3,7 +3,7 @@ import { nextTick } from 'vue'; import DesignDiscussion from '~/work_items/components/design_management/design_notes/design_discussion.vue'; import DesignNote from '~/work_items/components/design_management/design_notes/design_note.vue'; import ToggleRepliesWidget from '~/work_items/components/design_management/design_notes/toggle_replies_widget.vue'; -import notes from './mock_notes'; +import notes, { DISCUSSION_1 } from './mock_notes'; const defaultMockDiscussion = { id: '0', @@ -15,6 +15,7 @@ const defaultMockDiscussion = { describe('Design discussions component', () => { let wrapper; + const findDesignNotesList = () => wrapper.find('[data-testid="design-discussion-content"]'); const findDesignNotes = () => wrapper.findAllComponents(DesignNote); const findRepliesWidget = () => wrapper.findComponent(ToggleRepliesWidget); const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]'); @@ -159,4 +160,25 @@ describe('Design discussions component', () => { }); expect(findRepliesWidget().exists()).toBe(false); }); + + describe('active discussions', () => { + describe('when any note from a discussion is active', () => { + it.each([notes[0], notes[0].discussion.notes.nodes[1]])( + 'applies correct class to the active discussion', + (note) => { + createComponent({ + props: { discussion: DISCUSSION_1 }, + data: { + activeDesignDiscussion: { + id: note.id, + source: 'pin', + }, + }, + }); + + expect(findDesignNotesList().classes('gl-bg-blue-50')).toBe(true); + }, + ); + }); + }); }); diff --git a/spec/frontend/work_items/components/design_management/design_notes/mock_notes.js b/spec/frontend/work_items/components/design_management/design_notes/mock_notes.js index 76e3ce66f2fb7ec1088ffe871710b30d94e9c421..505cd1dac723f78aef713d889f3561021b853710 100644 --- a/spec/frontend/work_items/components/design_management/design_notes/mock_notes.js +++ b/spec/frontend/work_items/components/design_management/design_notes/mock_notes.js @@ -22,7 +22,7 @@ export const mockAwardEmoji = { ], }; -const DISCUSSION_1 = { +export const DISCUSSION_1 = { id: 'discussion-id-1', resolved: false, resolvable: true, diff --git a/spec/frontend/work_items/components/design_management/design_preview/__snapshots__/design_presentation_spec.js.snap b/spec/frontend/work_items/components/design_management/design_preview/__snapshots__/design_presentation_spec.js.snap index d021356f50c27bd626449a450a6329898c76aa2b..23e591d85dce7a5f90c8a42a16f1a9c44a4daca5 100644 --- a/spec/frontend/work_items/components/design_management/design_preview/__snapshots__/design_presentation_spec.js.snap +++ b/spec/frontend/work_items/components/design_management/design_preview/__snapshots__/design_presentation_spec.js.snap @@ -3,6 +3,7 @@ exports[`DesignPresentation renders empty state when no image provided 1`] = ` <div class="gl-h-full gl-p-5 gl-relative gl-w-full overflow-auto" + data-testid="presentation-viewport" > <div class="gl-flex gl-h-full gl-items-center gl-relative gl-w-full" @@ -13,6 +14,7 @@ exports[`DesignPresentation renders empty state when no image provided 1`] = ` exports[`DesignPresentation renders image and overlay when image provided 1`] = ` <div class="gl-h-full gl-p-5 gl-relative gl-w-full overflow-auto" + data-testid="presentation-viewport" > <div class="gl-flex gl-h-full gl-items-center gl-relative gl-w-full" @@ -22,6 +24,12 @@ exports[`DesignPresentation renders image and overlay when image provided 1`] = name="test" scale="1" /> + <design-overlay-stub + dimensions="[object Object]" + disablecommenting="true" + notes="" + position="[object Object]" + /> </div> </div> `; diff --git a/spec/frontend/work_items/components/design_management/design_preview/design_overlay_spec.js b/spec/frontend/work_items/components/design_management/design_preview/design_overlay_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5f8df417b66ade7abaa066961ce13d160401143e --- /dev/null +++ b/spec/frontend/work_items/components/design_management/design_preview/design_overlay_spec.js @@ -0,0 +1,352 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import DesignOverlay from '~/work_items/components/design_management/design_preview/design_overlay.vue'; +import { resolvers } from '~/graphql_shared/issuable_client'; +import activeDiscussionQuery from '~/work_items/components/design_management/graphql/client/active_design_discussion.query.graphql'; +import notes from '../design_notes/mock_notes'; + +Vue.use(VueApollo); + +describe('Design overlay component', () => { + let wrapper; + let apolloProvider; + + const mockDimensions = { width: 100, height: 100 }; + + const findOverlay = () => wrapper.findByTestId('design-overlay'); + const findAllNotes = () => wrapper.findAllByTestId('note-pin'); + const findCommentBadge = () => wrapper.findByTestId('comment-badge'); + const findBadgeAtIndex = (noteIndex) => findAllNotes().at(noteIndex); + const findFirstBadge = () => findBadgeAtIndex(0); + const findSecondBadge = () => findBadgeAtIndex(1); + + const clickAndDragBadge = (elem, fromPoint, toPoint) => { + elem.vm.$emit( + 'mousedown', + new MouseEvent('click', { clientX: fromPoint.x, clientY: fromPoint.y }), + ); + findOverlay().trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y }); + elem.vm.$emit('mouseup', new MouseEvent('click', { clientX: toPoint.x, clientY: toPoint.y })); + }; + + function createComponent(props = {}, data = {}) { + apolloProvider = createMockApollo([], resolvers); + apolloProvider.clients.defaultClient.writeQuery({ + query: activeDiscussionQuery, + data: { + activeDesignDiscussion: { + __typename: 'ActiveDiscussion', + id: null, + source: null, + }, + }, + }); + + wrapper = shallowMountExtended(DesignOverlay, { + apolloProvider, + propsData: { + dimensions: mockDimensions, + position: { + top: '0', + left: '0', + }, + resolvedDiscussionsExpanded: false, + ...props, + }, + data() { + return { + activeDesignDiscussion: { + id: null, + source: null, + }, + ...data, + }; + }, + }); + } + + afterEach(() => { + apolloProvider = null; + }); + + it('should have correct inline style', () => { + createComponent(); + + expect(wrapper.attributes().style).toBe('width: 100px; height: 100px; top: 0px; left: 0px;'); + }); + + it('should emit `openCommentForm` when clicking on overlay', () => { + createComponent(); + const newCoordinates = { + x: 10, + y: 10, + }; + + wrapper + .find('[data-testid="design-image-button"]') + .trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y }); + + expect(wrapper.emitted('openCommentForm')).toEqual([ + [{ x: newCoordinates.x, y: newCoordinates.y }], + ]); + }); + + describe('with notes', () => { + it('should render only the first note', () => { + createComponent({ + notes, + }); + expect(findAllNotes()).toHaveLength(1); + }); + + describe('with resolved discussions toggle expanded', () => { + beforeEach(() => { + createComponent({ + notes, + resolvedDiscussionsExpanded: true, + }); + }); + + it('should render all notes', () => { + expect(findAllNotes()).toHaveLength(notes.length); + }); + + it('should have set the correct position for each note badge', () => { + expect(findFirstBadge().props('position')).toEqual({ + left: '10px', + top: '15px', + }); + expect(findSecondBadge().props('position')).toEqual({ left: '50px', top: '50px' }); + }); + + it('should apply resolved class to the resolved note pin', () => { + expect(findSecondBadge().props('isResolved')).toBe(true); + }); + + describe('when no discussion is active', () => { + it('should not apply inactive class to any pins', () => { + expect( + findAllNotes(0).wrappers.every((designNote) => designNote.classes('gl-bg-blue-50')), + ).toBe(false); + }); + }); + + describe('when a discussion is active', () => { + it.each([notes[0].discussion.notes.nodes[1], notes[0].discussion.notes.nodes[0]])( + 'should not apply inactive class to the pin for the active discussion', + async (note) => { + apolloProvider.clients.defaultClient.writeQuery({ + query: activeDiscussionQuery, + data: { + activeDesignDiscussion: { + __typename: 'ActiveDiscussion', + id: note.id, + source: 'discussion', + }, + }, + }); + + await nextTick(); + expect(findBadgeAtIndex(0).props('isInactive')).toBe(false); + }, + ); + + it('should apply inactive class to all pins besides the active one', async () => { + apolloProvider.clients.defaultClient.writeQuery({ + query: activeDiscussionQuery, + data: { + activeDesignDiscussion: { + __typename: 'ActiveDiscussion', + id: notes[0].id, + source: 'discussion', + }, + }, + }); + + await nextTick(); + expect(findSecondBadge().props('isInactive')).toBe(true); + expect(findFirstBadge().props('isInactive')).toBe(false); + }); + }); + }); + + it('should calculate badges positions based on dimensions', () => { + createComponent({ + notes, + dimensions: { + width: 200, + height: 200, + }, + }); + + expect(findFirstBadge().props('position')).toEqual({ left: '20px', top: '30px' }); + }); + + it('should update active discussion when clicking a note without moving it', async () => { + createComponent({ + notes, + dimensions: { + width: 400, + height: 400, + }, + }); + + expect(findFirstBadge().props('isInactive')).toBe(null); + + const note = notes[0]; + const { position } = note; + + findFirstBadge().vm.$emit( + 'mousedown', + new MouseEvent('click', { clientX: position.x, clientY: position.y }), + ); + + findFirstBadge().vm.$emit( + 'mouseup', + new MouseEvent('click', { clientX: position.x, clientY: position.y }), + ); + await waitForPromises(); + expect(findFirstBadge().props('isInactive')).toBe(false); + }); + }); + + describe('when moving notes', () => { + it('should emit `moveNote` event when note-moving action ends', async () => { + createComponent({ notes }); + const note = notes[0]; + const { position } = note; + const newCoordinates = { x: 20, y: 20 }; + + const badge = findFirstBadge(); + await clickAndDragBadge(badge, { x: position.x, y: position.y }, newCoordinates); + + expect(wrapper.emitted('moveNote')).toEqual([ + [ + { + noteId: notes[0].id, + discussionId: notes[0].discussion.id, + coordinates: newCoordinates, + }, + ], + ]); + }); + + describe('without [repositionNote] permission', () => { + const mockNoteNotAuthorised = { + ...notes[0], + userPermissions: { + repositionNote: false, + }, + }; + + const mockNoteCoordinates = { + x: mockNoteNotAuthorised.position.x, + y: mockNoteNotAuthorised.position.y, + }; + + it('should be unable to move a note', async () => { + createComponent({ + dimensions: mockDimensions, + notes: [mockNoteNotAuthorised], + }); + + const badge = findAllNotes().at(0); + await clickAndDragBadge(badge, { ...mockNoteCoordinates }, { x: 20, y: 20 }); + // note position should not change after a click-and-drag attempt + expect(findFirstBadge().props('position')).toEqual({ + left: `${mockNoteCoordinates.x}px`, + top: `${mockNoteCoordinates.y}px`, + }); + }); + }); + }); + + describe('with a new form', () => { + it('should render a new comment badge', () => { + createComponent({ + currentCommentForm: { + ...notes[0].position, + }, + }); + + expect(findCommentBadge().exists()).toBe(true); + expect(findCommentBadge().props('position')).toEqual({ left: '10px', top: '15px' }); + }); + + describe('when moving the comment badge', () => { + it('should update badge style when note-moving action ends', () => { + const { position } = notes[0]; + createComponent({ + currentCommentForm: { + ...position, + }, + }); + + expect(findCommentBadge().props('position')).toEqual({ left: '10px', top: '15px' }); + + const toPoint = { x: 20, y: 20 }; + + createComponent({ + currentCommentForm: { height: position.height, width: position.width, ...toPoint }, + }); + + expect(findCommentBadge().props('position')).toEqual({ left: '20px', top: '20px' }); + }); + + it('should emit `openCommentForm` event when mouseleave fired on overlay element', async () => { + const { position } = notes[0]; + createComponent({ + notes, + currentCommentForm: { + ...position, + }, + }); + + const newCoordinates = { x: 20, y: 20 }; + + await clickAndDragBadge( + findCommentBadge(), + { x: position.x, y: position.y }, + newCoordinates, + ); + + findOverlay().vm.$emit('mouseleave'); + expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]); + }); + + it('should emit `openCommentForm` event when mouseup fired on comment badge element', async () => { + const { position } = notes[0]; + createComponent({ + notes, + currentCommentForm: { + ...position, + }, + }); + + const newCoordinates = { x: 20, y: 20 }; + + await clickAndDragBadge( + findCommentBadge(), + { x: position.x, y: position.y }, + newCoordinates, + ); + + expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]); + }); + }); + }); + + describe('when notes are disabled', () => { + it('does not render note pins', () => { + createComponent({ + notes, + disableNotes: true, + }); + + expect(findAllNotes()).toHaveLength(0); + }); + }); +}); diff --git a/spec/frontend/work_items/components/design_management/design_preview/design_presentation_spec.js b/spec/frontend/work_items/components/design_management/design_preview/design_presentation_spec.js index 06feb7f88e77e953ccdf14d699321d2550f611e5..64661ddf2a41f1b55ad3ff31cf86403f3546246d 100644 --- a/spec/frontend/work_items/components/design_management/design_preview/design_presentation_spec.js +++ b/spec/frontend/work_items/components/design_management/design_preview/design_presentation_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import DesignPresentation from '~/work_items/components/design_management/design_preview/design_presentation.vue'; import DesignImage from '~/work_items/components/design_management/design_preview/image.vue'; +import DesignOverlay from '~/work_items/components/design_management/design_preview/design_overlay.vue'; const mockOverlayDimensions = { width: 100, @@ -12,6 +13,9 @@ describe('DesignPresentation', () => { let wrapper; const findDesignImage = () => wrapper.findComponent(DesignImage); + const findDesignOverlay = () => wrapper.findComponent(DesignOverlay); + const findPresentationViewport = () => + wrapper.find('[data-testid="presentation-viewport"]').element; function createComponent(props = {}, initialOverlayDimensions = mockOverlayDimensions, options) { wrapper = shallowMount(DesignPresentation, { @@ -200,7 +204,7 @@ describe('DesignPresentation', () => { jest.spyOn(wrapper.vm, 'shiftZoomFocalPoint'); jest.spyOn(wrapper.vm, 'scaleZoomFocalPoint'); jest.spyOn(wrapper.vm, 'scrollToFocalPoint'); - wrapper.vm.onImageResize({ width: 10, height: 10 }); + findDesignImage().vm.$emit('resize', { width: 10, height: 10 }); await nextTick(); }); @@ -209,11 +213,60 @@ describe('DesignPresentation', () => { expect(wrapper.vm.initialLoad).toBe(false); }); - it('calls scaleZoomFocalPoint and scrollToFocalPoint after initial load', async () => { - wrapper.vm.onImageResize({ width: 10, height: 10 }); + it('scrolls to focal point after initial load', async () => { + const scrollToSpy = jest.spyOn(findPresentationViewport(), 'scrollTo'); + + findDesignImage().vm.$emit('resize', { width: 10, height: 10 }); + await nextTick(); + expect(scrollToSpy).toHaveBeenCalledWith(0, 0); + }); + }); + + describe('setOverlayPosition', () => { + beforeEach(() => { + createComponent({ + image: 'test.jpg', + imageName: 'test', + }); + }); + + it('sets overlay position correctly when overlay is smaller than viewport', async () => { + Object.defineProperty(findPresentationViewport(), 'offsetWidth', { value: 200 }); + Object.defineProperty(findPresentationViewport(), 'offsetHeight', { value: 200 }); + + findDesignImage().vm.$emit('resize', { width: 100, height: 100 }); + await nextTick(); - expect(wrapper.vm.scaleZoomFocalPoint).toHaveBeenCalled(); - expect(wrapper.vm.scrollToFocalPoint).toHaveBeenCalled(); + expect(findDesignOverlay().props('position')).toEqual({ + left: `calc(50% - ${mockOverlayDimensions.width / 2}px)`, + top: `calc(50% - ${mockOverlayDimensions.height / 2}px)`, + }); + }); + + it('sets overlay position correctly when overlay width is larger than viewports', async () => { + Object.defineProperty(findPresentationViewport(), 'offsetWidth', { value: 50 }); + Object.defineProperty(findPresentationViewport(), 'offsetHeight', { value: 200 }); + + findDesignImage().vm.$emit('resize', { width: 100, height: 100 }); + + await nextTick(); + expect(findDesignOverlay().props('position')).toEqual({ + left: `0`, + top: `calc(50% - ${mockOverlayDimensions.height / 2}px)`, + }); + }); + + it('sets overlay position correctly when overlay height is larger than viewports', async () => { + Object.defineProperty(findPresentationViewport(), 'offsetWidth', { value: 200 }); + Object.defineProperty(findPresentationViewport(), 'offsetHeight', { value: 50 }); + + findDesignImage().vm.$emit('resize', { width: 100, height: 100 }); + + await nextTick(); + expect(findDesignOverlay().props('position')).toEqual({ + left: `calc(50% - ${mockOverlayDimensions.width / 2}px)`, + top: '0', + }); }); }); }); diff --git a/spec/frontend/work_items/components/design_management/design_preview/design_sidebar_spec.js b/spec/frontend/work_items/components/design_management/design_preview/design_sidebar_spec.js index 20322ac57329617a2b23b2f6ab76595606a1224e..1ac7ff9294db7506a6c2f1f844164f3e7043d4f3 100644 --- a/spec/frontend/work_items/components/design_management/design_preview/design_sidebar_spec.js +++ b/spec/frontend/work_items/components/design_management/design_preview/design_sidebar_spec.js @@ -1,5 +1,8 @@ import { GlSkeletonLoader, GlEmptyState, GlAccordionItem } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import emptyDiscussionUrl from '@gitlab/svgs/dist/illustrations/empty-state/empty-activity-md.svg'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import DesignSidebar from '~/work_items/components/design_management/design_preview/design_sidebar.vue'; import DesignDescription from '~/work_items/components/design_management/design_preview/design_description.vue'; @@ -7,6 +10,8 @@ import DesignDisclosure from '~/vue_shared/components/design_management/design_d import DesignDiscussion from '~/work_items/components/design_management/design_notes/design_discussion.vue'; import mockDesign from './mock_design'; +Vue.use(VueApollo); + describe('DesignSidebar', () => { let wrapper; @@ -22,20 +27,30 @@ describe('DesignSidebar', () => { const findDisclosure = () => wrapper.findAllComponents(DesignDisclosure); const findDesignDescription = () => wrapper.findComponent(DesignDescription); const findDiscussions = () => wrapper.findAllComponents(DesignDiscussion); + const findFirstDiscussion = () => findDiscussions().at(0); const findUnresolvedDiscussions = () => wrapper.findAllByTestId('unresolved-discussion'); const findResolvedDiscussions = () => wrapper.findAllByTestId('resolved-discussion'); const findUnresolvedDiscussionsCount = () => wrapper.findByTestId('unresolved-discussion-count'); const findResolvedCommentsToggle = () => wrapper.findComponent(GlAccordionItem); + const mockUpdateActiveDiscussionMutationResolver = jest.fn(); + const mockApollo = createMockApollo([], { + Mutation: { + updateActiveDesignDiscussion: mockUpdateActiveDiscussionMutationResolver, + }, + }); + function createComponent({ design = mockDesign, isLoading = false, isLoggedIn = true } = {}) { if (isLoggedIn) { window.gon.current_user_id = 1; } wrapper = shallowMountExtended(DesignSidebar, { + apolloProvider: mockApollo, propsData: { design, isLoading, isOpen: true, + resolvedDiscussionsExpanded: false, }, mocks: { $route, @@ -156,5 +171,35 @@ describe('DesignSidebar', () => { it('has resolved comments accordion item collapsed', () => { expect(findResolvedCommentsToggle().props('visible')).toBe(false); }); + + it('emits toggleResolveComments event on resolve comments button click', async () => { + findResolvedCommentsToggle().vm.$emit('input', true); + await nextTick(); + expect(wrapper.emitted('toggleResolvedComments')).toHaveLength(1); + }); + + it('emits correct event to send a mutation to set an active discussion when clicking on a discussion', async () => { + findFirstDiscussion().vm.$emit('update-active-discussion'); + await nextTick(); + + expect(mockUpdateActiveDiscussionMutationResolver).toHaveBeenCalledWith( + expect.any(Object), + { id: mockDesign.discussions.nodes[0].notes.nodes[0].id, source: 'discussion' }, + expect.any(Object), + expect.any(Object), + ); + }); + + it('sends a mutation to reset an active discussion when clicking outside of discussion', async () => { + wrapper.find('.image-notes').trigger('click'); + await nextTick(); + + expect(mockUpdateActiveDiscussionMutationResolver).toHaveBeenCalledWith( + expect.any(Object), + { id: undefined, source: 'discussion' }, + expect.any(Object), + expect.any(Object), + ); + }); }); });