Commit d417f575 authored by Natalia Tepluhina's avatar Natalia Tepluhina Committed by Nicolò Maria Mezzopera

Implement resolving design comments

- resolve discussion on click
- resolve discussion on comment with checkbox
- toggle resolved discussions
- toggle comments for resolved discussions
- change resolved discussions styles
parent bcf3d865
......@@ -13,7 +13,7 @@ export default {
required: true,
},
label: {
type: String,
type: Number,
required: false,
default: null,
},
......
<script>
import { ApolloMutation } from 'vue-apollo';
import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import allVersionsMixin from '../../mixins/all_versions';
import createNoteMutation from '../../graphql/mutations/createNote.mutation.graphql';
import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql';
import getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql';
import DesignNote from './design_note.vue';
import DesignReplyForm from './design_reply_form.vue';
import { updateStoreAfterAddDiscussionComment } from '../../utils/cache_update';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
import ToggleRepliesWidget from './toggle_replies_widget.vue';
export default {
components: {
......@@ -16,6 +21,14 @@ export default {
DesignNote,
ReplyPlaceholder,
DesignReplyForm,
GlIcon,
GlLoadingIcon,
GlLink,
ToggleRepliesWidget,
TimeAgoTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [allVersionsMixin],
props: {
......@@ -31,15 +44,15 @@ export default {
type: String,
required: true,
},
discussionIndex: {
type: Number,
required: true,
},
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
resolvedDiscussionsExpanded: {
type: Boolean,
required: true,
},
},
apollo: {
activeDiscussion: {
......@@ -49,6 +62,7 @@ export default {
// 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.resolvedDiscussionsExpanded &&
discussionId &&
data.activeDiscussion.source === ACTIVE_DISCUSSION_SOURCE_TYPES.pin &&
discussionId === this.discussion.notes[0].id
......@@ -66,6 +80,9 @@ export default {
discussionComment: '',
isFormRendered: false,
activeDiscussion: {},
isResolving: false,
shouldChangeResolvedStatus: false,
areRepliesCollapsed: this.discussion.resolved,
};
},
computed: {
......@@ -87,6 +104,29 @@ export default {
isDiscussionHighlighted() {
return this.discussion.notes[0].id === this.activeDiscussion.id;
},
resolveCheckboxText() {
return this.discussion.resolved
? s__('DesignManagement|Unresolve thread')
: s__('DesignManagement|Resolve thread');
},
firstNote() {
return this.discussion.notes[0];
},
discussionReplies() {
return this.discussion.notes.slice(1);
},
areRepliesShown() {
return !this.discussion.resolved || !this.areRepliesCollapsed;
},
resolveIconName() {
return this.discussion.resolved ? 'check-circle-filled' : 'check-circle';
},
isRepliesWidgetVisible() {
return this.discussion.resolved && this.discussionReplies.length > 0;
},
isReplyPlaceholderVisible() {
return this.areRepliesShown || !this.discussionReplies.length;
},
},
methods: {
addDiscussionComment(
......@@ -106,9 +146,12 @@ export default {
onDone() {
this.discussionComment = '';
this.hideForm();
if (this.shouldChangeResolvedStatus) {
this.toggleResolvedStatus();
}
},
onError(err) {
this.$emit('error', err);
onCreateNoteError(err) {
this.$emit('createNoteError', err);
},
hideForm() {
this.isFormRendered = false;
......@@ -123,6 +166,25 @@ export default {
}
this.isFormRendered = false;
},
toggleResolvedStatus() {
this.isResolving = true;
this.$apollo
.mutate({
mutation: toggleResolveDiscussionMutation,
variables: { id: this.discussion.id, resolve: !this.discussion.resolved },
})
.then(({ data }) => {
if (data.errors?.length > 0) {
this.$emit('resolveDiscussionError', data.errors[0]);
}
})
.catch(err => {
this.$emit('resolveDiscussionError', err);
})
.finally(() => {
this.isResolving = false;
});
},
},
createNoteMutation,
};
......@@ -130,20 +192,69 @@ export default {
<template>
<div class="design-discussion-wrapper">
<div class="badge badge-pill" type="button">{{ discussionIndex }}</div>
<div
class="design-discussion bordered-box position-relative"
class="badge badge-pill gl-display-flex gl-align-items-center gl-justify-content-center"
:class="{ resolved: discussion.resolved }"
type="button"
>
{{ discussion.index }}
</div>
<ul
class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none"
data-qa-selector="design_discussion_content"
>
<design-note
v-for="note in discussion.notes"
:note="firstNote"
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
:class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
@error="$emit('updateNoteError', $event)"
>
<template v-if="discussion.resolvable" #resolveDiscussion>
<button
v-gl-tooltip
:class="{ 'is-active': discussion.resolved }"
:title="resolveCheckboxText"
:aria-label="resolveCheckboxText"
type="button"
class="line-resolve-btn note-action-button gl-mr-3"
data-testid="resolve-button"
@click.stop="toggleResolvedStatus"
>
<gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" />
<gl-loading-icon v-else inline />
</button>
</template>
<template v-if="discussion.resolved" #resolvedStatus>
<p class="gl-text-gray-700 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message">
{{ __('Resolved by') }}
<gl-link
class="gl-text-gray-700 gl-text-decoration-none gl-font-sm link-inherit-color"
:href="discussion.resolvedBy.webUrl"
target="_blank"
>{{ discussion.resolvedBy.name }}</gl-link
>
<time-ago-tooltip :time="discussion.resolvedAt" tooltip-placement="bottom" />
</p>
</template>
</design-note>
<toggle-replies-widget
v-if="isRepliesWidgetVisible"
:collapsed="areRepliesCollapsed"
:replies="discussionReplies"
@toggle="areRepliesCollapsed = !areRepliesCollapsed"
/>
<design-note
v-for="note in discussionReplies"
v-show="areRepliesShown"
:key="note.id"
:note="note"
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
:class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
@error="$emit('updateNoteError', $event)"
/>
<div class="reply-wrapper">
<li v-show="isReplyPlaceholderVisible" class="reply-wrapper">
<reply-placeholder
v-if="!isFormRendered"
class="qa-discussion-reply"
......@@ -159,7 +270,7 @@ export default {
}"
:update="addDiscussionComment"
@done="onDone"
@error="onError"
@error="onCreateNoteError"
>
<design-reply-form
v-model="discussionComment"
......@@ -168,9 +279,16 @@ export default {
@submitForm="mutate"
@cancelForm="hideForm"
@onBlur="handleReplyFormBlur"
/>
>
<template v-if="discussion.resolvable" #resolveCheckbox>
<label data-testid="resolve-checkbox">
<input v-model="shouldChangeResolvedStatus" type="checkbox" />
{{ resolveCheckboxText }}
</label>
</template>
</design-reply-form>
</apollo-mutation>
</div>
</div>
</li>
</ul>
</div>
</template>
......@@ -54,6 +54,9 @@ export default {
body: this.noteText,
};
},
isEditButtonVisible() {
return !this.isEditing && this.note.userPermissions.adminNote;
},
},
mounted() {
if (this.isNoteLinked) {
......@@ -107,23 +110,28 @@ export default {
</template>
</span>
</div>
<button
v-if="!isEditing && note.userPermissions.adminNote"
v-gl-tooltip
type="button"
title="Edit comment"
class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
@click="isEditing = true"
>
<gl-icon name="pencil" class="link-highlight" />
</button>
<div class="gl-display-flex">
<slot name="resolveDiscussion"></slot>
<button
v-if="isEditButtonVisible"
v-gl-tooltip
type="button"
:title="__('Edit comment')"
class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
@click="isEditing = true"
>
<gl-icon name="pencil" class="link-highlight" />
</button>
</div>
</div>
<div
v-if="!isEditing"
class="note-text js-note-text md"
data-qa-selector="note_content"
v-html="note.bodyHtml"
></div>
<template v-if="!isEditing">
<div
class="note-text js-note-text md"
data-qa-selector="note_content"
v-html="note.bodyHtml"
></div>
<slot name="resolvedStatus"></slot>
</template>
<apollo-mutation
v-else
#default="{ mutate, loading }"
......
......@@ -108,7 +108,8 @@ export default {
</textarea>
</template>
</markdown-field>
<div class="note-form-actions d-flex justify-content-between">
<slot name="resolveCheckbox"></slot>
<div class="note-form-actions gl-display-flex gl-justify-content-space-between">
<gl-deprecated-button
ref="submitButton"
:disabled="!hasValue || isSaving"
......
<script>
import { GlIcon, GlButton, GlLink } from '@gitlab/ui';
import { __, n__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'ToggleNotesWidget',
components: {
GlIcon,
GlButton,
GlLink,
TimeAgoTooltip,
},
props: {
collapsed: {
type: Boolean,
required: true,
},
replies: {
type: Array,
required: true,
},
},
computed: {
lastReply() {
return this.replies[this.replies.length - 1];
},
iconName() {
return this.collapsed ? 'chevron-right' : 'chevron-down';
},
toggleText() {
return this.collapsed
? `${this.replies.length} ${n__('reply', 'replies', this.replies.length)}`
: __('Collapse replies');
},
},
};
</script>
<template>
<li
class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3"
:class="{ expanded: !collapsed }"
data-testid="toggle-comments-wrapper"
>
<gl-icon :name="iconName" class="gl-ml-3" @click.stop="$emit('toggle')" />
<gl-button
variant="link"
class="toggle-comments-button gl-ml-2 gl-mr-2"
@click.stop="$emit('toggle')"
>
{{ toggleText }}
</gl-button>
<template v-if="collapsed">
<span class="gl-text-gray-700">{{ __('Last reply by') }}</span>
<gl-link
:href="lastReply.author.webUrl"
target="_blank"
class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2"
>
{{ lastReply.author.name }}
</gl-link>
<time-ago-tooltip
:time="lastReply.createdAt"
tooltip-placement="bottom"
class="gl-text-gray-700"
/>
</template>
</li>
</template>
......@@ -33,6 +33,10 @@ export default {
required: false,
default: false,
},
resolvedDiscussionsExpanded: {
type: Boolean,
required: true,
},
},
apollo: {
activeDiscussion: {
......@@ -236,6 +240,9 @@ export default {
isNoteInactive(note) {
return this.activeDiscussion.id && this.activeDiscussion.id !== note.id;
},
designPinClass(note) {
return { inactive: this.isNoteInactive(note), resolved: note.resolved };
},
},
};
</script>
......@@ -254,20 +261,23 @@ export default {
data-qa-selector="design_image_button"
@mouseup="onAddCommentMouseup"
></button>
<design-note-pin
v-for="(note, index) in notes"
:key="note.id"
:label="`${index + 1}`"
:repositioning="isMovingNote(note.id)"
:position="
isMovingNote(note.id) && movingNoteNewPosition
? getNotePositionStyle(movingNoteNewPosition)
: getNotePositionStyle(note.position)
"
:class="{ inactive: isNoteInactive(note) }"
@mousedown.stop="onNoteMousedown($event, note)"
@mouseup.stop="onNoteMouseup(note)"
/>
<template v-for="note in notes">
<design-note-pin
v-if="resolvedDiscussionsExpanded || !note.resolved"
:key="note.id"
:label="note.index"
:repositioning="isMovingNote(note.id)"
:position="
isMovingNote(note.id) && movingNoteNewPosition
? getNotePositionStyle(movingNoteNewPosition)
: getNotePositionStyle(note.position)
"
:class="designPinClass(note)"
@mousedown.stop="onNoteMousedown($event, note)"
@mouseup.stop="onNoteMouseup(note)"
/>
</template>
<design-note-pin
v-if="currentCommentForm"
:position="currentCommentPositionStyle"
......
......@@ -35,6 +35,10 @@ export default {
required: false,
default: 1,
},
resolvedDiscussionsExpanded: {
type: Boolean,
required: true,
},
},
data() {
return {
......@@ -54,7 +58,10 @@ export default {
},
computed: {
discussionStartingNotes() {
return this.discussions.map(discussion => discussion.notes[0]);
return this.discussions.map(discussion => ({
...discussion.notes[0],
index: discussion.index,
}));
},
currentCommentForm() {
return (this.isAnnotating && this.currentAnnotationPosition) || null;
......@@ -305,6 +312,7 @@ export default {
:notes="discussionStartingNotes"
:current-comment-form="currentCommentForm"
:disable-commenting="isDraggingDesign"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
@openCommentForm="openCommentForm"
@closeCommentForm="closeCommentForm"
@moveNote="moveNote"
......
<script>
import { s__ } from '~/locale';
import Cookies from 'js-cookie';
import { parseBoolean } from '~/lib/utils/common_utils';
import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui';
import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
import { extractDiscussions, extractParticipants } from '../utils/design_management_utils';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
import DesignDiscussion from './design_notes/design_discussion.vue';
import Participants from '~/sidebar/components/participants/participants.vue';
export default {
components: {
DesignDiscussion,
Participants,
GlCollapse,
GlButton,
GlPopover,
},
props: {
design: {
type: Object,
required: true,
},
resolvedDiscussionsExpanded: {
type: Boolean,
required: true,
},
markdownPreviewPath: {
type: String,
required: true,
},
},
data() {
return {
isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)),
};
},
computed: {
discussions() {
return extractDiscussions(this.design.discussions);
},
issue() {
return {
...this.design.issue,
webPath: this.design.issue.webPath.substr(1),
};
},
discussionParticipants() {
return extractParticipants(this.issue.participants);
},
resolvedDiscussions() {
return this.discussions.filter(discussion => discussion.resolved);
},
unresolvedDiscussions() {
return this.discussions.filter(discussion => !discussion.resolved);
},
resolvedCommentsToggleIcon() {
return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right';
},
},
methods: {
handleSidebarClick() {
this.isResolvedCommentsPopoverHidden = true;
Cookies.set(this.$options.cookieKey, 'true', { expires: 365 * 10 });
this.updateActiveDiscussion();
},
updateActiveDiscussion(id) {
this.$apollo.mutate({
mutation: updateActiveDiscussionMutation,
variables: {
id,
source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion,
},
});
},
closeCommentForm() {
this.comment = '';
this.$emit('closeCommentForm');
},
},
resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
cookieKey: 'hide_design_resolved_comments_popover',
};
</script>
<template>
<div class="image-notes" @click="handleSidebarClick">
<h2 class="gl-font-weight-bold gl-mt-0">
{{ issue.title }}
</h2>
<a
class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block"
:href="issue.webUrl"
>{{ issue.webPath }}</a
>
<participants
:participants="discussionParticipants"
:show-participant-label="false"
class="gl-mb-4"
/>
<h2
v-if="unresolvedDiscussions.length === 0"
class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4"
data-testid="new-discussion-disclaimer"
>
{{ s__("DesignManagement|Click the image where you'd like to start a new discussion") }}
</h2>
<design-discussion
v-for="discussion in unresolvedDiscussions"
:key="discussion.id"
:discussion="discussion"
:design-id="$route.params.id"
:noteable-id="design.id"
:markdown-preview-path="markdownPreviewPath"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
data-testid="unresolved-discussion"
@createNoteError="$emit('onDesignDiscussionError', $event)"
@updateNoteError="$emit('updateNoteError', $event)"
@resolveDiscussionError="$emit('resolveDiscussionError', $event)"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
/>
<template v-if="resolvedDiscussions.length > 0">
<gl-button
id="resolved-comments"
data-testid="resolved-comments"
:icon="resolvedCommentsToggleIcon"
variant="link"
class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4"
@click="$emit('toggleResolvedComments')"
>{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }})
</gl-button>
<gl-popover
v-if="!isResolvedCommentsPopoverHidden"
:show="!isResolvedCommentsPopoverHidden"
target="resolved-comments"
container="popovercontainer"
placement="top"
:title="s__('DesignManagement|Resolved Comments')"
>
<p>
{{
s__(
'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below',
)
}}
</p>
<a href="#" rel="noopener noreferrer" target="_blank">{{
s__('DesignManagement|Learn more about resolving comments')
}}</a>
</gl-popover>
<gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3">
<design-discussion
v-for="discussion in resolvedDiscussions"
:key="discussion.id"
:discussion="discussion"
:design-id="$route.params.id"
:noteable-id="design.id"
:markdown-preview-path="markdownPreviewPath"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
data-testid="resolved-discussion"
@error="$emit('onDesignDiscussionError', $event)"
@updateNoteError="$emit('updateNoteError', $event)"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
/>
</gl-collapse>
</template>
<slot name="replyForm"></slot>
</div>
</template>
#import "./designNote.fragment.graphql"
#import "./designList.fragment.graphql"
#import "./diffRefs.fragment.graphql"
#import "./discussion_resolved_status.fragment.graphql"
fragment DesignItem on Design {
...DesignListItem
......@@ -12,6 +13,7 @@ fragment DesignItem on Design {
nodes {
id
replyId
...ResolvedStatus
notes {
nodes {
...DesignNote
......
......@@ -10,6 +10,7 @@ fragment DesignNote on Note {
body
bodyHtml
createdAt
resolved
position {
diffRefs {
...DesignDiffRefs
......
fragment ResolvedStatus on Discussion {
resolvable
resolved
resolvedAt
resolvedBy {
name
webUrl
}
}
#import "../fragments/designNote.fragment.graphql"
#import "../fragments/discussion_resolved_status.fragment.graphql"
mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) {
discussionToggleResolve(input: { id: $id, resolve: $resolve }) {
discussion {
id
...ResolvedStatus
notes {
nodes {
...DesignNote
}
}
}
errors
}
}
<script>
import { ApolloMutation } from 'vue-apollo';
import Mousetrap from 'mousetrap';
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import allVersionsMixin from '../../mixins/all_versions';
import Toolbar from '../../components/toolbar/index.vue';
import DesignDiscussion from '../../components/design_notes/design_discussion.vue';
import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
import DesignDestroyer from '../../components/design_destroyer.vue';
import DesignScaler from '../../components/design_scaler.vue';
import Participants from '~/sidebar/components/participants/participants.vue';
import DesignPresentation from '../../components/design_presentation.vue';
import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
import DesignSidebar from '../../components/design_sidebar.vue';
import getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
import appDataQuery from '../../graphql/queries/appData.query.graphql';
import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql';
......@@ -20,7 +19,6 @@ import updateActiveDiscussionMutation from '../../graphql/mutations/update_activ
import {
extractDiscussions,
extractDesign,
extractParticipants,
updateImageDiffNoteOptimisticResponse,
} from '../../utils/design_management_utils';
import {
......@@ -43,15 +41,14 @@ import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
export default {
components: {
ApolloMutation,
DesignReplyForm,
DesignPresentation,
DesignDiscussion,
DesignScaler,
DesignDestroyer,
Toolbar,
DesignReplyForm,
GlLoadingIcon,
GlAlert,
Participants,
DesignSidebar,
},
mixins: [allVersionsMixin],
props: {
......@@ -69,6 +66,7 @@ export default {
errorMessage: '',
issueIid: '',
scale: 1,
resolvedDiscussionsExpanded: false,
};
},
apollo: {
......@@ -103,20 +101,17 @@ export default {
return this.$apollo.queries.design.loading && !this.design.filename;
},
discussions() {
if (!this.design.discussions) {
return [];
}
return extractDiscussions(this.design.discussions);
},
discussionParticipants() {
return extractParticipants(this.design.issue.participants);
},
markdownPreviewPath() {
return `/${this.projectPath}/preview_markdown?target_type=Issue`;
},
isSubmitButtonDisabled() {
return this.comment.trim().length === 0;
},
renderDiscussions() {
return this.discussions.length || this.annotationCoordinates;
},
designVariables() {
return {
fullPath: this.projectPath,
......@@ -144,15 +139,19 @@ export default {
},
};
},
issue() {
return {
...this.design.issue,
webPath: this.design.issue.webPath.substr(1),
};
},
isAnnotating() {
return Boolean(this.annotationCoordinates);
},
resolvedDiscussions() {
return this.discussions.filter(discussion => discussion.resolved);
},
},
watch: {
resolvedDiscussions(val) {
if (!val.length) {
this.resolvedDiscussionsExpanded = false;
}
},
},
mounted() {
Mousetrap.bind('esc', this.closeDesign);
......@@ -250,6 +249,9 @@ export default {
onDesignDeleteError(e) {
this.onError(designDeletionError({ singular: true }), e);
},
onResolveDiscussionError(e) {
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
},
openCommentForm(annotationCoordinates) {
this.annotationCoordinates = annotationCoordinates;
},
......@@ -281,6 +283,9 @@ export default {
},
});
},
toggleResolvedComments() {
this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded;
},
},
createImageDiffNoteMutation,
DESIGNS_ROUTE_NAME,
......@@ -323,6 +328,7 @@ export default {
:discussions="discussions"
:is-annotating="isAnnotating"
:scale="scale"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
@openCommentForm="openCommentForm"
@closeCommentForm="closeCommentForm"
@moveNote="onMoveNote"
......@@ -332,33 +338,19 @@ export default {
<design-scaler @scale="scale = $event" />
</div>
</div>
<div class="image-notes" @click="updateActiveDiscussion()">
<h2 class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0">
{{ issue.title }}
</h2>
<a class="text-tertiary text-decoration-none mb-3 d-block" :href="issue.webUrl">{{
issue.webPath
}}</a>
<participants
:participants="discussionParticipants"
:show-participant-label="false"
class="mb-4"
/>
<template v-if="renderDiscussions">
<design-discussion
v-for="(discussion, index) in discussions"
:key="discussion.id"
:discussion="discussion"
:design-id="id"
:noteable-id="design.id"
:discussion-index="index + 1"
:markdown-preview-path="markdownPreviewPath"
@error="onDesignDiscussionError"
@updateNoteError="onUpdateNoteError"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
/>
<design-sidebar
:design="design"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
:markdown-preview-path="markdownPreviewPath"
@onDesignDiscussionError="onDesignDiscussionError"
@onCreateImageDiffNoteError="onCreateImageDiffNoteError"
@updateNoteError="onUpdateNoteError"
@resolveDiscussionError="onResolveDiscussionError"
@toggleResolvedComments="toggleResolvedComments"
>
<template #replyForm>
<apollo-mutation
v-if="annotationCoordinates"
v-if="isAnnotating"
#default="{ mutate, loading }"
:mutation="$options.createImageDiffNoteMutation"
:variables="{
......@@ -374,13 +366,9 @@ export default {
:markdown-preview-path="markdownPreviewPath"
@submitForm="mutate"
@cancelForm="closeCommentForm"
/>
</apollo-mutation>
</template>
<h2 v-else class="new-discussion-disclaimer gl-font-base m-0">
{{ __("Click the image where you'd like to start a new discussion") }}
</h2>
</div>
/> </apollo-mutation
></template>
</design-sidebar>
</template>
</div>
</template>
......@@ -95,6 +95,10 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) =
__typename: 'Discussion',
id: createImageDiffNote.note.discussion.id,
replyId: createImageDiffNote.note.discussion.replyId,
resolvable: true,
resolved: false,
resolvedAt: null,
resolvedBy: null,
notes: {
__typename: 'NoteConnection',
nodes: [createImageDiffNote.note],
......
......@@ -21,8 +21,9 @@ export const extractNodes = elements => elements.edges.map(({ node }) => node);
*/
export const extractDiscussions = discussions =>
discussions.nodes.map(discussion => ({
discussions.nodes.map((discussion, index) => ({
...discussion,
index: index + 1,
notes: discussion.notes.nodes,
}));
......
......@@ -20,6 +20,20 @@
}
}
}
.badge.badge-pill {
display: flex;
height: 28px;
width: 28px;
background-color: $blue-400;
color: $white;
border: $white 1px solid;
border-radius: 50%;
&.resolved {
background-color: $gray-700;
}
}
}
.design-presentation-wrapper {
......@@ -52,14 +66,31 @@
min-width: 400px;
flex-basis: 28%;
.link-inherit-color {
&:hover,
&:active,
&:focus {
color: inherit;
text-decoration: none;
}
}
.toggle-comments {
line-height: 20px;
border-top: 1px solid $border-color;
&.expanded {
border-bottom: 1px solid $border-color;
}
.toggle-comments-button:focus {
text-decoration: none;
color: $blue-600;
}
}
.badge.badge-pill {
margin-left: $gl-padding;
background-color: $blue-400;
color: $white;
border: $white 1px solid;
min-height: 28px;
padding: 7px 10px;
border-radius: $gl-padding;
}
.design-discussion {
......
---
title: "[Frontend] Resolvable design discussions"
merge_request: 32399
author:
type: added
......@@ -177,6 +177,22 @@ Different discussions have different pin numbers:
From GitLab 12.5 on, new discussions will be outputted to the issue activity,
so that everyone involved can participate in the discussion.
## Resolve Design threads
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13049) in GitLab 13.1.
Discussion threads can be resolved on Designs. You can mark a thread as resolved
or unresolved by clicking the **Resolve thread** icon at the first comment of the
discussion.
![Resolve thread icon](img/resolve_design-discussion_icon_v13_1.png)
Pinned comments can also be resolved or unresolved in their threads.
When replying to a comment, you will see a checkbox that you can click in order to resolve or unresolve
the thread once published.
![Resolve checkbox](img/resolve_design-discussion_checkbox_v13_1.png)
## Referring to designs in Markdown
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217160) in **GitLab 13.1**.
......
......@@ -4451,9 +4451,6 @@ msgstr ""
msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
msgstr ""
msgid "Click the image where you'd like to start a new discussion"
msgstr ""
msgid "Click to expand it."
msgstr ""
......@@ -5528,6 +5525,9 @@ msgstr ""
msgid "Collapse child epics"
msgstr ""
msgid "Collapse replies"
msgstr ""
msgid "Collapse sidebar"
msgstr ""
......@@ -7519,9 +7519,15 @@ msgstr ""
msgid "DesignManagement|Cancel comment update confirmation"
msgstr ""
msgid "DesignManagement|Click the image where you'd like to start a new discussion"
msgstr ""
msgid "DesignManagement|Comment"
msgstr ""
msgid "DesignManagement|Comments you resolve can be viewed and unresolved by going to the \"Resolved Comments\" section below"
msgstr ""
msgid "DesignManagement|Could not add a new comment. Please try again."
msgstr ""
......@@ -7567,9 +7573,18 @@ msgstr ""
msgid "DesignManagement|Keep comment"
msgstr ""
msgid "DesignManagement|Learn more about resolving comments"
msgstr ""
msgid "DesignManagement|Requested design version does not exist. Showing latest version instead"
msgstr ""
msgid "DesignManagement|Resolve thread"
msgstr ""
msgid "DesignManagement|Resolved Comments"
msgstr ""
msgid "DesignManagement|Save comment"
msgstr ""
......@@ -7582,6 +7597,9 @@ msgstr ""
msgid "DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance."
msgstr ""
msgid "DesignManagement|Unresolve thread"
msgstr ""
msgid "DesignManagement|Upload designs"
msgstr ""
......@@ -18657,6 +18675,9 @@ msgstr ""
msgid "Resolved all discussions."
msgstr ""
msgid "Resolved by"
msgstr ""
msgid "Resolved by %{name}"
msgstr ""
......
......@@ -26,7 +26,7 @@ describe('Design note pin component', () => {
});
it('should match the snapshot of note with index', () => {
createComponent({ label: '1' });
createComponent({ label: 1 });
expect(wrapper.element).toMatchSnapshot();
});
......
......@@ -50,12 +50,18 @@ exports[`Design note component should match the snapshot 1`] = `
</span>
</div>
<!---->
<div
class="gl-display-flex"
>
<!---->
</div>
</div>
<div
class="note-text js-note-text md"
data-qa-selector="note_content"
/>
</timeline-entry-item-stub>
`;
import { shallowMount } from '@vue/test-utils';
import { ApolloMutation } from 'vue-apollo';
import { mount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import notes from '../../mock_data/notes';
import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
import DesignNote from '~/design_management/components/design_notes/design_note.vue';
import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
import createNoteMutation from '~/design_management/graphql/mutations/createNote.mutation.graphql';
import toggleResolveDiscussionMutation from '~/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import ToggleRepliesWidget from '~/design_management/components/design_notes/toggle_replies_widget.vue';
const discussion = {
id: '0',
resolved: false,
resolvable: true,
notes,
};
describe('Design discussions component', () => {
let wrapper;
const findDesignNotes = () => wrapper.findAll(DesignNote);
const findReplyPlaceholder = () => wrapper.find(ReplyPlaceholder);
const findReplyForm = () => wrapper.find(DesignReplyForm);
const findRepliesWidget = () => wrapper.find(ToggleRepliesWidget);
const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]');
const findResolveIcon = () => wrapper.find('[data-testid="resolve-icon"]');
const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]');
const findResolveLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]');
const mutationVariables = {
mutation: createNoteMutation,
......@@ -29,19 +46,10 @@ describe('Design discussions component', () => {
};
function createComponent(props = {}, data = {}) {
wrapper = shallowMount(DesignDiscussion, {
wrapper = mount(DesignDiscussion, {
propsData: {
discussion: {
id: '0',
notes: [
{
id: '1',
},
{
id: '2',
},
],
},
resolvedDiscussionsExpanded: true,
discussion,
noteableId: 'noteable-id',
designId: 'design-id',
discussionIndex: 1,
......@@ -52,11 +60,12 @@ describe('Design discussions component', () => {
...data,
};
},
stubs: {
ReplyPlaceholder,
ApolloMutation,
mocks: {
$apollo,
$route: {
hash: '#note_1',
},
},
mocks: { $apollo },
});
}
......@@ -64,14 +73,139 @@ describe('Design discussions component', () => {
wrapper.destroy();
});
it('renders correct amount of discussion notes', () => {
createComponent();
expect(wrapper.findAll(DesignNote)).toHaveLength(2);
describe('when discussion is not resolvable', () => {
beforeEach(() => {
createComponent({
discussion: {
...discussion,
resolvable: false,
},
});
});
it('does not render an icon to resolve a thread', () => {
expect(findResolveIcon().exists()).toBe(false);
});
it('does not render a checkbox in reply form', () => {
findReplyPlaceholder().vm.$emit('onMouseDown');
return wrapper.vm.$nextTick().then(() => {
expect(findResolveCheckbox().exists()).toBe(false);
});
});
});
it('renders reply placeholder by default', () => {
createComponent();
expect(findReplyPlaceholder().exists()).toBe(true);
describe('when discussion is unresolved', () => {
beforeEach(() => {
createComponent();
});
it('renders correct amount of discussion notes', () => {
expect(findDesignNotes()).toHaveLength(2);
expect(findDesignNotes().wrappers.every(w => w.isVisible())).toBe(true);
});
it('renders reply placeholder', () => {
expect(findReplyPlaceholder().isVisible()).toBe(true);
});
it('does not render toggle replies widget', () => {
expect(findRepliesWidget().exists()).toBe(false);
});
it('renders a correct icon to resolve a thread', () => {
expect(findResolveIcon().props('name')).toBe('check-circle');
});
it('renders a checkbox with Resolve thread text in reply form', () => {
findReplyPlaceholder().vm.$emit('onMouseDown');
return wrapper.vm.$nextTick().then(() => {
expect(findResolveCheckbox().text()).toBe('Resolve thread');
});
});
it('does not render resolved message', () => {
expect(findResolvedMessage().exists()).toBe(false);
});
});
describe('when discussion is resolved', () => {
beforeEach(() => {
createComponent({
discussion: {
...discussion,
resolved: true,
resolvedBy: notes[0].author,
resolvedAt: '2020-05-08T07:10:45Z',
},
});
});
it('shows only the first note', () => {
expect(
findDesignNotes()
.at(0)
.isVisible(),
).toBe(true);
expect(
findDesignNotes()
.at(1)
.isVisible(),
).toBe(false);
});
it('renders resolved message', () => {
expect(findResolvedMessage().exists()).toBe(true);
});
it('does not show renders reply placeholder', () => {
expect(findReplyPlaceholder().isVisible()).toBe(false);
});
it('renders toggle replies widget with correct props', () => {
expect(findRepliesWidget().exists()).toBe(true);
expect(findRepliesWidget().props()).toEqual({
collapsed: true,
replies: notes.slice(1),
});
});
it('renders a correct icon to resolve a thread', () => {
expect(findResolveIcon().props('name')).toBe('check-circle-filled');
});
describe('when replies are expanded', () => {
beforeEach(() => {
findRepliesWidget().vm.$emit('toggle');
return wrapper.vm.$nextTick();
});
it('renders replies widget with collapsed prop equal to false', () => {
expect(findRepliesWidget().props('collapsed')).toBe(false);
});
it('renders the second note', () => {
expect(
findDesignNotes()
.at(1)
.isVisible(),
).toBe(true);
});
it('renders a reply placeholder', () => {
expect(findReplyPlaceholder().isVisible()).toBe(true);
});
it('renders a checkbox with Unresolve thread text in reply form', () => {
findReplyPlaceholder().vm.$emit('onMouseDown');
return wrapper.vm.$nextTick().then(() => {
expect(findResolveCheckbox().text()).toBe('Unresolve thread');
});
});
});
});
it('hides reply placeholder and opens form on placeholder click', () => {
......@@ -120,7 +254,7 @@ describe('Design discussions component', () => {
{},
{
activeDiscussion: {
id: '1',
id: notes[0].id,
source: 'pin',
},
},
......@@ -148,4 +282,35 @@ describe('Design discussions component', () => {
expect(findReplyForm().exists()).toBe(true);
});
});
it('calls toggleResolveDiscussion mutation on resolve thread button click', () => {
createComponent();
findResolveButton().trigger('click');
expect(mutate).toHaveBeenCalledWith({
mutation: toggleResolveDiscussionMutation,
variables: {
id: discussion.id,
resolve: true,
},
});
return wrapper.vm.$nextTick(() => {
expect(findResolveLoadingIcon().exists()).toBe(true);
});
});
it('calls toggleResolveDiscussion mutation after adding a note if checkbox was checked', () => {
createComponent({}, { discussionComment: 'test', isFormRendered: true });
findResolveButton().trigger('click');
findReplyForm().vm.$emit('submitForm');
return mutate().then(() => {
expect(mutate).toHaveBeenCalledWith({
mutation: toggleResolveDiscussionMutation,
variables: {
id: discussion.id,
resolve: true,
},
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlButton, GlLink } from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import ToggleRepliesWidget from '~/design_management/components/design_notes/toggle_replies_widget.vue';
import notes from '../../mock_data/notes';
describe('Toggle replies widget component', () => {
let wrapper;
const findToggleWrapper = () => wrapper.find('[data-testid="toggle-comments-wrapper"]');
const findIcon = () => wrapper.find(GlIcon);
const findButton = () => wrapper.find(GlButton);
const findAuthorLink = () => wrapper.find(GlLink);
const findTimeAgo = () => wrapper.find(TimeAgoTooltip);
function createComponent(props = {}) {
wrapper = shallowMount(ToggleRepliesWidget, {
propsData: {
collapsed: true,
replies: notes,
...props,
},
});
}
afterEach(() => {
wrapper.destroy();
});
describe('when replies are collapsed', () => {
beforeEach(() => {
createComponent();
});
it('should not have expanded class', () => {
expect(findToggleWrapper().classes()).not.toContain('expanded');
});
it('should render chevron-right icon', () => {
expect(findIcon().props('name')).toBe('chevron-right');
});
it('should have replies length on button', () => {
expect(findButton().text()).toBe('2 replies');
});
it('should render a link to the last reply author', () => {
expect(findAuthorLink().exists()).toBe(true);
expect(findAuthorLink().text()).toBe(notes[1].author.name);
expect(findAuthorLink().attributes('href')).toBe(notes[1].author.webUrl);
});
it('should render correct time ago tooltip', () => {
expect(findTimeAgo().exists()).toBe(true);
expect(findTimeAgo().props('time')).toBe(notes[1].createdAt);
});
});
describe('when replies are expanded', () => {
beforeEach(() => {
createComponent({ collapsed: false });
});
it('should have expanded class', () => {
expect(findToggleWrapper().classes()).toContain('expanded');
});
it('should render chevron-down icon', () => {
expect(findIcon().props('name')).toBe('chevron-down');
});
it('should have Collapse replies text on button', () => {
expect(findButton().text()).toBe('Collapse replies');
});
it('should not have a link to the last reply author', () => {
expect(findAuthorLink().exists()).toBe(false);
});
it('should not render time ago tooltip', () => {
expect(findTimeAgo().exists()).toBe(false);
});
});
it('should emit toggle event on icon click', () => {
createComponent();
findIcon().vm.$emit('click', new MouseEvent('click'));
expect(wrapper.emitted('toggle')).toHaveLength(1);
});
it('should emit toggle event on button click', () => {
createComponent();
findButton().vm.$emit('click', new MouseEvent('click'));
expect(wrapper.emitted('toggle')).toHaveLength(1);
});
});
......@@ -12,6 +12,7 @@ describe('Design overlay component', () => {
const mockDimensions = { width: 100, height: 100 };
const mockNoteNotAuthorised = {
id: 'note-not-authorised',
index: 1,
discussion: { id: 'discussion-not-authorised' },
position: {
x: 1,
......@@ -19,6 +20,7 @@ describe('Design overlay component', () => {
...mockDimensions,
},
userPermissions: {},
resolved: false,
};
const findOverlay = () => wrapper.find('.image-diff-overlay');
......@@ -43,6 +45,7 @@ describe('Design overlay component', () => {
top: '0',
left: '0',
},
resolvedDiscussionsExpanded: false,
...props,
},
data() {
......@@ -88,19 +91,46 @@ describe('Design overlay component', () => {
});
describe('with notes', () => {
beforeEach(() => {
it('should render only the first note', () => {
createComponent({
notes,
});
expect(findAllNotes()).toHaveLength(1);
});
it('should render a correct amount of notes', () => {
expect(findAllNotes()).toHaveLength(notes.length);
});
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().attributes().style).toBe('left: 10px; top: 15px;');
expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;');
});
it('should apply resolved class to the resolved note pin', () => {
expect(findSecondBadge().classes()).toContain('resolved');
});
it('when there is an active discussion, should apply inactive class to all pins besides the active one', () => {
wrapper.setData({
activeDiscussion: {
id: notes[0].id,
source: 'discussion',
},
});
it('should have a correct style for each note badge', () => {
expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;');
expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;');
return wrapper.vm.$nextTick().then(() => {
expect(findSecondBadge().classes()).toContain('inactive');
});
});
});
it('should recalculate badges positions on window resize', () => {
......@@ -144,19 +174,6 @@ describe('Design overlay component', () => {
expect(mutate).toHaveBeenCalledWith(mutationVariables);
});
});
it('when there is an active discussion, should apply inactive class to all pins besides the active one', () => {
wrapper.setData({
activeDiscussion: {
id: notes[0].id,
source: 'discussion',
},
});
return wrapper.vm.$nextTick().then(() => {
expect(findSecondBadge().classes()).toContain('inactive');
});
});
});
describe('when moving notes', () => {
......
......@@ -17,7 +17,13 @@ describe('Design management design presentation component', () => {
let wrapper;
function createComponent(
{ image, imageName, discussions = [], isAnnotating = false } = {},
{
image,
imageName,
discussions = [],
isAnnotating = false,
resolvedDiscussionsExpanded = false,
} = {},
data = {},
stubs = {},
) {
......@@ -27,6 +33,7 @@ describe('Design management design presentation component', () => {
imageName,
discussions,
isAnnotating,
resolvedDiscussionsExpanded,
},
stubs,
});
......
import { shallowMount } from '@vue/test-utils';
import { GlCollapse, GlPopover } from '@gitlab/ui';
import Cookies from 'js-cookie';
import DesignSidebar from '~/design_management/components/design_sidebar.vue';
import Participants from '~/sidebar/components/participants/participants.vue';
import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
import design from '../mock_data/design';
import updateActiveDiscussionMutation from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql';
const updateActiveDiscussionMutationVariables = {
mutation: updateActiveDiscussionMutation,
variables: {
id: design.discussions.nodes[0].notes.nodes[0].id,
source: 'discussion',
},
};
const $route = {
params: {
id: '1',
},
};
const cookieKey = 'hide_design_resolved_comments_popover';
const mutate = jest.fn().mockResolvedValue();
describe('Design management design sidebar component', () => {
let wrapper;
const findDiscussions = () => wrapper.findAll(DesignDiscussion);
const findFirstDiscussion = () => findDiscussions().at(0);
const findUnresolvedDiscussions = () => wrapper.findAll('[data-testid="unresolved-discussion"]');
const findResolvedDiscussions = () => wrapper.findAll('[data-testid="resolved-discussion"]');
const findParticipants = () => wrapper.find(Participants);
const findCollapsible = () => wrapper.find(GlCollapse);
const findToggleResolvedCommentsButton = () => wrapper.find('[data-testid="resolved-comments"]');
const findPopover = () => wrapper.find(GlPopover);
const findNewDiscussionDisclaimer = () =>
wrapper.find('[data-testid="new-discussion-disclaimer"]');
function createComponent(props = {}) {
wrapper = shallowMount(DesignSidebar, {
propsData: {
design,
resolvedDiscussionsExpanded: false,
markdownPreviewPath: '',
...props,
},
mocks: {
$route,
$apollo: {
mutate,
},
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('renders participants', () => {
createComponent();
expect(findParticipants().exists()).toBe(true);
});
it('passes the correct amount of participants to the Participants component', () => {
createComponent();
expect(findParticipants().props('participants')).toHaveLength(1);
});
describe('when has no discussions', () => {
beforeEach(() => {
createComponent({
design: {
...design,
discussions: {
nodes: [],
},
},
});
});
it('does not render discussions', () => {
expect(findDiscussions().exists()).toBe(false);
});
it('renders a message about possibility to create a new discussion', () => {
expect(findNewDiscussionDisclaimer().exists()).toBe(true);
});
});
describe('when has discussions', () => {
beforeEach(() => {
Cookies.set(cookieKey, true);
createComponent();
});
it('renders correct amount of unresolved discussions', () => {
expect(findUnresolvedDiscussions()).toHaveLength(1);
});
it('renders correct amount of resolved discussions', () => {
expect(findResolvedDiscussions()).toHaveLength(1);
});
it('has resolved comments collapsible collapsed', () => {
expect(findCollapsible().attributes('visible')).toBeUndefined();
});
it('emits toggleResolveComments event on resolve comments button click', () => {
findToggleResolvedCommentsButton().vm.$emit('click');
expect(wrapper.emitted('toggleResolvedComments')).toHaveLength(1);
});
it('opens a collapsible when resolvedDiscussionsExpanded prop changes to true', () => {
expect(findCollapsible().attributes('visible')).toBeUndefined();
wrapper.setProps({
resolvedDiscussionsExpanded: true,
});
return wrapper.vm.$nextTick().then(() => {
expect(findCollapsible().attributes('visible')).toBe('true');
});
});
it('does not popover about resolved comments', () => {
expect(findPopover().exists()).toBe(false);
});
it('sends a mutation to set an active discussion when clicking on a discussion', () => {
findFirstDiscussion().trigger('click');
expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables);
});
it('sends a mutation to reset an active discussion when clicking outside of discussion', () => {
wrapper.trigger('click');
expect(mutate).toHaveBeenCalledWith({
...updateActiveDiscussionMutationVariables,
variables: { id: undefined, source: 'discussion' },
});
});
it('emits correct event on discussion create note error', () => {
findFirstDiscussion().vm.$emit('createNoteError', 'payload');
expect(wrapper.emitted('onDesignDiscussionError')).toEqual([['payload']]);
});
it('emits correct event on discussion update note error', () => {
findFirstDiscussion().vm.$emit('updateNoteError', 'payload');
expect(wrapper.emitted('updateNoteError')).toEqual([['payload']]);
});
it('emits correct event on discussion resolve error', () => {
findFirstDiscussion().vm.$emit('resolveDiscussionError', 'payload');
expect(wrapper.emitted('resolveDiscussionError')).toEqual([['payload']]);
});
});
describe('when all discussions are resolved', () => {
beforeEach(() => {
createComponent({
design: {
...design,
discussions: {
nodes: [
{
id: 'discussion-id',
replyId: 'discussion-reply-id',
resolved: true,
notes: {
nodes: [
{
id: 'note-id',
body: '123',
author: {
name: 'Administrator',
username: 'root',
webUrl: 'link-to-author',
avatarUrl: 'link-to-avatar',
},
},
],
},
},
],
},
},
});
});
it('renders a message about possibility to create a new discussion', () => {
expect(findNewDiscussionDisclaimer().exists()).toBe(true);
});
it('does not render unresolved discussions', () => {
expect(findUnresolvedDiscussions()).toHaveLength(0);
});
});
describe('when showing resolved discussions for the first time', () => {
beforeEach(() => {
Cookies.set(cookieKey, false);
createComponent();
});
it('renders a popover if we show resolved comments collapsible for the first time', () => {
expect(findPopover().exists()).toBe(true);
});
it('dismisses a popover on the outside click', () => {
wrapper.trigger('click');
return wrapper.vm.$nextTick(() => {
expect(findPopover().exists()).toBe(false);
});
});
it(`sets a ${cookieKey} cookie on clicking outside the popover`, () => {
jest.spyOn(Cookies, 'set');
wrapper.trigger('click');
expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { expires: 365 * 10 });
});
});
});
......@@ -29,6 +29,7 @@ export default {
{
id: 'discussion-id',
replyId: 'discussion-reply-id',
resolved: false,
notes: {
nodes: [
{
......@@ -44,6 +45,25 @@ export default {
],
},
},
{
id: 'discussion-resolved',
replyId: 'discussion-reply-resolved',
resolved: true,
notes: {
nodes: [
{
id: 'note-resolved',
body: '123',
author: {
name: 'Administrator',
username: 'root',
webUrl: 'link-to-author',
avatarUrl: 'link-to-avatar',
},
},
],
},
},
],
},
diffRefs: {
......
export default [
{
id: 'note-id-1',
index: 1,
position: {
height: 100,
width: 100,
x: 10,
y: 15,
},
author: {
name: 'John',
webUrl: 'link-to-john-profile',
},
createdAt: '2020-05-08T07:10:45Z',
userPermissions: {
adminNote: true,
},
discussion: {
id: 'discussion-id-1',
},
resolved: false,
},
{
id: 'note-id-2',
index: 2,
position: {
height: 50,
width: 50,
x: 25,
y: 25,
},
author: {
name: 'Mary',
webUrl: 'link-to-mary-profile',
},
createdAt: '2020-05-08T07:10:45Z',
userPermissions: {
adminNote: true,
},
discussion: {
id: 'discussion-id-2',
},
resolved: true,
},
];
......@@ -16,7 +16,7 @@ exports[`Design management design index page renders design index 1`] = `
<!---->
<design-presentation-stub
discussions="[object Object]"
discussions="[object Object],[object Object]"
image="test.jpg"
imagename="test.jpg"
scale="1"
......@@ -33,58 +33,84 @@ exports[`Design management design index page renders design index 1`] = `
class="image-notes"
>
<h2
class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0"
class="gl-font-weight-bold gl-mt-0"
>
My precious issue
My precious issue
</h2>
<a
class="text-tertiary text-decoration-none mb-3 d-block"
class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block"
href="full-issue-url"
>
ull-issue-path
</a>
<participants-stub
class="mb-4"
class="gl-mb-4"
numberoflessparticipants="7"
participants="[object Object]"
/>
<div
class="design-discussion-wrapper"
<!---->
<design-discussion-stub
data-testid="unresolved-discussion"
designid="test"
discussion="[object Object]"
markdownpreviewpath="//preview_markdown?target_type=Issue"
noteableid="design-id"
/>
<gl-button-stub
category="tertiary"
class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4"
data-testid="resolved-comments"
icon="chevron-right"
id="resolved-comments"
size="medium"
variant="link"
>
<div
class="badge badge-pill"
type="button"
>
1
</div>
Resolved Comments (1)
</gl-button-stub>
<gl-popover-stub
container="popovercontainer"
cssclasses=""
placement="top"
show="true"
target="resolved-comments"
title="Resolved Comments"
>
<p>
Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below
</p>
<div
class="design-discussion bordered-box position-relative"
data-qa-selector="design_discussion_content"
<a
href="#"
rel="noopener noreferrer"
target="_blank"
>
<design-note-stub
class=""
markdownpreviewpath="//preview_markdown?target_type=Issue"
note="[object Object]"
/>
<div
class="reply-wrapper"
>
<reply-placeholder-stub
buttontext="Reply..."
class="qa-discussion-reply"
/>
</div>
</div>
</div>
Learn more about resolving comments
</a>
</gl-popover-stub>
<gl-collapse-stub
class="gl-mt-3"
>
<design-discussion-stub
data-testid="resolved-discussion"
designid="test"
discussion="[object Object]"
markdownpreviewpath="//preview_markdown?target_type=Issue"
noteableid="design-id"
/>
</gl-collapse-stub>
<!---->
</div>
</div>
`;
......@@ -152,33 +178,37 @@ exports[`Design management design index page with error GlAlert is rendered in c
class="image-notes"
>
<h2
class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0"
class="gl-font-weight-bold gl-mt-0"
>
My precious issue
My precious issue
</h2>
<a
class="text-tertiary text-decoration-none mb-3 d-block"
class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block"
href="full-issue-url"
>
ull-issue-path
</a>
<participants-stub
class="mb-4"
class="gl-mb-4"
numberoflessparticipants="7"
participants="[object Object]"
/>
<h2
class="new-discussion-disclaimer gl-font-base m-0"
class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4"
data-testid="new-discussion-disclaimer"
>
Click the image where you'd like to start a new discussion
Click the image where you'd like to start a new discussion
</h2>
<!---->
</div>
</div>
`;
......@@ -4,11 +4,9 @@ import { GlAlert } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
import createFlash from '~/flash';
import DesignIndex from '~/design_management/pages/design/index.vue';
import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
import DesignSidebar from '~/design_management/components/design_sidebar.vue';
import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
import Participants from '~/sidebar/components/participants/participants.vue';
import createImageDiffNoteMutation from '~/design_management/graphql/mutations/createImageDiffNote.mutation.graphql';
import updateActiveDiscussionMutation from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql';
import design from '../../mock_data/design';
import mockResponseWithDesigns from '../../mock_data/designs';
import mockResponseNoDesigns from '../../mock_data/no_designs';
......@@ -62,20 +60,10 @@ describe('Design management design index page', () => {
},
};
const updateActiveDiscussionMutationVariables = {
mutation: updateActiveDiscussionMutation,
variables: {
id: design.discussions.nodes[0].notes.nodes[0].id,
source: 'discussion',
},
};
const mutate = jest.fn().mockResolvedValue();
const findDiscussions = () => wrapper.findAll(DesignDiscussion);
const findDiscussionForm = () => wrapper.find(DesignReplyForm);
const findParticipants = () => wrapper.find(Participants);
const findDiscussionsWrapper = () => wrapper.find('.image-notes');
const findSidebar = () => wrapper.find(DesignSidebar);
function createComponent(loading = false, data = {}) {
const $apollo = {
......@@ -94,7 +82,7 @@ describe('Design management design index page', () => {
mocks: { $apollo },
stubs: {
ApolloMutation,
DesignDiscussion,
DesignSidebar,
},
data() {
return {
......@@ -145,63 +133,13 @@ describe('Design management design index page', () => {
expect(wrapper.find(GlAlert).exists()).toBe(false);
});
it('renders participants', () => {
it('passes correct props to sidebar component', () => {
createComponent(false, { design });
expect(findParticipants().exists()).toBe(true);
});
it('passes the correct amount of participants to the Participants component', () => {
createComponent(false, { design });
expect(findParticipants().props('participants')).toHaveLength(1);
});
describe('when has no discussions', () => {
beforeEach(() => {
createComponent(false, {
design: {
...design,
discussions: {
nodes: [],
},
},
});
});
it('does not render discussions', () => {
expect(findDiscussions().exists()).toBe(false);
});
it('renders a message about possibility to create a new discussion', () => {
expect(wrapper.find('.new-discussion-disclaimer').exists()).toBe(true);
});
});
describe('when has discussions', () => {
beforeEach(() => {
createComponent(false, { design });
});
it('renders correct amount of discussions', () => {
expect(findDiscussions()).toHaveLength(1);
});
it('sends a mutation to set an active discussion when clicking on a discussion', () => {
findDiscussions()
.at(0)
.trigger('click');
expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables);
});
it('sends a mutation to reset an active discussion when clicking outside of discussion', () => {
findDiscussionsWrapper().trigger('click');
expect(mutate).toHaveBeenCalledWith({
...updateActiveDiscussionMutationVariables,
variables: { id: undefined, source: 'discussion' },
});
expect(findSidebar().props()).toEqual({
design,
markdownPreviewPath: '//preview_markdown?target_type=Issue',
resolvedDiscussionsExpanded: false,
});
});
......
......@@ -53,10 +53,10 @@ describe('extractDiscussions', () => {
it('discards the edges.node artifacts of GraphQL', () => {
expect(extractDiscussions(discussions)).toEqual([
{ id: 1, notes: ['a'] },
{ id: 2, notes: ['b'] },
{ id: 3, notes: ['c'] },
{ id: 4, notes: ['d'] },
{ id: 1, notes: ['a'], index: 1 },
{ id: 2, notes: ['b'], index: 2 },
{ id: 3, notes: ['c'], index: 3 },
{ id: 4, notes: ['d'], index: 4 },
]);
});
});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment