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

Added a copy of design management app

This copy to be removed after feature flag removal
parent e968773f
// This application is being moved, please do not touch this files
// Please see https://gitlab.com/gitlab-org/gitlab/-/issues/14744#note_364468096 for details
import $ from 'jquery';
import Vue from 'vue';
import createRouter from './router';
......
<script>
import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash';
export default {
name: 'DeleteButton',
components: {
GlDeprecatedButton,
GlModal,
},
directives: {
GlModalDirective,
},
props: {
isDeleting: {
type: Boolean,
required: false,
default: false,
},
buttonClass: {
type: String,
required: false,
default: '',
},
buttonVariant: {
type: String,
required: false,
default: '',
},
hasSelectedDesigns: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
modalId: uniqueId('design-deletion-confirmation-'),
};
},
};
</script>
<template>
<div>
<gl-modal
:modal-id="modalId"
:title="s__('DesignManagement|Delete designs confirmation')"
:ok-title="s__('DesignManagement|Delete')"
ok-variant="danger"
@ok="$emit('deleteSelectedDesigns')"
>
<p>{{ s__('DesignManagement|Are you sure you want to delete the selected designs?') }}</p>
</gl-modal>
<gl-deprecated-button
v-gl-modal-directive="modalId"
:variant="buttonVariant"
:disabled="isDeleting || !hasSelectedDesigns"
:class="buttonClass"
>
<slot></slot>
</gl-deprecated-button>
</div>
</template>
<script>
import { ApolloMutation } from 'vue-apollo';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql';
import { updateStoreAfterDesignsDelete } from '../utils/cache_update';
export default {
components: {
ApolloMutation,
},
props: {
filenames: {
type: Array,
required: true,
},
projectPath: {
type: String,
required: true,
},
iid: {
type: String,
required: true,
},
},
computed: {
projectQueryBody() {
return {
query: getDesignListQuery,
variables: { fullPath: this.projectPath, iid: this.iid, atVersion: null },
};
},
},
methods: {
updateStoreAfterDelete(
store,
{
data: { designManagementDelete },
},
) {
updateStoreAfterDesignsDelete(
store,
designManagementDelete,
this.projectQueryBody,
this.filenames,
);
},
},
destroyDesignMutation,
};
</script>
<template>
<apollo-mutation
#default="{ mutate, loading, error }"
:mutation="$options.destroyDesignMutation"
:variables="{
filenames,
projectPath,
iid,
}"
:update="updateStoreAfterDelete"
v-on="$listeners"
>
<slot v-bind="{ mutate, loading, error }"></slot>
</apollo-mutation>
</template>
<script>
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'DesignNotePin',
components: {
Icon,
},
props: {
position: {
type: Object,
required: true,
},
label: {
type: Number,
required: false,
default: null,
},
repositioning: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isNewNote() {
return this.label === null;
},
pinStyle() {
return this.repositioning ? { ...this.position, cursor: 'move' } : this.position;
},
pinLabel() {
return this.isNewNote
? __('Comment form position')
: sprintf(__("Comment '%{label}' position"), { label: this.label });
},
},
};
</script>
<template>
<button
:style="pinStyle"
:aria-label="pinLabel"
:class="{
'btn-transparent comment-indicator': isNewNote,
'js-image-badge badge badge-pill': !isNewNote,
}"
class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center"
type="button"
@mousedown="$emit('mousedown', $event)"
@mouseup="$emit('mouseup', $event)"
@click="$emit('click', $event)"
>
<icon v-if="isNewNote" name="image-comment-dark" />
<template v-else>
{{ label }}
</template>
</button>
</template>
<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/create_note.mutation.graphql';
import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql';
import getDesignQuery from '../../graphql/queries/get_design.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: {
ApolloMutation,
DesignNote,
ReplyPlaceholder,
DesignReplyForm,
GlIcon,
GlLoadingIcon,
GlLink,
ToggleRepliesWidget,
TimeAgoTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [allVersionsMixin],
props: {
discussion: {
type: Object,
required: true,
},
noteableId: {
type: String,
required: true,
},
designId: {
type: String,
required: true,
},
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
resolvedDiscussionsExpanded: {
type: Boolean,
required: true,
},
discussionWithOpenForm: {
type: String,
required: true,
},
},
apollo: {
activeDiscussion: {
query: activeDiscussionQuery,
result({ data }) {
const discussionId = data.activeDiscussion.id;
if (this.discussion.resolved && !this.resolvedDiscussionsExpanded) {
return;
}
// 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 (
discussionId &&
data.activeDiscussion.source === ACTIVE_DISCUSSION_SOURCE_TYPES.pin &&
discussionId === this.discussion.notes[0].id
) {
this.$el.scrollIntoView({
behavior: 'smooth',
inline: 'start',
});
}
},
},
},
data() {
return {
discussionComment: '',
isFormRendered: false,
activeDiscussion: {},
isResolving: false,
shouldChangeResolvedStatus: false,
areRepliesCollapsed: this.discussion.resolved,
};
},
computed: {
mutationPayload() {
return {
noteableId: this.noteableId,
body: this.discussionComment,
discussionId: this.discussion.id,
};
},
designVariables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
filenames: [this.$route.params.id],
atVersion: this.designsVersion,
};
},
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;
},
isFormVisible() {
return this.isFormRendered && this.discussionWithOpenForm === this.discussion.id;
},
},
methods: {
addDiscussionComment(
store,
{
data: { createNote },
},
) {
updateStoreAfterAddDiscussionComment(
store,
createNote,
getDesignQuery,
this.designVariables,
this.discussion.id,
);
},
onDone() {
this.discussionComment = '';
this.hideForm();
if (this.shouldChangeResolvedStatus) {
this.toggleResolvedStatus();
}
},
onCreateNoteError(err) {
this.$emit('createNoteError', err);
},
hideForm() {
this.isFormRendered = false;
this.discussionComment = '';
},
showForm() {
this.$emit('openForm', this.discussion.id);
this.isFormRendered = true;
},
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,
};
</script>
<template>
<div class="design-discussion-wrapper">
<div
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
: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)"
/>
<li v-show="isReplyPlaceholderVisible" class="reply-wrapper">
<reply-placeholder
v-if="!isFormVisible"
class="qa-discussion-reply"
:button-text="__('Reply...')"
@onClick="showForm"
/>
<apollo-mutation
v-else
#default="{ mutate, loading }"
:mutation="$options.createNoteMutation"
:variables="{
input: mutationPayload,
}"
:update="addDiscussionComment"
@done="onDone"
@error="onCreateNoteError"
>
<design-reply-form
v-model="discussionComment"
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
@submitForm="mutate"
@cancelForm="hideForm"
>
<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>
</li>
</ul>
</div>
</template>
<script>
import { ApolloMutation } from 'vue-apollo';
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import DesignReplyForm from './design_reply_form.vue';
import { findNoteId } from '../../utils/design_management_utils';
import { hasErrors } from '../../utils/cache_update';
export default {
components: {
UserAvatarLink,
TimelineEntryItem,
TimeAgoTooltip,
DesignReplyForm,
ApolloMutation,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
note: {
type: Object,
required: true,
},
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
noteText: this.note.body,
isEditing: false,
};
},
computed: {
author() {
return this.note.author;
},
noteAnchorId() {
return findNoteId(this.note.id);
},
isNoteLinked() {
return this.$route.hash === `#note_${this.noteAnchorId}`;
},
mutationPayload() {
return {
id: this.note.id,
body: this.noteText,
};
},
isEditButtonVisible() {
return !this.isEditing && this.note.userPermissions.adminNote;
},
},
mounted() {
if (this.isNoteLinked) {
this.$refs.anchor.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' });
}
},
methods: {
hideForm() {
this.isEditing = false;
this.noteText = this.note.body;
},
onDone({ data }) {
this.hideForm();
if (hasErrors(data.updateNote)) {
this.$emit('error', data.errors[0]);
}
},
},
updateNoteMutation,
};
</script>
<template>
<timeline-entry-item :id="`note_${noteAnchorId}`" ref="anchor" class="design-note note-form">
<user-avatar-link
:link-href="author.webUrl"
:img-src="author.avatarUrl"
:img-alt="author.username"
:img-size="40"
/>
<div class="d-flex justify-content-between">
<div>
<a
v-once
:href="author.webUrl"
class="js-user-link"
:data-user-id="author.id"
:data-username="author.username"
>
<span class="note-header-author-name bold">{{ author.name }}</span>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
<span class="note-headline-light">@{{ author.username }}</span>
</a>
<span class="note-headline-light note-headline-meta">
<span class="system-note-message"> <slot></slot> </span>
<template v-if="note.createdAt">
<span class="system-note-separator"></span>
<a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`">
<time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
</a>
</template>
</span>
</div>
<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>
<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 }"
:mutation="$options.updateNoteMutation"
:variables="{
input: mutationPayload,
}"
@error="$emit('error', $event)"
@done="onDone"
>
<design-reply-form
v-model="noteText"
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
:is-new-comment="false"
class="mt-5"
@submitForm="mutate"
@cancelForm="hideForm"
/>
</apollo-mutation>
</timeline-entry-item>
</template>
<script>
import { GlDeprecatedButton, GlModal } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { s__ } from '~/locale';
export default {
name: 'DesignReplyForm',
components: {
MarkdownField,
GlDeprecatedButton,
GlModal,
},
props: {
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
value: {
type: String,
required: true,
},
isSaving: {
type: Boolean,
required: true,
},
isNewComment: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
formText: this.value,
};
},
computed: {
hasValue() {
return this.value.trim().length > 0;
},
modalSettings() {
if (this.isNewComment) {
return {
title: s__('DesignManagement|Cancel comment confirmation'),
okTitle: s__('DesignManagement|Discard comment'),
cancelTitle: s__('DesignManagement|Keep comment'),
content: s__('DesignManagement|Are you sure you want to cancel creating this comment?'),
};
}
return {
title: s__('DesignManagement|Cancel comment update confirmation'),
okTitle: s__('DesignManagement|Cancel changes'),
cancelTitle: s__('DesignManagement|Keep changes'),
content: s__('DesignManagement|Are you sure you want to cancel changes to this comment?'),
};
},
buttonText() {
return this.isNewComment
? s__('DesignManagement|Comment')
: s__('DesignManagement|Save comment');
},
},
mounted() {
this.focusInput();
},
methods: {
submitForm() {
if (this.hasValue) this.$emit('submitForm');
},
cancelComment() {
if (this.hasValue && this.formText !== this.value) {
this.$refs.cancelCommentModal.show();
} else {
this.$emit('cancelForm');
}
},
focusInput() {
this.$refs.textarea.focus();
},
},
};
</script>
<template>
<form class="new-note common-note-form" @submit.prevent>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:can-attach-file="false"
:enable-autocomplete="true"
:textarea-value="value"
markdown-docs-path="/help/user/markdown"
class="bordered-box"
>
<template #textarea>
<textarea
ref="textarea"
:value="value"
class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
data-supports-quick-actions="false"
data-qa-selector="note_textarea"
:aria-label="__('Description')"
:placeholder="__('Write a comment…')"
@input="$emit('input', $event.target.value)"
@keydown.meta.enter="submitForm"
@keydown.ctrl.enter="submitForm"
@keyup.esc.stop="cancelComment"
>
</textarea>
</template>
</markdown-field>
<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"
variant="success"
type="submit"
data-track-event="click_button"
data-qa-selector="save_comment_button"
@click="$emit('submitForm')"
>
{{ buttonText }}
</gl-deprecated-button>
<gl-deprecated-button ref="cancelButton" @click="cancelComment">{{
__('Cancel')
}}</gl-deprecated-button>
</div>
<gl-modal
ref="cancelCommentModal"
ok-variant="danger"
:title="modalSettings.title"
:ok-title="modalSettings.okTitle"
:cancel-title="modalSettings.cancelTitle"
modal-id="cancel-comment-modal"
@ok="$emit('cancelForm')"
>{{ modalSettings.content }}
</gl-modal>
</form>
</template>
<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>
<script>
import activeDiscussionQuery from '../graphql/queries/active_discussion.query.graphql';
import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
import DesignNotePin from './design_note_pin.vue';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
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,
},
},
apollo: {
activeDiscussion: {
query: activeDiscussionQuery,
},
},
data() {
return {
movingNoteNewPosition: null,
movingNoteStartPosition: null,
activeDiscussion: {},
};
},
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);
},
},
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 { adminNote } = userPermissions || {};
return Boolean(adminNote);
},
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.updateActiveDiscussion(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.activeDiscussion.id) {
this.updateActiveDiscussion();
}
this.setNewNoteCoordinates({ x: offsetX, y: offsetY });
},
updateActiveDiscussion(id) {
this.$apollo.mutate({
mutation: updateActiveDiscussionMutation,
variables: {
id,
source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin,
},
});
},
isNoteInactive(note) {
return this.activeDiscussion.id && this.activeDiscussion.id !== note.id;
},
designPinClass(note) {
return { inactive: this.isNoteInactive(note), resolved: note.resolved };
},
},
};
</script>
<template>
<div
class="position-absolute image-diff-overlay frame"
:style="overlayStyle"
@mousemove="onOverlayMousemove"
@mouseleave="onNoteMouseup"
>
<button
v-show="!disableCommenting"
type="button"
class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button"
data-qa-selector="design_image_button"
@mouseup="onAddCommentMouseup"
></button>
<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"
:repositioning="isMovingCurrentComment"
@mousedown.stop="onNoteMousedown"
@mouseup.stop="onNoteMouseup"
/>
</div>
</template>
<script>
import { throttle } from 'lodash';
import DesignImage from './image.vue';
import DesignOverlay from './design_overlay.vue';
const CLICK_DRAG_BUFFER_PX = 2;
export default {
components: {
DesignImage,
DesignOverlay,
},
props: {
image: {
type: String,
required: false,
default: '',
},
imageName: {
type: String,
required: false,
default: '',
},
discussions: {
type: Array,
required: true,
},
isAnnotating: {
type: Boolean,
required: false,
default: false,
},
scale: {
type: Number,
required: false,
default: 1,
},
resolvedDiscussionsExpanded: {
type: Boolean,
required: true,
},
},
data() {
return {
overlayDimensions: null,
overlayPosition: null,
currentAnnotationPosition: null,
zoomFocalPoint: {
x: 0,
y: 0,
width: 0,
height: 0,
},
initialLoad: true,
lastDragPosition: null,
isDraggingDesign: false,
};
},
computed: {
discussionStartingNotes() {
return this.discussions.map(discussion => ({
...discussion.notes[0],
index: discussion.index,
}));
},
currentCommentForm() {
return (this.isAnnotating && this.currentAnnotationPosition) || null;
},
presentationStyle() {
return {
cursor: this.isDraggingDesign ? 'grabbing' : undefined,
};
},
},
beforeDestroy() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return;
presentationViewport.removeEventListener('scroll', this.scrollThrottled, false);
},
mounted() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return;
this.scrollThrottled = throttle(() => {
this.shiftZoomFocalPoint();
}, 400);
presentationViewport.addEventListener('scroll', this.scrollThrottled, false);
},
methods: {
syncCurrentAnnotationPosition() {
if (!this.currentAnnotationPosition) return;
const widthRatio = this.overlayDimensions.width / this.currentAnnotationPosition.width;
const heightRatio = this.overlayDimensions.height / this.currentAnnotationPosition.height;
const x = this.currentAnnotationPosition.x * widthRatio;
const y = this.currentAnnotationPosition.y * heightRatio;
this.currentAnnotationPosition = this.getAnnotationPositon({ x, y });
},
setOverlayDimensions(overlayDimensions) {
this.overlayDimensions = overlayDimensions;
// every time we set overlay dimensions, we need to
// update the current annotation as well
this.syncCurrentAnnotationPosition();
},
setOverlayPosition() {
if (!this.overlayDimensions) {
this.overlayPosition = {};
}
const { presentationViewport } = this.$refs;
if (!presentationViewport) return;
// default to center
this.overlayPosition = {
left: `calc(50% - ${this.overlayDimensions.width / 2}px)`,
top: `calc(50% - ${this.overlayDimensions.height / 2}px)`,
};
// if the overlay overflows, then don't center
if (this.overlayDimensions.width > presentationViewport.offsetWidth) {
this.overlayPosition.left = '0';
}
if (this.overlayDimensions.height > presentationViewport.offsetHeight) {
this.overlayPosition.top = '0';
}
},
/**
* Return a point that represents the center of an
* overflowing child element w.r.t it's parent
*/
getViewportCenter() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return {};
// get height of scroll bars (i.e. the max values for scrollTop, scrollLeft)
const scrollBarWidth = presentationViewport.scrollWidth - presentationViewport.offsetWidth;
const scrollBarHeight = presentationViewport.scrollHeight - presentationViewport.offsetHeight;
// determine how many child pixels have been scrolled
const xScrollRatio =
presentationViewport.scrollLeft > 0 ? presentationViewport.scrollLeft / scrollBarWidth : 0;
const yScrollRatio =
presentationViewport.scrollTop > 0 ? presentationViewport.scrollTop / scrollBarHeight : 0;
const xScrollOffset =
(presentationViewport.scrollWidth - presentationViewport.offsetWidth - 0) * xScrollRatio;
const yScrollOffset =
(presentationViewport.scrollHeight - presentationViewport.offsetHeight - 0) * yScrollRatio;
const viewportCenterX = presentationViewport.offsetWidth / 2;
const viewportCenterY = presentationViewport.offsetHeight / 2;
const focalPointX = viewportCenterX + xScrollOffset;
const focalPointY = viewportCenterY + yScrollOffset;
return {
x: focalPointX,
y: focalPointY,
};
},
/**
* Scroll the viewport such that the focal point is positioned centrally
*/
scrollToFocalPoint() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return;
const scrollX = this.zoomFocalPoint.x - presentationViewport.offsetWidth / 2;
const scrollY = this.zoomFocalPoint.y - presentationViewport.offsetHeight / 2;
presentationViewport.scrollTo(scrollX, scrollY);
},
scaleZoomFocalPoint() {
const { x, y, width, height } = this.zoomFocalPoint;
const widthRatio = this.overlayDimensions.width / width;
const heightRatio = this.overlayDimensions.height / height;
this.zoomFocalPoint = {
x: Math.round(x * widthRatio * 100) / 100,
y: Math.round(y * heightRatio * 100) / 100,
...this.overlayDimensions,
};
},
shiftZoomFocalPoint() {
this.zoomFocalPoint = {
...this.getViewportCenter(),
...this.overlayDimensions,
};
},
onImageResize(imageDimensions) {
this.setOverlayDimensions(imageDimensions);
this.setOverlayPosition();
this.$nextTick(() => {
if (this.initialLoad) {
// set focal point on initial load
this.shiftZoomFocalPoint();
this.initialLoad = false;
} else {
this.scaleZoomFocalPoint();
this.scrollToFocalPoint();
}
});
},
getAnnotationPositon(coordinates) {
const { x, y } = coordinates;
const { width, height } = this.overlayDimensions;
return {
x: Math.round(x),
y: Math.round(y),
width: Math.round(width),
height: Math.round(height),
};
},
openCommentForm(coordinates) {
this.currentAnnotationPosition = this.getAnnotationPositon(coordinates);
this.$emit('openCommentForm', this.currentAnnotationPosition);
},
closeCommentForm() {
this.currentAnnotationPosition = null;
this.$emit('closeCommentForm');
},
moveNote({ noteId, discussionId, coordinates }) {
const position = this.getAnnotationPositon(coordinates);
this.$emit('moveNote', { noteId, discussionId, position });
},
onPresentationMousedown({ clientX, clientY }) {
if (!this.isDesignOverflowing()) return;
this.lastDragPosition = {
x: clientX,
y: clientY,
};
},
getDragDelta(clientX, clientY) {
return {
deltaX: this.lastDragPosition.x - clientX,
deltaY: this.lastDragPosition.y - clientY,
};
},
exceedsDragThreshold(clientX, clientY) {
const { deltaX, deltaY } = this.getDragDelta(clientX, clientY);
return Math.abs(deltaX) > CLICK_DRAG_BUFFER_PX || Math.abs(deltaY) > CLICK_DRAG_BUFFER_PX;
},
shouldDragDesign(clientX, clientY) {
return (
this.lastDragPosition &&
(this.isDraggingDesign || this.exceedsDragThreshold(clientX, clientY))
);
},
onPresentationMousemove({ clientX, clientY }) {
const { presentationViewport } = this.$refs;
if (!presentationViewport || !this.shouldDragDesign(clientX, clientY)) return;
this.isDraggingDesign = true;
const { scrollLeft, scrollTop } = presentationViewport;
const { deltaX, deltaY } = this.getDragDelta(clientX, clientY);
presentationViewport.scrollTo(scrollLeft + deltaX, scrollTop + deltaY);
this.lastDragPosition = {
x: clientX,
y: clientY,
};
},
onPresentationMouseup() {
this.lastDragPosition = null;
this.isDraggingDesign = false;
},
isDesignOverflowing() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return false;
return (
presentationViewport.scrollWidth > presentationViewport.offsetWidth ||
presentationViewport.scrollHeight > presentationViewport.offsetHeight
);
},
},
};
</script>
<template>
<div
ref="presentationViewport"
class="h-100 w-100 p-3 overflow-auto position-relative"
:style="presentationStyle"
@mousedown="onPresentationMousedown"
@mousemove="onPresentationMousemove"
@mouseup="onPresentationMouseup"
@mouseleave="onPresentationMouseup"
@touchstart="onPresentationMousedown"
@touchmove="onPresentationMousemove"
@touchend="onPresentationMouseup"
@touchcancel="onPresentationMouseup"
>
<div class="h-100 w-100 d-flex align-items-center position-relative">
<design-image
v-if="image"
:image="image"
:name="imageName"
:scale="scale"
@resize="onImageResize"
/>
<design-overlay
v-if="overlayDimensions && overlayPosition"
:dimensions="overlayDimensions"
:position="overlayPosition"
:notes="discussionStartingNotes"
:current-comment-form="currentCommentForm"
:disable-commenting="isDraggingDesign"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
@openCommentForm="openCommentForm"
@closeCommentForm="closeCommentForm"
@moveNote="moveNote"
/>
</div>
</div>
</template>
<script>
import { GlIcon } from '@gitlab/ui';
const SCALE_STEP_SIZE = 0.2;
const DEFAULT_SCALE = 1;
const MIN_SCALE = 1;
const MAX_SCALE = 2;
export default {
components: {
GlIcon,
},
data() {
return {
scale: DEFAULT_SCALE,
};
},
computed: {
disableReset() {
return this.scale <= MIN_SCALE;
},
disableDecrease() {
return this.scale === DEFAULT_SCALE;
},
disableIncrease() {
return this.scale >= MAX_SCALE;
},
},
methods: {
setScale(scale) {
if (scale < MIN_SCALE) {
return;
}
this.scale = Math.round(scale * 100) / 100;
this.$emit('scale', this.scale);
},
incrementScale() {
this.setScale(this.scale + SCALE_STEP_SIZE);
},
decrementScale() {
this.setScale(this.scale - SCALE_STEP_SIZE);
},
resetScale() {
this.setScale(DEFAULT_SCALE);
},
},
};
</script>
<template>
<div class="design-scaler btn-group" role="group">
<button class="btn" :disabled="disableDecrease" @click="decrementScale">
<span class="d-flex-center gl-icon s16">
</span>
</button>
<button class="btn" :disabled="disableReset" @click="resetScale">
<gl-icon name="redo" />
</button>
<button class="btn" :disabled="disableIncrease" @click="incrementScale">
<gl-icon name="plus" />
</button>
</div>
</template>
<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)),
discussionWithOpenForm: '',
};
},
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');
},
updateDiscussionWithOpenForm(id) {
this.discussionWithOpenForm = id;
},
},
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"
:discussion-with-open-form="discussionWithOpenForm"
data-testid="unresolved-discussion"
@createNoteError="$emit('onDesignDiscussionError', $event)"
@updateNoteError="$emit('updateNoteError', $event)"
@resolveDiscussionError="$emit('resolveDiscussionError', $event)"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
@openForm="updateDiscussionWithOpenForm"
/>
<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"
:discussion-with-open-form="discussionWithOpenForm"
data-testid="resolved-discussion"
@error="$emit('onDesignDiscussionError', $event)"
@updateNoteError="$emit('updateNoteError', $event)"
@openForm="updateDiscussionWithOpenForm"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
/>
</gl-collapse>
</template>
<slot name="replyForm"></slot>
</div>
</template>
<script>
import { throttle } from 'lodash';
import { GlIcon } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
props: {
image: {
type: String,
required: false,
default: '',
},
name: {
type: String,
required: false,
default: '',
},
scale: {
type: Number,
required: false,
default: 1,
},
},
data() {
return {
baseImageSize: null,
imageStyle: null,
imageError: false,
};
},
watch: {
scale(val) {
this.zoom(val);
},
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeThrottled, false);
},
mounted() {
this.onImgLoad();
this.resizeThrottled = throttle(() => {
// NOTE: if imageStyle is set, then baseImageSize
// won't change due to resize. We must still emit a
// `resize` event so that the parent can handle
// resizes appropriately (e.g. for design_overlay)
this.setBaseImageSize();
}, 400);
window.addEventListener('resize', this.resizeThrottled, false);
},
methods: {
onImgLoad() {
requestIdleCallback(this.setBaseImageSize, { timeout: 1000 });
},
onImgError() {
this.imageError = true;
},
setBaseImageSize() {
const { contentImg } = this.$refs;
if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return;
this.baseImageSize = {
height: contentImg.offsetHeight,
width: contentImg.offsetWidth,
};
this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height });
},
onResize({ width, height }) {
this.$emit('resize', { width, height });
},
zoom(amount) {
if (amount === 1) {
this.imageStyle = null;
this.$nextTick(() => {
this.setBaseImageSize();
});
return;
}
const width = this.baseImageSize.width * amount;
const height = this.baseImageSize.height * amount;
this.imageStyle = {
width: `${width}px`,
height: `${height}px`,
};
this.onResize({ width, height });
},
},
};
</script>
<template>
<div class="m-auto js-design-image">
<gl-icon v-if="imageError" class="text-secondary-100" name="media-broken" :size="48" />
<img
v-show="!imageError"
ref="contentImg"
class="mh-100"
:src="image"
:alt="name"
:style="imageStyle"
:class="{ 'img-fluid': !imageStyle }"
@error="onImgError"
@load="onImgLoad"
/>
</div>
</template>
<script>
import { GlLoadingIcon, GlIcon, GlIntersectionObserver } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import { n__, __ } from '~/locale';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
components: {
GlLoadingIcon,
GlIntersectionObserver,
GlIcon,
Icon,
Timeago,
},
props: {
id: {
type: [Number, String],
required: true,
},
event: {
type: String,
required: true,
},
notesCount: {
type: Number,
required: true,
},
image: {
type: String,
required: true,
},
filename: {
type: String,
required: true,
},
updatedAt: {
type: String,
required: false,
default: null,
},
isUploading: {
type: Boolean,
required: false,
default: true,
},
imageV432x230: {
type: String,
required: false,
default: null,
},
},
data() {
return {
imageLoading: true,
imageError: false,
wasInView: false,
};
},
computed: {
icon() {
const normalizedEvent = this.event.toLowerCase();
const icons = {
creation: {
name: 'file-addition-solid',
classes: 'text-success-500',
tooltip: __('Added in this version'),
},
modification: {
name: 'file-modified-solid',
classes: 'text-primary-500',
tooltip: __('Modified in this version'),
},
deletion: {
name: 'file-deletion-solid',
classes: 'text-danger-500',
tooltip: __('Deleted in this version'),
},
};
return icons[normalizedEvent] ? icons[normalizedEvent] : {};
},
notesLabel() {
return n__('%d comment', '%d comments', this.notesCount);
},
imageLink() {
return this.wasInView ? this.imageV432x230 || this.image : '';
},
showLoadingSpinner() {
return this.imageLoading || this.isUploading;
},
showImageErrorIcon() {
return this.wasInView && this.imageError;
},
showImage() {
return !this.showLoadingSpinner && !this.showImageErrorIcon;
},
},
methods: {
onImageLoad() {
this.imageLoading = false;
this.imageError = false;
},
onImageError() {
this.imageLoading = false;
this.imageError = true;
},
onAppear() {
// do nothing if image has previously
// been in view
if (this.wasInView) {
return;
}
this.wasInView = true;
this.imageLoading = true;
},
},
DESIGN_ROUTE_NAME,
};
</script>
<template>
<router-link
:to="{
name: $options.DESIGN_ROUTE_NAME,
params: { id: filename },
query: $route.query,
}"
class="card cursor-pointer text-plain js-design-list-item design-list-item"
>
<div class="card-body p-0 d-flex-center overflow-hidden position-relative">
<div v-if="icon.name" class="design-event position-absolute">
<span :title="icon.tooltip" :aria-label="icon.tooltip">
<icon :name="icon.name" :size="18" :class="icon.classes" />
</span>
</div>
<gl-intersection-observer @appear="onAppear">
<gl-loading-icon v-if="showLoadingSpinner" size="md" />
<gl-icon
v-else-if="showImageErrorIcon"
name="media-broken"
class="text-secondary"
:size="32"
/>
<img
v-show="showImage"
:src="imageLink"
:alt="filename"
class="block mx-auto mw-100 mh-100 design-img"
data-qa-selector="design_image"
@load="onImageLoad"
@error="onImageError"
/>
</gl-intersection-observer>
</div>
<div class="card-footer d-flex w-100">
<div class="d-flex flex-column str-truncated-100">
<span class="bold str-truncated-100" data-qa-selector="design_file_name">{{
filename
}}</span>
<span v-if="updatedAt" class="str-truncated-100">
{{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" />
</span>
</div>
<div v-if="notesCount" class="ml-auto d-flex align-items-center text-secondary">
<icon name="comments" class="ml-1" />
<span :aria-label="notesLabel" class="ml-1">
{{ notesCount }}
</span>
</div>
</div>
</router-link>
</template>
<script>
import { GlDeprecatedButton } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import Pagination from './pagination.vue';
import DeleteButton from '../delete_button.vue';
import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql';
import appDataQuery from '../../graphql/queries/app_data.query.graphql';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
export default {
components: {
Icon,
Pagination,
DeleteButton,
GlDeprecatedButton,
},
mixins: [timeagoMixin],
props: {
id: {
type: String,
required: true,
},
isDeleting: {
type: Boolean,
required: true,
},
filename: {
type: String,
required: false,
default: '',
},
updatedAt: {
type: String,
required: false,
default: null,
},
updatedBy: {
type: Object,
required: false,
default: () => ({}),
},
isLatestVersion: {
type: Boolean,
required: true,
},
image: {
type: String,
required: true,
},
},
data() {
return {
permissions: {
createDesign: false,
},
projectPath: '',
issueIid: null,
};
},
apollo: {
appData: {
query: appDataQuery,
manual: true,
result({ data: { projectPath, issueIid } }) {
this.projectPath = projectPath;
this.issueIid = issueIid;
},
},
permissions: {
query: permissionsQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
};
},
update: data => data.project.issue.userPermissions,
},
},
computed: {
updatedText() {
return sprintf(__('Updated %{updated_at} by %{updated_by}'), {
updated_at: this.timeFormatted(this.updatedAt),
updated_by: this.updatedBy.name,
});
},
canDeleteDesign() {
return this.permissions.createDesign;
},
},
DESIGNS_ROUTE_NAME,
};
</script>
<template>
<header class="d-flex p-2 bg-white align-items-center js-design-header">
<router-link
:to="{
name: $options.DESIGNS_ROUTE_NAME,
query: $route.query,
}"
:aria-label="s__('DesignManagement|Go back to designs')"
class="mr-3 text-plain d-flex justify-content-center align-items-center"
>
<icon :size="18" name="close" />
</router-link>
<div class="overflow-hidden d-flex align-items-center">
<h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
<small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
</div>
<pagination :id="id" class="ml-auto flex-shrink-0" />
<gl-deprecated-button :href="image" class="mr-2">
<icon :size="18" name="download" />
</gl-deprecated-button>
<delete-button
v-if="isLatestVersion && canDeleteDesign"
:is-deleting="isDeleting"
button-variant="danger"
@deleteSelectedDesigns="$emit('delete')"
>
<icon :size="18" name="remove" />
</delete-button>
</header>
</template>
<script>
/* global Mousetrap */
import 'mousetrap';
import { s__, sprintf } from '~/locale';
import PaginationButton from './pagination_button.vue';
import allDesignsMixin from '../../mixins/all_designs';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
components: {
PaginationButton,
},
mixins: [allDesignsMixin],
props: {
id: {
type: String,
required: true,
},
},
computed: {
designsCount() {
return this.designs.length;
},
currentIndex() {
return this.designs.findIndex(design => design.filename === this.id);
},
paginationText() {
return sprintf(s__('DesignManagement|%{current_design} of %{designs_count}'), {
current_design: this.currentIndex + 1,
designs_count: this.designsCount,
});
},
previousDesign() {
if (!this.designsCount) return null;
return this.designs[this.currentIndex - 1];
},
nextDesign() {
if (!this.designsCount) return null;
return this.designs[this.currentIndex + 1];
},
},
mounted() {
Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign));
Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign));
},
beforeDestroy() {
Mousetrap.unbind(['left', 'right'], this.navigateToDesign);
},
methods: {
navigateToDesign(design) {
if (design) {
this.$router.push({
name: DESIGN_ROUTE_NAME,
params: { id: design.filename },
query: this.$route.query,
});
}
},
},
};
</script>
<template>
<div v-if="designsCount" class="d-flex align-items-center">
{{ paginationText }}
<div class="btn-group ml-3 mr-3">
<pagination-button
:design="previousDesign"
:title="s__('DesignManagement|Go to previous design')"
icon-name="angle-left"
class="js-previous-design"
/>
<pagination-button
:design="nextDesign"
:title="s__('DesignManagement|Go to next design')"
icon-name="angle-right"
class="js-next-design"
/>
</div>
</div>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
components: {
Icon,
},
props: {
design: {
type: Object,
required: false,
default: null,
},
title: {
type: String,
required: true,
},
iconName: {
type: String,
required: true,
},
},
computed: {
designLink() {
if (!this.design) return {};
return {
name: DESIGN_ROUTE_NAME,
params: { id: this.design.filename },
query: this.$route.query,
};
},
},
};
</script>
<template>
<router-link
:to="designLink"
:disabled="!design"
:class="{ disabled: !design }"
:aria-label="title"
class="btn btn-default"
>
<icon :name="iconName" />
</router-link>
</template>
<script>
import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
export default {
components: {
GlDeprecatedButton,
GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
isSaving: {
type: Boolean,
required: true,
},
},
methods: {
openFileUpload() {
this.$refs.fileUpload.click();
},
onFileUploadChange(e) {
this.$emit('upload', e.target.files);
},
},
VALID_DESIGN_FILE_MIMETYPE,
};
</script>
<template>
<div>
<gl-deprecated-button
v-gl-tooltip.hover
:title="
s__(
'DesignManagement|Adding a design with the same filename replaces the file in a new version.',
)
"
:disabled="isSaving"
variant="success"
@click="openFileUpload"
>
{{ s__('DesignManagement|Upload designs') }}
<gl-loading-icon v-if="isSaving" inline class="ml-1" />
</gl-deprecated-button>
<input
ref="fileUpload"
type="file"
name="design_file"
:accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
class="hide"
multiple
@change="onFileUploadChange"
/>
</div>
</template>
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import createFlash from '~/flash';
import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql';
import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages';
import { isValidDesignFile } from '../../utils/design_management_utils';
import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
export default {
components: {
GlIcon,
GlLink,
GlSprintf,
},
data() {
return {
dragCounter: 0,
isDragDataValid: false,
};
},
computed: {
dragging() {
return this.dragCounter !== 0;
},
},
methods: {
isValidUpload(files) {
return files.every(isValidDesignFile);
},
isValidDragDataType({ dataTransfer }) {
return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE));
},
ondrop({ dataTransfer = {} }) {
this.dragCounter = 0;
// User already had feedback when dropzone was active, so bail here
if (!this.isDragDataValid) {
return;
}
const { files } = dataTransfer;
if (!this.isValidUpload(Array.from(files))) {
createFlash(UPLOAD_DESIGN_INVALID_FILETYPE_ERROR);
return;
}
this.$emit('change', files);
},
ondragenter(e) {
this.dragCounter += 1;
this.isDragDataValid = this.isValidDragDataType(e);
},
ondragleave() {
this.dragCounter -= 1;
},
openFileUpload() {
this.$refs.fileUpload.click();
},
onDesignInputChange(e) {
this.$emit('change', e.target.files);
},
},
uploadDesignMutation,
VALID_DESIGN_FILE_MIMETYPE,
};
</script>
<template>
<div
class="w-100 position-relative"
@dragstart.prevent.stop
@dragend.prevent.stop
@dragover.prevent.stop
@dragenter.prevent.stop="ondragenter"
@dragleave.prevent.stop="ondragleave"
@drop.prevent.stop="ondrop"
>
<slot>
<button
class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
@click="openFileUpload"
>
<div class="d-flex-center flex-column text-center">
<gl-icon name="doc-new" :size="48" class="mb-4" />
<p>
<gl-sprintf
:message="
__(
'%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}.',
)
"
>
<template #lineOne="{ content }"
><span class="d-block">{{ content }}</span>
</template>
<template #link="{ content }">
<gl-link class="h-100 w-100" @click.stop="openFileUpload">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
</div>
</button>
<input
ref="fileUpload"
type="file"
name="design_file"
:accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
class="hide"
multiple
@change="onDesignInputChange"
/>
</slot>
<transition name="design-dropzone-fade">
<div
v-show="dragging"
class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
>
<div v-show="!isDragDataValid" class="mw-50 text-center">
<h3>{{ __('Oh no!') }}</h3>
<span>{{
__(
'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.',
)
}}</span>
</div>
<div v-show="isDragDataValid" class="mw-50 text-center">
<h3>{{ __('Incoming!') }}</h3>
<span>{{ __('Drop your designs to start your upload.') }}</span>
</div>
</div>
</transition>
</div>
</template>
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import allVersionsMixin from '../../mixins/all_versions';
import { findVersionId } from '../../utils/design_management_utils';
export default {
components: {
GlDropdown,
GlDropdownItem,
},
mixins: [allVersionsMixin],
computed: {
queryVersion() {
return this.$route.query.version;
},
currentVersionIdx() {
if (!this.queryVersion) return 0;
const idx = this.allVersions.findIndex(
version => this.findVersionId(version.node.id) === this.queryVersion,
);
// if the currentVersionId isn't a valid version (i.e. not in allVersions)
// then return the latest version (index 0)
return idx !== -1 ? idx : 0;
},
currentVersionId() {
if (this.queryVersion) return this.queryVersion;
const currentVersion = this.allVersions[this.currentVersionIdx];
return this.findVersionId(currentVersion.node.id);
},
dropdownText() {
if (this.isLatestVersion) {
return __('Showing Latest Version');
}
// allVersions is sorted in reverse chronological order (latest first)
const currentVersionNumber = this.allVersions.length - this.currentVersionIdx;
return sprintf(__('Showing Version #%{versionNumber}'), {
versionNumber: currentVersionNumber,
});
},
},
methods: {
findVersionId,
},
};
</script>
<template>
<gl-dropdown :text="dropdownText" variant="link" class="design-version-dropdown">
<gl-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id">
<router-link
class="d-flex js-version-link"
:to="{ path: $route.path, query: { version: findVersionId(version.node.id) } }"
>
<div class="flex-grow-1 ml-2">
<div>
<strong
>{{ __('Version') }} {{ allVersions.length - index }}
<span v-if="findVersionId(version.node.id) === latestVersionId"
>({{ __('latest') }})</span
>
</strong>
</div>
</div>
<i
v-if="findVersionId(version.node.id) === currentVersionId"
class="fa fa-check pull-right"
></i>
</router-link>
</gl-dropdown-item>
</gl-dropdown>
</template>
// WARNING: replace this with something
// more sensical as per https://gitlab.com/gitlab-org/gitlab/issues/118611
export const VALID_DESIGN_FILE_MIMETYPE = {
mimetype: 'image/*',
regex: /image\/.+/,
};
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
export const VALID_DATA_TRANSFER_TYPE = 'Files';
export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
pin: 'pin',
discussion: 'discussion',
};
export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0'];
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { uniqueId } from 'lodash';
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql';
import typeDefs from './graphql/typedefs.graphql';
Vue.use(VueApollo);
const resolvers = {
Mutation: {
updateActiveDiscussion: (_, { id = null, source }, { cache }) => {
const data = cache.readQuery({ query: activeDiscussionQuery });
data.activeDiscussion = {
__typename: 'ActiveDiscussion',
id,
source,
};
cache.writeQuery({ query: activeDiscussionQuery, data });
},
},
};
const defaultClient = createDefaultClient(
resolvers,
// This config is added temporarily to resolve an issue with duplicate design IDs.
// Should be removed as soon as https://gitlab.com/gitlab-org/gitlab/issues/13495 is resolved
{
cacheConfig: {
dataIdFromObject: object => {
// eslint-disable-next-line no-underscore-dangle, @gitlab/require-i18n-strings
if (object.__typename === 'Design') {
return object.id && object.image ? `${object.id}-${object.image}` : uniqueId();
}
return defaultDataIdFromObject(object);
},
},
typeDefs,
},
);
export default new VueApollo({
defaultClient,
});
#import "./design_note.fragment.graphql"
#import "./design_list.fragment.graphql"
#import "./diff_refs.fragment.graphql"
#import "./discussion_resolved_status.fragment.graphql"
fragment DesignItem on Design {
...DesignListItem
fullPath
diffRefs {
...DesignDiffRefs
}
discussions {
nodes {
id
replyId
...ResolvedStatus
notes {
nodes {
...DesignNote
}
}
}
}
}
fragment DesignListItem on Design {
id
event
filename
notesCount
image
imageV432x230
}
#import "./diff_refs.fragment.graphql"
#import "~/graphql_shared/fragments/author.fragment.graphql"
#import "./note_permissions.fragment.graphql"
fragment DesignNote on Note {
id
author {
...Author
}
body
bodyHtml
createdAt
resolved
position {
diffRefs {
...DesignDiffRefs
}
x
y
height
width
}
userPermissions {
...DesignNotePermissions
}
discussion {
id
}
}
fragment DesignDiffRefs on DiffRefs {
baseSha
startSha
headSha
}
fragment ResolvedStatus on Discussion {
resolvable
resolved
resolvedAt
resolvedBy {
name
webUrl
}
}
#import "../fragments/design_note.fragment.graphql"
mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
createImageDiffNote(input: $input) {
note {
...DesignNote
discussion {
id
replyId
notes {
edges {
node {
...DesignNote
}
}
}
}
}
errors
}
}
#import "../fragments/design_note.fragment.graphql"
mutation createNote($input: CreateNoteInput!) {
createNote(input: $input) {
note {
...DesignNote
}
errors
}
}
#import "../fragments/version.fragment.graphql"
mutation destroyDesign($filenames: [String!]!, $projectPath: ID!, $iid: ID!) {
designManagementDelete(input: { projectPath: $projectPath, iid: $iid, filenames: $filenames }) {
version {
...VersionListItem
}
errors
}
}
#import "../fragments/design_note.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
}
}
mutation updateActiveDiscussion($id: String, $source: String) {
updateActiveDiscussion (id: $id, source: $source ) @client
}
#import "../fragments/design_note.fragment.graphql"
mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) {
updateImageDiffNote(input: $input) {
errors
note {
...DesignNote
}
}
}
#import "../fragments/design_note.fragment.graphql"
mutation updateNote($input: UpdateNoteInput!) {
updateNote(input: $input) {
note {
...DesignNote
}
errors
}
}
#import "../fragments/design.fragment.graphql"
mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) {
designs {
...DesignItem
versions {
edges {
node {
id
sha
}
}
},
}
skippedDesigns {
filename
}
errors
}
}
query activeDiscussion {
activeDiscussion @client {
id
source
}
}
query projectFullPath {
projectPath @client
issueIid @client
}
query permissions($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
id
issue(iid: $iid) {
userPermissions {
createDesign
}
}
}
}
#import "../fragments/design.fragment.graphql"
#import "~/graphql_shared/fragments/author.fragment.graphql"
query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [String!]) {
project(fullPath: $fullPath) {
id
issue(iid: $iid) {
designCollection {
designs(atVersion: $atVersion, filenames: $filenames) {
edges {
node {
...DesignItem
issue {
title
webPath
webUrl
participants {
edges {
node {
...Author
}
}
}
}
}
}
}
}
}
}
}
#import "../fragments/design_list.fragment.graphql"
#import "../fragments/version.fragment.graphql"
query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
project(fullPath: $fullPath) {
id
issue(iid: $iid) {
designCollection {
designs(atVersion: $atVersion) {
edges {
node {
...DesignListItem
}
}
}
versions {
edges {
node {
...VersionListItem
}
}
}
}
}
}
}
type ActiveDiscussion {
id: ID
source: String
}
extend type Query {
activeDiscussion: ActiveDiscussion
}
extend type Mutation {
updateActiveDiscussion(id: ID!, source: String!): Boolean
}
import $ from 'jquery';
import Vue from 'vue';
import createRouter from './router';
import App from './components/app.vue';
import apolloProvider from './graphql';
import getDesignListQuery from './graphql/queries/get_design_list.query.graphql';
import { DESIGNS_ROUTE_NAME, ROOT_ROUTE_NAME } from './router/constants';
export default () => {
const el = document.querySelector('.js-design-management-new');
const badge = document.querySelector('.js-designs-count');
const { issueIid, projectPath, issuePath } = el.dataset;
const router = createRouter(issuePath);
$('.js-issue-tabs').on('shown.bs.tab', ({ target: { id } }) => {
if (id === 'designs' && router.currentRoute.name === ROOT_ROUTE_NAME) {
router.push({ name: DESIGNS_ROUTE_NAME });
} else if (id === 'discussion') {
router.push({ name: ROOT_ROUTE_NAME });
}
});
apolloProvider.clients.defaultClient.cache.writeData({
data: {
projectPath,
issueIid,
activeDiscussion: {
__typename: 'ActiveDiscussion',
id: null,
source: null,
},
},
});
apolloProvider.clients.defaultClient
.watchQuery({
query: getDesignListQuery,
variables: {
fullPath: projectPath,
iid: issueIid,
atVersion: null,
},
})
.subscribe(({ data }) => {
if (badge) {
badge.textContent = data.project.issue.designCollection.designs.edges.length;
}
});
return new Vue({
el,
router,
apolloProvider,
render(createElement) {
return createElement(App);
},
});
};
import { propertyOf } from 'lodash';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import { extractNodes } from '../utils/design_management_utils';
import allVersionsMixin from './all_versions';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
export default {
mixins: [allVersionsMixin],
apollo: {
designs: {
query: getDesignListQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
atVersion: this.designsVersion,
};
},
update: data => {
const designEdges = propertyOf(data)(['project', 'issue', 'designCollection', 'designs']);
if (designEdges) {
return extractNodes(designEdges);
}
return [];
},
error() {
this.error = true;
},
result() {
if (this.$route.query.version && !this.hasValidVersion) {
createFlash(
s__(
'DesignManagement|Requested design version does not exist. Showing latest version instead',
),
);
this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } });
}
},
},
},
data() {
return {
designs: [],
error: false,
};
},
};
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import appDataQuery from '../graphql/queries/app_data.query.graphql';
import { findVersionId } from '../utils/design_management_utils';
export default {
apollo: {
appData: {
query: appDataQuery,
manual: true,
result({ data: { projectPath, issueIid } }) {
this.projectPath = projectPath;
this.issueIid = issueIid;
},
},
allVersions: {
query: getDesignListQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
atVersion: null,
};
},
update: data => data.project.issue.designCollection.versions.edges,
},
},
computed: {
hasValidVersion() {
return (
this.$route.query.version &&
this.allVersions &&
this.allVersions.some(version => version.node.id.endsWith(this.$route.query.version))
);
},
designsVersion() {
return this.hasValidVersion
? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}`
: null;
},
latestVersionId() {
const latestVersion = this.allVersions[0];
return latestVersion && findVersionId(latestVersion.node.id);
},
isLatestVersion() {
if (this.allVersions.length > 0) {
return (
!this.$route.query.version ||
!this.latestVersionId ||
this.$route.query.version === this.latestVersionId
);
}
return true;
},
},
data() {
return {
allVersions: [],
projectPath: '',
issueIid: null,
};
},
};
<script>
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 DesignDestroyer from '../../components/design_destroyer.vue';
import DesignScaler from '../../components/design_scaler.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/get_design.query.graphql';
import appDataQuery from '../../graphql/queries/app_data.query.graphql';
import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql';
import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql';
import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql';
import {
extractDiscussions,
extractDesign,
updateImageDiffNoteOptimisticResponse,
} from '../../utils/design_management_utils';
import {
updateStoreAfterAddImageDiffNote,
updateStoreAfterUpdateImageDiffNote,
} from '../../utils/cache_update';
import {
ADD_DISCUSSION_COMMENT_ERROR,
ADD_IMAGE_DIFF_NOTE_ERROR,
UPDATE_IMAGE_DIFF_NOTE_ERROR,
DESIGN_NOT_FOUND_ERROR,
DESIGN_VERSION_NOT_EXIST_ERROR,
UPDATE_NOTE_ERROR,
designDeletionError,
} from '../../utils/error_messages';
import { trackDesignDetailView } from '../../utils/tracking';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
export default {
components: {
ApolloMutation,
DesignReplyForm,
DesignPresentation,
DesignScaler,
DesignDestroyer,
Toolbar,
GlLoadingIcon,
GlAlert,
DesignSidebar,
},
mixins: [allVersionsMixin],
props: {
id: {
type: String,
required: true,
},
},
data() {
return {
design: {},
comment: '',
annotationCoordinates: null,
projectPath: '',
errorMessage: '',
issueIid: '',
scale: 1,
resolvedDiscussionsExpanded: false,
};
},
apollo: {
appData: {
query: appDataQuery,
manual: true,
result({ data: { projectPath, issueIid } }) {
this.projectPath = projectPath;
this.issueIid = issueIid;
},
},
design: {
query: getDesignQuery,
// We want to see cached design version if we have one, and fetch newer version on the background to update discussions
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
variables() {
return this.designVariables;
},
update: data => extractDesign(data),
result(res) {
this.onDesignQueryResult(res);
},
error() {
this.onQueryError(DESIGN_NOT_FOUND_ERROR);
},
},
},
computed: {
isFirstLoading() {
// We only want to show spinner on initial design load (when opened from a deep link to design)
// If we already have cached a design, loading shouldn't be indicated to user
return this.$apollo.queries.design.loading && !this.design.filename;
},
discussions() {
if (!this.design.discussions) {
return [];
}
return extractDiscussions(this.design.discussions);
},
markdownPreviewPath() {
return `/${this.projectPath}/preview_markdown?target_type=Issue`;
},
isSubmitButtonDisabled() {
return this.comment.trim().length === 0;
},
designVariables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
filenames: [this.$route.params.id],
atVersion: this.designsVersion,
};
},
mutationPayload() {
const { x, y, width, height } = this.annotationCoordinates;
return {
noteableId: this.design.id,
body: this.comment,
position: {
headSha: this.design.diffRefs.headSha,
baseSha: this.design.diffRefs.baseSha,
startSha: this.design.diffRefs.startSha,
x,
y,
width,
height,
paths: {
newPath: this.design.fullPath,
},
},
};
},
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);
this.trackEvent();
// We need to reset the active discussion when opening a new design
this.updateActiveDiscussion();
},
beforeDestroy() {
Mousetrap.unbind('esc', this.closeDesign);
},
methods: {
addImageDiffNoteToStore(
store,
{
data: { createImageDiffNote },
},
) {
updateStoreAfterAddImageDiffNote(
store,
createImageDiffNote,
getDesignQuery,
this.designVariables,
);
},
updateImageDiffNoteInStore(
store,
{
data: { updateImageDiffNote },
},
) {
return updateStoreAfterUpdateImageDiffNote(
store,
updateImageDiffNote,
getDesignQuery,
this.designVariables,
);
},
onMoveNote({ noteId, discussionId, position }) {
const discussion = this.discussions.find(({ id }) => id === discussionId);
const note = discussion.notes.find(
({ discussion: noteDiscussion }) => noteDiscussion.id === discussionId,
);
const mutationPayload = {
optimisticResponse: updateImageDiffNoteOptimisticResponse(note, {
position,
}),
variables: {
input: {
id: noteId,
position,
},
},
mutation: updateImageDiffNoteMutation,
update: this.updateImageDiffNoteInStore,
};
return this.$apollo.mutate(mutationPayload).catch(e => this.onUpdateImageDiffNoteError(e));
},
onDesignQueryResult({ data, loading }) {
// On the initial load with cache-and-network policy data is undefined while loading is true
// To prevent throwing an error, we don't perform any logic until loading is false
if (loading) {
return;
}
if (!data || !extractDesign(data)) {
this.onQueryError(DESIGN_NOT_FOUND_ERROR);
} else if (this.$route.query.version && !this.hasValidVersion) {
this.onQueryError(DESIGN_VERSION_NOT_EXIST_ERROR);
}
},
onQueryError(message) {
// because we redirect user to /designs (the issue page),
// we want to create these flashes on the issue page
createFlash(message);
this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME });
},
onError(message, e) {
this.errorMessage = message;
throw e;
},
onCreateImageDiffNoteError(e) {
this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e);
},
onUpdateNoteError(e) {
this.onError(UPDATE_NOTE_ERROR, e);
},
onDesignDiscussionError(e) {
this.onError(ADD_DISCUSSION_COMMENT_ERROR, e);
},
onUpdateImageDiffNoteError(e) {
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
},
onDesignDeleteError(e) {
this.onError(designDeletionError({ singular: true }), e);
},
onResolveDiscussionError(e) {
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
},
openCommentForm(annotationCoordinates) {
this.annotationCoordinates = annotationCoordinates;
if (this.$refs.newDiscussionForm) {
this.$refs.newDiscussionForm.focusInput();
}
},
closeCommentForm() {
this.comment = '';
this.annotationCoordinates = null;
},
closeDesign() {
this.$router.push({
name: this.$options.DESIGNS_ROUTE_NAME,
query: this.$route.query,
});
},
trackEvent() {
// TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue
trackDesignDetailView(
'issue-design-collection',
'issue',
this.$route.query.version || this.latestVersionId,
this.isLatestVersion,
);
},
updateActiveDiscussion(id) {
this.$apollo.mutate({
mutation: updateActiveDiscussionMutation,
variables: {
id,
source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion,
},
});
},
toggleResolvedComments() {
this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded;
},
},
createImageDiffNoteMutation,
DESIGNS_ROUTE_NAME,
};
</script>
<template>
<div
class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
>
<gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" />
<template v-else>
<div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative">
<design-destroyer
:filenames="[design.filename]"
:project-path="projectPath"
:iid="issueIid"
@done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })"
@error="onDesignDeleteError"
>
<template #default="{ mutate, loading }">
<toolbar
:id="id"
:is-deleting="loading"
:is-latest-version="isLatestVersion"
v-bind="design"
@delete="mutate"
/>
</template>
</design-destroyer>
<div v-if="errorMessage" class="p-3">
<gl-alert variant="danger" @dismiss="errorMessage = null">
{{ errorMessage }}
</gl-alert>
</div>
<design-presentation
:image="design.image"
:image-name="design.filename"
:discussions="discussions"
:is-annotating="isAnnotating"
:scale="scale"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
@openCommentForm="openCommentForm"
@closeCommentForm="closeCommentForm"
@moveNote="onMoveNote"
/>
<div class="design-scaler-wrapper position-absolute mb-4 d-flex-center">
<design-scaler @scale="scale = $event" />
</div>
</div>
<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="isAnnotating"
#default="{ mutate, loading }"
:mutation="$options.createImageDiffNoteMutation"
:variables="{
input: mutationPayload,
}"
:update="addImageDiffNoteToStore"
@done="closeCommentForm"
@error="onCreateImageDiffNoteError"
>
<design-reply-form
ref="newDiscussionForm"
v-model="comment"
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
@submitForm="mutate"
@cancelForm="closeCommentForm"
/> </apollo-mutation
></template>
</design-sidebar>
</template>
</div>
</template>
<script>
import { GlLoadingIcon, GlDeprecatedButton, GlAlert } from '@gitlab/ui';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
import UploadButton from '../components/upload/button.vue';
import DeleteButton from '../components/delete_button.vue';
import Design from '../components/list/item.vue';
import DesignDestroyer from '../components/design_destroyer.vue';
import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
import DesignDropzone from '../components/upload/design_dropzone.vue';
import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
import permissionsQuery from '../graphql/queries/design_permissions.query.graphql';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import allDesignsMixin from '../mixins/all_designs';
import {
UPLOAD_DESIGN_ERROR,
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
designUploadSkippedWarning,
designDeletionError,
} from '../utils/error_messages';
import { updateStoreAfterUploadDesign } from '../utils/cache_update';
import {
designUploadOptimisticResponse,
isValidDesignFile,
} from '../utils/design_management_utils';
import { getFilename } from '~/lib/utils/file_upload';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
export default {
components: {
GlLoadingIcon,
GlAlert,
GlDeprecatedButton,
UploadButton,
Design,
DesignDestroyer,
DesignVersionDropdown,
DeleteButton,
DesignDropzone,
},
mixins: [allDesignsMixin],
apollo: {
permissions: {
query: permissionsQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
};
},
update: data => data.project.issue.userPermissions,
},
},
data() {
return {
permissions: {
createDesign: false,
},
filesToBeSaved: [],
selectedDesigns: [],
};
},
computed: {
isLoading() {
return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading;
},
isSaving() {
return this.filesToBeSaved.length > 0;
},
canCreateDesign() {
return this.permissions.createDesign;
},
showToolbar() {
return this.canCreateDesign && this.allVersions.length > 0;
},
hasDesigns() {
return this.designs.length > 0;
},
hasSelectedDesigns() {
return this.selectedDesigns.length > 0;
},
canDeleteDesigns() {
return this.isLatestVersion && this.hasSelectedDesigns;
},
projectQueryBody() {
return {
query: getDesignListQuery,
variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null },
};
},
selectAllButtonText() {
return this.hasSelectedDesigns
? s__('DesignManagement|Deselect all')
: s__('DesignManagement|Select all');
},
},
mounted() {
this.toggleOnPasteListener(this.$route.name);
},
methods: {
resetFilesToBeSaved() {
this.filesToBeSaved = [];
},
/**
* Determine if a design upload is valid, given [files]
* @param {Array<File>} files
*/
isValidDesignUpload(files) {
if (!this.canCreateDesign) return false;
if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
createFlash(
sprintf(
s__(
'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
),
{
upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
},
),
);
return false;
}
return true;
},
onUploadDesign(files) {
// convert to Array so that we have Array methods (.map, .some, etc.)
this.filesToBeSaved = Array.from(files);
if (!this.isValidDesignUpload(this.filesToBeSaved)) return null;
const mutationPayload = {
optimisticResponse: designUploadOptimisticResponse(this.filesToBeSaved),
variables: {
files: this.filesToBeSaved,
projectPath: this.projectPath,
iid: this.issueIid,
},
context: {
hasUpload: true,
},
mutation: uploadDesignMutation,
update: this.afterUploadDesign,
};
return this.$apollo
.mutate(mutationPayload)
.then(res => this.onUploadDesignDone(res))
.catch(() => this.onUploadDesignError());
},
afterUploadDesign(
store,
{
data: { designManagementUpload },
},
) {
updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody);
},
onUploadDesignDone(res) {
const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
if (skippedWarningMessage) {
createFlash(skippedWarningMessage, 'warning');
}
// if this upload resulted in a new version being created, redirect user to the latest version
if (!this.isLatestVersion) {
this.$router.push({ name: DESIGNS_ROUTE_NAME });
}
this.resetFilesToBeSaved();
},
onUploadDesignError() {
this.resetFilesToBeSaved();
createFlash(UPLOAD_DESIGN_ERROR);
},
changeSelectedDesigns(filename) {
if (this.isDesignSelected(filename)) {
this.selectedDesigns = this.selectedDesigns.filter(design => design !== filename);
} else {
this.selectedDesigns.push(filename);
}
},
toggleDesignsSelection() {
if (this.hasSelectedDesigns) {
this.selectedDesigns = [];
} else {
this.selectedDesigns = this.designs.map(design => design.filename);
}
},
isDesignSelected(filename) {
return this.selectedDesigns.includes(filename);
},
isDesignToBeSaved(filename) {
return this.filesToBeSaved.some(file => file.name === filename);
},
canSelectDesign(filename) {
return this.isLatestVersion && this.canCreateDesign && !this.isDesignToBeSaved(filename);
},
onDesignDelete() {
this.selectedDesigns = [];
if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME });
},
onDesignDeleteError() {
const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 });
createFlash(errorMessage);
},
onExistingDesignDropzoneChange(files, existingDesignFilename) {
const filesArr = Array.from(files);
if (filesArr.length > 1) {
createFlash(EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE);
return;
}
if (!filesArr.some(({ name }) => existingDesignFilename === name)) {
createFlash(EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE);
return;
}
this.onUploadDesign(files);
},
onDesignPaste(event) {
const { clipboardData } = event;
const files = Array.from(clipboardData.files);
if (clipboardData && files.length > 0) {
if (!files.some(isValidDesignFile)) {
return;
}
event.preventDefault();
let filename = getFilename(event);
if (!filename || filename === 'image.png') {
filename = `design_${Date.now()}.png`;
}
const newFile = new File([files[0]], filename);
this.onUploadDesign([newFile]);
}
},
toggleOnPasteListener(route) {
if (route === DESIGNS_ROUTE_NAME) {
document.addEventListener('paste', this.onDesignPaste);
} else {
document.removeEventListener('paste', this.onDesignPaste);
}
},
},
beforeRouteUpdate(to, from, next) {
this.toggleOnPasteListener(to.name);
this.selectedDesigns = [];
next();
},
beforeRouteLeave(to, from, next) {
this.toggleOnPasteListener(to.name);
next();
},
};
</script>
<template>
<div>
<header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex">
<div class="d-flex justify-content-between align-items-center w-100">
<design-version-dropdown />
<div :class="['qa-selector-toolbar', { 'd-flex': hasDesigns, 'd-none': !hasDesigns }]">
<gl-deprecated-button
v-if="isLatestVersion"
variant="link"
class="mr-2 js-select-all"
@click="toggleDesignsSelection"
>{{ selectAllButtonText }}</gl-deprecated-button
>
<design-destroyer
#default="{ mutate, loading }"
:filenames="selectedDesigns"
:project-path="projectPath"
:iid="issueIid"
@done="onDesignDelete"
@error="onDesignDeleteError"
>
<delete-button
v-if="isLatestVersion"
:is-deleting="loading"
button-class="btn-danger btn-inverted mr-2"
:has-selected-designs="hasSelectedDesigns"
@deleteSelectedDesigns="mutate()"
>
{{ s__('DesignManagement|Delete selected') }}
<gl-loading-icon v-if="loading" inline class="ml-1" />
</delete-button>
</design-destroyer>
<upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" />
</div>
</div>
</header>
<div class="mt-4">
<gl-loading-icon v-if="isLoading" size="md" />
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
{{ __('An error occurred while loading designs. Please try again.') }}
</gl-alert>
<ol v-else class="list-unstyled row">
<li class="col-md-6 col-lg-4 mb-3">
<design-dropzone class="design-list-item" @change="onUploadDesign" />
</li>
<li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-4 mb-3">
<design-dropzone @change="onExistingDesignDropzoneChange($event, design.filename)"
><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)"
/></design-dropzone>
<input
v-if="canSelectDesign(design.filename)"
:checked="isDesignSelected(design.filename)"
type="checkbox"
class="design-checkbox"
@change="changeSelectedDesigns(design.filename)"
/>
</li>
</ol>
</div>
<router-view :key="$route.fullPath" />
</div>
</template>
export const ROOT_ROUTE_NAME = 'root';
export const DESIGNS_ROUTE_NAME = 'designs';
export const DESIGN_ROUTE_NAME = 'design';
import $ from 'jquery';
import Vue from 'vue';
import VueRouter from 'vue-router';
import routes from './routes';
import { DESIGN_ROUTE_NAME } from './constants';
import { getPageLayoutElement } from '~/design_management_new/utils/design_management_utils';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants';
Vue.use(VueRouter);
export default function createRouter(base) {
const router = new VueRouter({
base,
mode: 'history',
routes,
});
const pageEl = getPageLayoutElement();
router.beforeEach(({ meta: { el }, name }, _, next) => {
$(`#${el}`).tab('show');
// apply a fullscreen layout style in Design View (a.k.a design detail)
if (pageEl) {
if (name === DESIGN_ROUTE_NAME) {
pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
} else {
pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
}
}
next();
});
return router;
}
import Home from '../pages/index.vue';
import DesignDetail from '../pages/design/index.vue';
import { ROOT_ROUTE_NAME, DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants';
export default [
{
name: ROOT_ROUTE_NAME,
path: '/',
component: Home,
meta: {
el: 'discussion',
},
},
{
name: DESIGNS_ROUTE_NAME,
path: '/designs',
component: Home,
meta: {
el: 'designs',
},
children: [
{
name: DESIGN_ROUTE_NAME,
path: ':id',
component: DesignDetail,
meta: {
el: 'designs',
},
beforeEnter(
{
params: { id },
},
from,
next,
) {
if (typeof id === 'string') {
next();
}
},
props: ({ params: { id } }) => ({ id }),
},
],
},
];
/* eslint-disable @gitlab/require-i18n-strings */
import createFlash from '~/flash';
import { extractCurrentDiscussion, extractDesign } from './design_management_utils';
import {
ADD_IMAGE_DIFF_NOTE_ERROR,
UPDATE_IMAGE_DIFF_NOTE_ERROR,
ADD_DISCUSSION_COMMENT_ERROR,
designDeletionError,
} from './error_messages';
const deleteDesignsFromStore = (store, query, selectedDesigns) => {
const data = store.readQuery(query);
const changedDesigns = data.project.issue.designCollection.designs.edges.filter(
({ node }) => !selectedDesigns.includes(node.filename),
);
data.project.issue.designCollection.designs.edges = [...changedDesigns];
store.writeQuery({
...query,
data,
});
};
/**
* Adds a new version of designs to store
*
* @param {Object} store
* @param {Object} query
* @param {Object} version
*/
const addNewVersionToStore = (store, query, version) => {
if (!version) return;
const data = store.readQuery(query);
const newEdge = { node: version, __typename: 'DesignVersionEdge' };
data.project.issue.designCollection.versions.edges = [
newEdge,
...data.project.issue.designCollection.versions.edges,
];
store.writeQuery({
...query,
data,
});
};
const addDiscussionCommentToStore = (store, createNote, query, queryVariables, discussionId) => {
const data = store.readQuery({
query,
variables: queryVariables,
});
const design = extractDesign(data);
const currentDiscussion = extractCurrentDiscussion(design.discussions, discussionId);
currentDiscussion.notes.nodes = [...currentDiscussion.notes.nodes, createNote.note];
design.notesCount += 1;
if (
!design.issue.participants.edges.some(
participant => participant.node.username === createNote.note.author.username,
)
) {
design.issue.participants.edges = [
...design.issue.participants.edges,
{
__typename: 'UserEdge',
node: {
__typename: 'User',
...createNote.note.author,
},
},
];
}
store.writeQuery({
query,
variables: queryVariables,
data: {
...data,
design: {
...design,
},
},
});
};
const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) => {
const data = store.readQuery({
query,
variables,
});
const newDiscussion = {
__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],
},
};
const design = extractDesign(data);
const notesCount = design.notesCount + 1;
design.discussions.nodes = [...design.discussions.nodes, newDiscussion];
if (
!design.issue.participants.edges.some(
participant => participant.node.username === createImageDiffNote.note.author.username,
)
) {
design.issue.participants.edges = [
...design.issue.participants.edges,
{
__typename: 'UserEdge',
node: {
__typename: 'User',
...createImageDiffNote.note.author,
},
},
];
}
store.writeQuery({
query,
variables,
data: {
...data,
design: {
...design,
notesCount,
},
},
});
};
const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => {
const data = store.readQuery({
query,
variables,
});
const design = extractDesign(data);
const discussion = extractCurrentDiscussion(
design.discussions,
updateImageDiffNote.note.discussion.id,
);
discussion.notes = {
...discussion.notes,
nodes: [updateImageDiffNote.note, ...discussion.notes.nodes.slice(1)],
};
store.writeQuery({
query,
variables,
data: {
...data,
design,
},
});
};
const addNewDesignToStore = (store, designManagementUpload, query) => {
const data = store.readQuery(query);
const newDesigns = data.project.issue.designCollection.designs.edges.reduce((acc, design) => {
if (!acc.find(d => d.filename === design.node.filename)) {
acc.push(design.node);
}
return acc;
}, designManagementUpload.designs);
let newVersionNode;
const findNewVersions = designManagementUpload.designs.find(design => design.versions);
if (findNewVersions) {
const findNewVersionsEdges = findNewVersions.versions.edges;
if (findNewVersionsEdges && findNewVersionsEdges.length) {
newVersionNode = [findNewVersionsEdges[0]];
}
}
const newVersions = [
...(newVersionNode || []),
...data.project.issue.designCollection.versions.edges,
];
const updatedDesigns = {
__typename: 'DesignCollection',
designs: {
__typename: 'DesignConnection',
edges: newDesigns.map(design => ({
__typename: 'DesignEdge',
node: design,
})),
},
versions: {
__typename: 'DesignVersionConnection',
edges: newVersions,
},
};
data.project.issue.designCollection = updatedDesigns;
store.writeQuery({
...query,
data,
});
};
const onError = (data, message) => {
createFlash(message);
throw new Error(data.errors);
};
export const hasErrors = ({ errors = [] }) => errors?.length;
/**
* Updates a store after design deletion
*
* @param {Object} store
* @param {Object} data
* @param {Object} query
* @param {Array} designs
*/
export const updateStoreAfterDesignsDelete = (store, data, query, designs) => {
if (hasErrors(data)) {
onError(data, designDeletionError({ singular: designs.length === 1 }));
} else {
deleteDesignsFromStore(store, query, designs);
addNewVersionToStore(store, query, data.version);
}
};
export const updateStoreAfterAddDiscussionComment = (
store,
data,
query,
queryVariables,
discussionId,
) => {
if (hasErrors(data)) {
onError(data, ADD_DISCUSSION_COMMENT_ERROR);
} else {
addDiscussionCommentToStore(store, data, query, queryVariables, discussionId);
}
};
export const updateStoreAfterAddImageDiffNote = (store, data, query, queryVariables) => {
if (hasErrors(data)) {
onError(data, ADD_IMAGE_DIFF_NOTE_ERROR);
} else {
addImageDiffNoteToStore(store, data, query, queryVariables);
}
};
export const updateStoreAfterUpdateImageDiffNote = (store, data, query, queryVariables) => {
if (hasErrors(data)) {
onError(data, UPDATE_IMAGE_DIFF_NOTE_ERROR);
} else {
updateImageDiffNoteInStore(store, data, query, queryVariables);
}
};
export const updateStoreAfterUploadDesign = (store, data, query) => {
if (hasErrors(data)) {
onError(data, data.errors[0]);
} else {
addNewDesignToStore(store, data, query);
}
};
import { uniqueId } from 'lodash';
import { VALID_DESIGN_FILE_MIMETYPE } from '../constants';
export const isValidDesignFile = ({ type }) =>
(type.match(VALID_DESIGN_FILE_MIMETYPE.regex) || []).length > 0;
/**
* Returns formatted array that doesn't contain
* `edges`->`node` nesting
*
* @param {Array} elements
*/
export const extractNodes = elements => elements.edges.map(({ node }) => node);
/**
* Returns formatted array of discussions that doesn't contain
* `edges`->`node` nesting for child notes
*
* @param {Array} discussions
*/
export const extractDiscussions = discussions =>
discussions.nodes.map((discussion, index) => ({
...discussion,
index: index + 1,
notes: discussion.notes.nodes,
}));
/**
* Returns a discussion with the given id from discussions array
*
* @param {Array} discussions
*/
export const extractCurrentDiscussion = (discussions, id) =>
discussions.nodes.find(discussion => discussion.id === id);
export const findVersionId = id => (id.match('::Version/(.+$)') || [])[1];
export const findNoteId = id => (id.match('DiffNote/(.+$)') || [])[1];
export const extractDesigns = data => data.project.issue.designCollection.designs.edges;
export const extractDesign = data => (extractDesigns(data) || [])[0]?.node;
/**
* Generates optimistic response for a design upload mutation
* @param {Array<File>} files
*/
export const designUploadOptimisticResponse = files => {
const designs = files.map(file => ({
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Design',
id: -uniqueId(),
image: '',
imageV432x230: '',
filename: file.name,
fullPath: '',
notesCount: 0,
event: 'NONE',
diffRefs: {
__typename: 'DiffRefs',
baseSha: '',
startSha: '',
headSha: '',
},
discussions: {
__typename: 'DesignDiscussion',
nodes: [],
},
versions: {
__typename: 'DesignVersionConnection',
edges: {
__typename: 'DesignVersionEdge',
node: {
__typename: 'DesignVersion',
id: -uniqueId(),
sha: -uniqueId(),
},
},
},
}));
return {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Mutation',
designManagementUpload: {
__typename: 'DesignManagementUploadPayload',
designs,
skippedDesigns: [],
errors: [],
},
};
};
/**
* Generates optimistic response for a design upload mutation
* @param {Array<File>} files
*/
export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Mutation',
updateImageDiffNote: {
__typename: 'UpdateImageDiffNotePayload',
note: {
...note,
position: {
...note.position,
...position,
},
},
errors: [],
},
});
const normalizeAuthor = author => ({
...author,
web_url: author.webUrl,
avatar_url: author.avatarUrl,
});
export const extractParticipants = users => users.edges.map(({ node }) => normalizeAuthor(node));
export const getPageLayoutElement = () => document.querySelector('.layout-page');
import { __, s__, n__, sprintf } from '~/locale';
export const ADD_DISCUSSION_COMMENT_ERROR = s__(
'DesignManagement|Could not add a new comment. Please try again.',
);
export const ADD_IMAGE_DIFF_NOTE_ERROR = s__(
'DesignManagement|Could not create new discussion. Please try again.',
);
export const UPDATE_IMAGE_DIFF_NOTE_ERROR = s__(
'DesignManagement|Could not update discussion. Please try again.',
);
export const UPDATE_NOTE_ERROR = s__('DesignManagement|Could not update note. Please try again.');
export const UPLOAD_DESIGN_ERROR = s__(
'DesignManagement|Error uploading a new design. Please try again.',
);
export const UPLOAD_DESIGN_INVALID_FILETYPE_ERROR = __(
'Could not upload your designs as one or more files uploaded are not supported.',
);
export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.');
export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.');
const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped.');
const ALL_DESIGNS_SKIPPED_MESSAGE = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
'The designs you tried uploading did not change.',
)}`;
export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __(
'You can only upload one design when dropping onto an existing design.',
);
export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __(
'You must upload a file with the same file name when dropping onto an existing design.',
);
const MAX_SKIPPED_FILES_LISTINGS = 5;
const oneDesignSkippedMessage = filename =>
`${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${sprintf(s__('DesignManagement|%{filename} did not change.'), {
filename,
})}`;
/**
* Return warning message indicating that some (but not all) uploaded
* files were skipped.
* @param {Array<{ filename }>} skippedFiles
*/
const someDesignsSkippedMessage = skippedFiles => {
const designsSkippedMessage = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
'Some of the designs you tried uploading did not change:',
)}`;
const moreText = sprintf(s__(`DesignManagement|and %{moreCount} more.`), {
moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS,
});
return `${designsSkippedMessage} ${skippedFiles
.slice(0, MAX_SKIPPED_FILES_LISTINGS)
.map(({ filename }) => filename)
.join(', ')}${skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS ? `, ${moreText}` : '.'}`;
};
export const designDeletionError = ({ singular = true } = {}) => {
const design = singular ? __('a design') : __('designs');
return sprintf(s__('Could not delete %{design}. Please try again.'), {
design,
});
};
/**
* Return warning message, if applicable, that one, some or all uploaded
* files were skipped.
* @param {Array<{ filename }>} uploadedDesigns
* @param {Array<{ filename }>} skippedFiles
*/
export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => {
if (skippedFiles.length === 0) {
return null;
}
if (skippedFiles.length === uploadedDesigns.length) {
const { filename } = skippedFiles[0];
return n__(oneDesignSkippedMessage(filename), ALL_DESIGNS_SKIPPED_MESSAGE, skippedFiles.length);
}
return someDesignsSkippedMessage(skippedFiles);
};
import Tracking from '~/tracking';
function assembleDesignPayload(payloadArr) {
return {
value: {
'internal-object-refrerer': payloadArr[0],
'design-collection-owner': payloadArr[1],
'design-version-number': payloadArr[2],
'design-is-current-version': payloadArr[3],
},
};
}
// Tracking Constants
const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
// eslint-disable-next-line import/prefer-default-export
export function trackDesignDetailView(
referer = '',
owner = '',
designVersion = 1,
latestVersion = false,
) {
Tracking.event(DESIGN_TRACKING_PAGE_NAME, 'design_viewed', {
label: 'design_viewed',
...assembleDesignPayload([referer, owner, designVersion, latestVersion]),
});
}
......@@ -13,14 +13,15 @@ export default function() {
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
// .js-design-management is currently EE-only.
// This will be moved to CE as part of https://gitlab.com/gitlab-org/gitlab/-/issues/212566#frontend
// at which point this conditional can be removed.
if (document.querySelector('.js-design-management')) {
import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then(module => module.default())
.catch(() => {});
}
import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then(module => module.default())
.catch(() => {});
// This will be removed when we remove the `design_management_moved` feature flag
// See https://gitlab.com/gitlab-org/gitlab/-/issues/223197
import(/* webpackChunkName: 'design_management' */ '~/design_management_new')
.then(module => module.default())
.catch(() => {});
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
......
- if @project.design_management_enabled?
.js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
- if Feature.enabled?(:design_management_moved, @project)
.js-design-management-new{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
- else
.js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
- else
.mt-4
.row.empty-state
......
......@@ -27,6 +27,10 @@ RSpec.describe 'viewing issues with design references' do
MD
end
before do
stub_feature_flags(design_management_moved: false)
end
def visit_page_with_design_references
public_issue = create(:issue, project: public_project, description: description)
visit project_issue_path(public_issue.project, public_issue)
......
......@@ -10,6 +10,7 @@ RSpec.describe 'User paginates issue designs', :js do
before do
enable_design_management
stub_feature_flags(design_management_moved: false)
create_list(:design, 2, :with_file, issue: issue)
......
......@@ -10,6 +10,7 @@ RSpec.describe 'User design permissions', :js do
before do
enable_design_management
stub_feature_flags(design_management_moved: false)
visit project_issue_path(project, issue)
......
......@@ -16,6 +16,7 @@ RSpec.describe 'User uploads new design', :js do
context "when the feature is available" do
before do
enable_design_management
stub_feature_flags(design_management_moved: false)
visit project_issue_path(project, issue)
......
......@@ -13,6 +13,7 @@ RSpec.describe 'Users views raw design image files' do
before do
enable_design_management
stub_feature_flags(design_management_moved: false)
end
it 'serves the latest design version when no ref is given' do
......
......@@ -11,6 +11,7 @@ RSpec.describe 'User views issue designs', :js do
before do
enable_design_management
stub_feature_flags(design_management_moved: false)
visit project_issue_path(project, issue)
......
......@@ -11,6 +11,7 @@ RSpec.describe 'User views issue designs', :js do
before do
enable_design_management
stub_feature_flags(design_management_moved: false)
end
context 'navigates from the issue view' do
......
......@@ -12,6 +12,7 @@ RSpec.describe 'User views an SVG design that contains XSS', :js do
before do
enable_design_management
stub_feature_flags(design_management_moved: false)
visit designs_project_issue_path(
project,
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design note pin component should match the snapshot of note when repositioning 1`] = `
<button
aria-label="Comment form position"
class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center btn-transparent comment-indicator"
style="left: 10px; top: 10px; cursor: move;"
type="button"
>
<icon-stub
name="image-comment-dark"
size="16"
/>
</button>
`;
exports[`Design note pin component should match the snapshot of note with index 1`] = `
<button
aria-label="Comment '1' position"
class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center js-image-badge badge badge-pill"
style="left: 10px; top: 10px;"
type="button"
>
1
</button>
`;
exports[`Design note pin component should match the snapshot of note without index 1`] = `
<button
aria-label="Comment form position"
class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center btn-transparent comment-indicator"
style="left: 10px; top: 10px;"
type="button"
>
<icon-stub
name="image-comment-dark"
size="16"
/>
</button>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management design presentation component currentCommentForm is equal to current annotation position when isAnnotating is true 1`] = `
<div
class="h-100 w-100 p-3 overflow-auto position-relative"
>
<div
class="h-100 w-100 d-flex align-items-center position-relative"
>
<design-image-stub
image="test.jpg"
name="test"
scale="1"
/>
<design-overlay-stub
currentcommentform="[object Object]"
dimensions="[object Object]"
notes=""
position="[object Object]"
/>
</div>
</div>
`;
exports[`Design management design presentation component currentCommentForm is null when isAnnotating is false 1`] = `
<div
class="h-100 w-100 p-3 overflow-auto position-relative"
>
<div
class="h-100 w-100 d-flex align-items-center position-relative"
>
<design-image-stub
image="test.jpg"
name="test"
scale="1"
/>
<design-overlay-stub
dimensions="[object Object]"
notes=""
position="[object Object]"
/>
</div>
</div>
`;
exports[`Design management design presentation component currentCommentForm is null when isAnnotating is true but annotation position is falsey 1`] = `
<div
class="h-100 w-100 p-3 overflow-auto position-relative"
>
<div
class="h-100 w-100 d-flex align-items-center position-relative"
>
<design-image-stub
image="test.jpg"
name="test"
scale="1"
/>
<design-overlay-stub
dimensions="[object Object]"
notes=""
position="[object Object]"
/>
</div>
</div>
`;
exports[`Design management design presentation component renders empty state when no image provided 1`] = `
<div
class="h-100 w-100 p-3 overflow-auto position-relative"
>
<div
class="h-100 w-100 d-flex align-items-center position-relative"
>
<!---->
<!---->
</div>
</div>
`;
exports[`Design management design presentation component renders image and overlay when image provided 1`] = `
<div
class="h-100 w-100 p-3 overflow-auto position-relative"
>
<div
class="h-100 w-100 d-flex align-items-center position-relative"
>
<design-image-stub
image="test.jpg"
name="test"
scale="1"
/>
<design-overlay-stub
dimensions="[object Object]"
notes=""
position="[object Object]"
/>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management design scaler component minus and reset buttons are disabled when scale === 1 1`] = `
<div
class="design-scaler btn-group"
role="group"
>
<button
class="btn"
disabled="disabled"
>
<span
class="d-flex-center gl-icon s16"
>
</span>
</button>
<button
class="btn"
disabled="disabled"
>
<gl-icon-stub
name="redo"
size="16"
/>
</button>
<button
class="btn"
>
<gl-icon-stub
name="plus"
size="16"
/>
</button>
</div>
`;
exports[`Design management design scaler component minus and reset buttons are enabled when scale > 1 1`] = `
<div
class="design-scaler btn-group"
role="group"
>
<button
class="btn"
>
<span
class="d-flex-center gl-icon s16"
>
</span>
</button>
<button
class="btn"
>
<gl-icon-stub
name="redo"
size="16"
/>
</button>
<button
class="btn"
>
<gl-icon-stub
name="plus"
size="16"
/>
</button>
</div>
`;
exports[`Design management design scaler component plus button is disabled when scale === 2 1`] = `
<div
class="design-scaler btn-group"
role="group"
>
<button
class="btn"
>
<span
class="d-flex-center gl-icon s16"
>
</span>
</button>
<button
class="btn"
>
<gl-icon-stub
name="redo"
size="16"
/>
</button>
<button
class="btn"
disabled="disabled"
>
<gl-icon-stub
name="plus"
size="16"
/>
</button>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management large image component renders image 1`] = `
<div
class="m-auto js-design-image"
>
<!---->
<img
alt="test"
class="mh-100 img-fluid"
src="test.jpg"
/>
</div>
`;
exports[`Design management large image component renders loading state 1`] = `
<div
class="m-auto js-design-image"
isloading="true"
>
<!---->
<img
alt=""
class="mh-100 img-fluid"
src=""
/>
</div>
`;
exports[`Design management large image component renders media broken icon on error 1`] = `
<gl-icon-stub
class="text-secondary-100"
name="media-broken"
size="48"
/>
`;
exports[`Design management large image component sets correct classes and styles if imageStyle is set 1`] = `
<div
class="m-auto js-design-image"
>
<!---->
<img
alt="test"
class="mh-100"
src="test.jpg"
style="width: 100px; height: 100px;"
/>
</div>
`;
exports[`Design management large image component zoom sets image style when zoomed 1`] = `
<div
class="m-auto js-design-image"
>
<!---->
<img
alt="test"
class="mh-100"
src="test.jpg"
style="width: 200px; height: 200px;"
/>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui';
import BatchDeleteButton from '~/design_management_new/components/delete_button.vue';
describe('Batch delete button component', () => {
let wrapper;
const findButton = () => wrapper.find(GlDeprecatedButton);
const findModal = () => wrapper.find(GlModal);
function createComponent(isDeleting = false) {
wrapper = shallowMount(BatchDeleteButton, {
propsData: {
isDeleting,
},
directives: {
GlModalDirective,
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('renders non-disabled button by default', () => {
createComponent();
expect(findButton().exists()).toBe(true);
expect(findButton().attributes('disabled')).toBeFalsy();
});
it('renders disabled button when design is deleting', () => {
createComponent(true);
expect(findButton().attributes('disabled')).toBeTruthy();
});
it('emits `deleteSelectedDesigns` event on modal ok click', () => {
createComponent();
findButton().vm.$emit('click');
return wrapper.vm
.$nextTick()
.then(() => {
findModal().vm.$emit('ok');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy();
});
});
});
import { shallowMount } from '@vue/test-utils';
import DesignNotePin from '~/design_management_new/components/design_note_pin.vue';
describe('Design note pin component', () => {
let wrapper;
function createComponent(propsData = {}) {
wrapper = shallowMount(DesignNotePin, {
propsData: {
position: {
left: '10px',
top: '10px',
},
...propsData,
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('should match the snapshot of note without index', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('should match the snapshot of note with index', () => {
createComponent({ label: 1 });
expect(wrapper.element).toMatchSnapshot();
});
it('should match the snapshot of note when repositioning', () => {
createComponent({ repositioning: true });
expect(wrapper.element).toMatchSnapshot();
});
describe('pinStyle', () => {
it('sets cursor to `move` when repositioning = true', () => {
createComponent({ repositioning: true });
expect(wrapper.vm.pinStyle.cursor).toBe('move');
});
it('does not set cursor when repositioning = false', () => {
createComponent();
expect(wrapper.vm.pinStyle.cursor).toBe(undefined);
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design note component should match the snapshot 1`] = `
<timeline-entry-item-stub
class="design-note note-form"
id="note_123"
>
<user-avatar-link-stub
imgalt=""
imgcssclasses=""
imgsize="40"
imgsrc=""
linkhref=""
tooltipplacement="top"
tooltiptext=""
username=""
/>
<div
class="d-flex justify-content-between"
>
<div>
<a
class="js-user-link"
data-user-id="author-id"
>
<span
class="note-header-author-name bold"
>
</span>
<!---->
<span
class="note-headline-light"
>
@
</span>
</a>
<span
class="note-headline-light note-headline-meta"
>
<span
class="system-note-message"
/>
<!---->
</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>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = `
"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
<!---->
Comment
</button>"
`;
exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = `
"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
<!---->
Save comment
</button>"
`;
import { mount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import notes from '../../mock_data/notes';
import DesignDiscussion from '~/design_management_new/components/design_notes/design_discussion.vue';
import DesignNote from '~/design_management_new/components/design_notes/design_note.vue';
import DesignReplyForm from '~/design_management_new/components/design_notes/design_reply_form.vue';
import createNoteMutation from '~/design_management_new/graphql/mutations/create_note.mutation.graphql';
import toggleResolveDiscussionMutation from '~/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import ToggleRepliesWidget from '~/design_management_new/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,
update: expect.anything(),
variables: {
input: {
noteableId: 'noteable-id',
body: 'test',
discussionId: '0',
},
},
};
const mutate = jest.fn(() => Promise.resolve());
const $apollo = {
mutate,
};
function createComponent(props = {}, data = {}) {
wrapper = mount(DesignDiscussion, {
propsData: {
resolvedDiscussionsExpanded: true,
discussion,
noteableId: 'noteable-id',
designId: 'design-id',
discussionIndex: 1,
discussionWithOpenForm: '',
...props,
},
data() {
return {
...data,
};
},
mocks: {
$apollo,
$route: {
hash: '#note_1',
},
},
});
}
afterEach(() => {
wrapper.destroy();
});
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);
});
});
});
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('onClick');
wrapper.setProps({ discussionWithOpenForm: discussion.id });
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('onClick');
wrapper.setProps({ discussionWithOpenForm: discussion.id });
return wrapper.vm.$nextTick().then(() => {
expect(findResolveCheckbox().text()).toBe('Unresolve thread');
});
});
});
});
it('hides reply placeholder and opens form on placeholder click', () => {
createComponent();
findReplyPlaceholder().vm.$emit('onClick');
wrapper.setProps({ discussionWithOpenForm: discussion.id });
return wrapper.vm.$nextTick().then(() => {
expect(findReplyPlaceholder().exists()).toBe(false);
expect(findReplyForm().exists()).toBe(true);
});
});
it('calls mutation on submitting form and closes the form', () => {
createComponent(
{ discussionWithOpenForm: discussion.id },
{ discussionComment: 'test', isFormRendered: true },
);
findReplyForm().vm.$emit('submitForm');
expect(mutate).toHaveBeenCalledWith(mutationVariables);
return mutate()
.then(() => {
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findReplyForm().exists()).toBe(false);
});
});
it('clears the discussion comment on closing comment form', () => {
createComponent(
{ discussionWithOpenForm: discussion.id },
{ discussionComment: 'test', isFormRendered: true },
);
return wrapper.vm
.$nextTick()
.then(() => {
findReplyForm().vm.$emit('cancelForm');
expect(wrapper.vm.discussionComment).toBe('');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findReplyForm().exists()).toBe(false);
});
});
it('applies correct class to design notes when discussion is highlighted', () => {
createComponent(
{},
{
activeDiscussion: {
id: notes[0].id,
source: 'pin',
},
},
);
expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).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(
{ discussionWithOpenForm: discussion.id },
{ 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,
},
});
});
});
it('emits openForm event on opening the form', () => {
createComponent();
findReplyPlaceholder().vm.$emit('onClick');
expect(wrapper.emitted('openForm')).toBeTruthy();
});
});
import { shallowMount } from '@vue/test-utils';
import { ApolloMutation } from 'vue-apollo';
import DesignNote from '~/design_management_new/components/design_notes/design_note.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import DesignReplyForm from '~/design_management_new/components/design_notes/design_reply_form.vue';
const scrollIntoViewMock = jest.fn();
const note = {
id: 'gid://gitlab/DiffNote/123',
author: {
id: 'author-id',
},
body: 'test',
userPermissions: {
adminNote: false,
},
};
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
const $route = {
hash: '#note_123',
};
const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } });
describe('Design note component', () => {
let wrapper;
const findUserAvatar = () => wrapper.find(UserAvatarLink);
const findUserLink = () => wrapper.find('.js-user-link');
const findReplyForm = () => wrapper.find(DesignReplyForm);
const findEditButton = () => wrapper.find('.js-note-edit');
const findNoteContent = () => wrapper.find('.js-note-text');
function createComponent(props = {}, data = { isEditing: false }) {
wrapper = shallowMount(DesignNote, {
propsData: {
note: {},
...props,
},
data() {
return {
...data,
};
},
mocks: {
$route,
$apollo: {
mutate,
},
},
stubs: {
ApolloMutation,
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('should match the snapshot', () => {
createComponent({
note,
});
expect(wrapper.element).toMatchSnapshot();
});
it('should render an author', () => {
createComponent({
note,
});
expect(findUserAvatar().exists()).toBe(true);
expect(findUserLink().exists()).toBe(true);
});
it('should render a time ago tooltip if note has createdAt property', () => {
createComponent({
note: {
...note,
createdAt: '2019-07-26T15:02:20Z',
},
});
expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
});
it('should trigger a scrollIntoView method', () => {
createComponent({
note,
});
expect(scrollIntoViewMock).toHaveBeenCalled();
});
it('should not render edit icon when user does not have a permission', () => {
createComponent({
note,
});
expect(findEditButton().exists()).toBe(false);
});
describe('when user has a permission to edit note', () => {
it('should open an edit form on edit button click', () => {
createComponent({
note: {
...note,
userPermissions: {
adminNote: true,
},
},
});
findEditButton().trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(findReplyForm().exists()).toBe(true);
expect(findNoteContent().exists()).toBe(false);
});
});
describe('when edit form is rendered', () => {
beforeEach(() => {
createComponent(
{
note: {
...note,
userPermissions: {
adminNote: true,
},
},
},
{ isEditing: true },
);
});
it('should not render note content and should render reply form', () => {
expect(findNoteContent().exists()).toBe(false);
expect(findReplyForm().exists()).toBe(true);
});
it('hides the form on hideForm event', () => {
findReplyForm().vm.$emit('cancelForm');
return wrapper.vm.$nextTick().then(() => {
expect(findReplyForm().exists()).toBe(false);
expect(findNoteContent().exists()).toBe(true);
});
});
it('calls a mutation on submitForm event and hides a form', () => {
findReplyForm().vm.$emit('submitForm');
expect(mutate).toHaveBeenCalled();
return mutate()
.then(() => {
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findReplyForm().exists()).toBe(false);
expect(findNoteContent().exists()).toBe(true);
});
});
});
});
});
import { mount } from '@vue/test-utils';
import DesignReplyForm from '~/design_management_new/components/design_notes/design_reply_form.vue';
const showModal = jest.fn();
const GlModal = {
template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
methods: {
show: showModal,
},
};
describe('Design reply form component', () => {
let wrapper;
const findTextarea = () => wrapper.find('textarea');
const findSubmitButton = () => wrapper.find({ ref: 'submitButton' });
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
const findModal = () => wrapper.find({ ref: 'cancelCommentModal' });
function createComponent(props = {}, mountOptions = {}) {
wrapper = mount(DesignReplyForm, {
propsData: {
value: '',
isSaving: false,
...props,
},
stubs: { GlModal },
...mountOptions,
});
}
afterEach(() => {
wrapper.destroy();
});
it('textarea has focus after component mount', () => {
// We need to attach to document, so that `document.activeElement` is properly set in jsdom
createComponent({}, { attachToDocument: true });
expect(findTextarea().element).toEqual(document.activeElement);
});
it('renders button text as "Comment" when creating a comment', () => {
createComponent();
expect(findSubmitButton().html()).toMatchSnapshot();
});
it('renders button text as "Save comment" when creating a comment', () => {
createComponent({ isNewComment: false });
expect(findSubmitButton().html()).toMatchSnapshot();
});
describe('when form has no text', () => {
beforeEach(() => {
createComponent({
value: '',
});
});
it('submit button is disabled', () => {
expect(findSubmitButton().attributes().disabled).toBeTruthy();
});
it('does not emit submitForm event on textarea ctrl+enter keydown', () => {
findTextarea().trigger('keydown.enter', {
ctrlKey: true,
});
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('submitForm')).toBeFalsy();
});
});
it('does not emit submitForm event on textarea meta+enter keydown', () => {
findTextarea().trigger('keydown.enter', {
metaKey: true,
});
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('submitForm')).toBeFalsy();
});
});
it('emits cancelForm event on pressing escape button on textarea', () => {
findTextarea().trigger('keyup.esc');
expect(wrapper.emitted('cancelForm')).toBeTruthy();
});
it('emits cancelForm event on clicking Cancel button', () => {
findCancelButton().vm.$emit('click');
expect(wrapper.emitted('cancelForm')).toHaveLength(1);
});
});
describe('when form has text', () => {
beforeEach(() => {
createComponent({
value: 'test',
});
});
it('submit button is enabled', () => {
expect(findSubmitButton().attributes().disabled).toBeFalsy();
});
it('emits submitForm event on Comment button click', () => {
findSubmitButton().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('submitForm')).toBeTruthy();
});
});
it('emits submitForm event on textarea ctrl+enter keydown', () => {
findTextarea().trigger('keydown.enter', {
ctrlKey: true,
});
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('submitForm')).toBeTruthy();
});
});
it('emits submitForm event on textarea meta+enter keydown', () => {
findTextarea().trigger('keydown.enter', {
metaKey: true,
});
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('submitForm')).toBeTruthy();
});
});
it('emits input event on changing textarea content', () => {
findTextarea().setValue('test2');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('input')).toBeTruthy();
});
});
it('emits cancelForm event on Escape key if text was not changed', () => {
findTextarea().trigger('keyup.esc');
expect(wrapper.emitted('cancelForm')).toBeTruthy();
});
it('opens confirmation modal on Escape key when text has changed', () => {
wrapper.setProps({ value: 'test2' });
return wrapper.vm.$nextTick().then(() => {
findTextarea().trigger('keyup.esc');
expect(showModal).toHaveBeenCalled();
});
});
it('emits cancelForm event on Cancel button click if text was not changed', () => {
findCancelButton().trigger('click');
expect(wrapper.emitted('cancelForm')).toBeTruthy();
});
it('opens confirmation modal on Cancel button click when text has changed', () => {
wrapper.setProps({ value: 'test2' });
return wrapper.vm.$nextTick().then(() => {
findCancelButton().trigger('click');
expect(showModal).toHaveBeenCalled();
});
});
it('emits cancelForm event on modal Ok button click', () => {
findTextarea().trigger('keyup.esc');
findModal().vm.$emit('ok');
expect(wrapper.emitted('cancelForm')).toBeTruthy();
});
});
});
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_new/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);
});
});
import { mount } from '@vue/test-utils';
import DesignOverlay from '~/design_management_new/components/design_overlay.vue';
import updateActiveDiscussion from '~/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql';
import notes from '../mock_data/notes';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '~/design_management_new/constants';
const mutate = jest.fn(() => Promise.resolve());
describe('Design overlay component', () => {
let wrapper;
const mockDimensions = { width: 100, height: 100 };
const findOverlay = () => wrapper.find('.image-diff-overlay');
const findAllNotes = () => wrapper.findAll('.js-image-badge');
const findCommentBadge = () => wrapper.find('.comment-indicator');
const findFirstBadge = () => findAllNotes().at(0);
const findSecondBadge = () => findAllNotes().at(1);
const clickAndDragBadge = (elem, fromPoint, toPoint) => {
elem.trigger('mousedown', { clientX: fromPoint.x, clientY: fromPoint.y });
return wrapper.vm.$nextTick().then(() => {
elem.trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y });
return wrapper.vm.$nextTick();
});
};
function createComponent(props = {}, data = {}) {
wrapper = mount(DesignOverlay, {
propsData: {
dimensions: mockDimensions,
position: {
top: '0',
left: '0',
},
resolvedDiscussionsExpanded: false,
...props,
},
data() {
return {
activeDiscussion: {
id: null,
source: null,
},
...data,
};
},
mocks: {
$apollo: {
mutate,
},
},
});
}
it('should have correct inline style', () => {
createComponent();
expect(wrapper.find('.image-diff-overlay').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('.image-diff-overlay-add-comment')
.trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y });
return wrapper.vm.$nextTick().then(() => {
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().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',
},
});
return wrapper.vm.$nextTick().then(() => {
expect(findSecondBadge().classes()).toContain('inactive');
});
});
});
it('should recalculate badges positions on window resize', () => {
createComponent({
notes,
dimensions: {
width: 400,
height: 400,
},
});
expect(findFirstBadge().attributes().style).toBe('left: 40px; top: 60px;');
wrapper.setProps({
dimensions: {
width: 200,
height: 200,
},
});
return wrapper.vm.$nextTick().then(() => {
expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 30px;');
});
});
it('should call an update active discussion mutation when clicking a note without moving it', () => {
const note = notes[0];
const { position } = note;
const mutationVariables = {
mutation: updateActiveDiscussion,
variables: {
id: note.id,
source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin,
},
};
findFirstBadge().trigger('mousedown', { clientX: position.x, clientY: position.y });
return wrapper.vm.$nextTick().then(() => {
findFirstBadge().trigger('mouseup', { clientX: position.x, clientY: position.y });
expect(mutate).toHaveBeenCalledWith(mutationVariables);
});
});
});
describe('when moving notes', () => {
it('should update badge style when note is being moved', () => {
createComponent({
notes,
});
const { position } = notes[0];
return clickAndDragBadge(
findFirstBadge(),
{ x: position.x, y: position.y },
{ x: 20, y: 20 },
).then(() => {
expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 20px; cursor: move;');
});
});
it('should emit `moveNote` event when note-moving action ends', () => {
createComponent({ notes });
const note = notes[0];
const { position } = note;
const newCoordinates = { x: 20, y: 20 };
wrapper.setData({
movingNoteNewPosition: {
...position,
...newCoordinates,
},
movingNoteStartPosition: {
noteId: notes[0].id,
discussionId: notes[0].discussion.id,
...position,
},
});
const badge = findFirstBadge();
return clickAndDragBadge(badge, { x: position.x, y: position.y }, newCoordinates)
.then(() => {
badge.trigger('mouseup');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.emitted('moveNote')).toEqual([
[
{
noteId: notes[0].id,
discussionId: notes[0].discussion.id,
coordinates: newCoordinates,
},
],
]);
});
});
describe('without [adminNote] permission', () => {
const mockNoteNotAuthorised = {
...notes[0],
userPermissions: {
adminNote: false,
},
};
const mockNoteCoordinates = {
x: mockNoteNotAuthorised.position.x,
y: mockNoteNotAuthorised.position.y,
};
it('should be unable to move a note', () => {
createComponent({
dimensions: mockDimensions,
notes: [mockNoteNotAuthorised],
});
const badge = findAllNotes().at(0);
return clickAndDragBadge(badge, { ...mockNoteCoordinates }, { x: 20, y: 20 }).then(() => {
// note position should not change after a click-and-drag attempt
expect(findFirstBadge().attributes().style).toContain(
`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().attributes().style).toBe('left: 10px; top: 15px;');
});
describe('when moving the comment badge', () => {
it('should update badge style to reflect new position', () => {
const { position } = notes[0];
createComponent({
currentCommentForm: {
...position,
},
});
return clickAndDragBadge(
findCommentBadge(),
{ x: position.x, y: position.y },
{ x: 20, y: 20 },
).then(() => {
expect(findCommentBadge().attributes().style).toBe(
'left: 20px; top: 20px; cursor: move;',
);
});
});
it('should update badge style when note-moving action ends', () => {
const { position } = notes[0];
createComponent({
currentCommentForm: {
...position,
},
});
const commentBadge = findCommentBadge();
const toPoint = { x: 20, y: 20 };
return clickAndDragBadge(commentBadge, { x: position.x, y: position.y }, toPoint)
.then(() => {
commentBadge.trigger('mouseup');
// simulates the currentCommentForm being updated in index.vue component, and
// propagated back down to this prop
wrapper.setProps({
currentCommentForm: { height: position.height, width: position.width, ...toPoint },
});
return wrapper.vm.$nextTick();
})
.then(() => {
expect(commentBadge.attributes().style).toBe('left: 20px; top: 20px;');
});
});
it.each`
element | getElementFunc | event
${'overlay'} | ${findOverlay} | ${'mouseleave'}
${'comment badge'} | ${findCommentBadge} | ${'mouseup'}
`(
'should emit `openCommentForm` event when $event fired on $element element',
({ getElementFunc, event }) => {
createComponent({
notes,
currentCommentForm: {
...notes[0].position,
},
});
const newCoordinates = { x: 20, y: 20 };
wrapper.setData({
movingNoteStartPosition: {
...notes[0].position,
},
movingNoteNewPosition: {
...notes[0].position,
...newCoordinates,
},
});
getElementFunc().trigger(event);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]);
});
},
);
});
});
describe('getMovingNotePositionDelta', () => {
it('should calculate delta correctly from state', () => {
createComponent();
wrapper.setData({
movingNoteStartPosition: {
clientX: 10,
clientY: 20,
},
});
const mockMouseEvent = {
clientX: 30,
clientY: 10,
};
expect(wrapper.vm.getMovingNotePositionDelta(mockMouseEvent)).toEqual({
deltaX: 20,
deltaY: -10,
});
});
});
describe('isPositionInOverlay', () => {
createComponent({ dimensions: mockDimensions });
it.each`
test | coordinates | expectedResult
${'within overlay bounds'} | ${{ x: 50, y: 50 }} | ${true}
${'outside overlay bounds'} | ${{ x: 101, y: 101 }} | ${false}
`('returns [$expectedResult] when position is $test', ({ coordinates, expectedResult }) => {
const position = { ...mockDimensions, ...coordinates };
expect(wrapper.vm.isPositionInOverlay(position)).toBe(expectedResult);
});
});
describe('getNoteRelativePosition', () => {
it('calculates position correctly', () => {
createComponent({ dimensions: mockDimensions });
const position = { x: 50, y: 50, width: 200, height: 200 };
expect(wrapper.vm.getNoteRelativePosition(position)).toEqual({ left: 25, top: 25 });
});
});
describe('canMoveNote', () => {
it.each`
adminNotePermission | canMoveNoteResult
${true} | ${true}
${false} | ${false}
${undefined} | ${false}
`(
'returns [$canMoveNoteResult] when [adminNote permission] is [$adminNotePermission]',
({ adminNotePermission, canMoveNoteResult }) => {
createComponent();
const note = {
userPermissions: {
adminNote: adminNotePermission,
},
};
expect(wrapper.vm.canMoveNote(note)).toBe(canMoveNoteResult);
},
);
});
});
import { shallowMount } from '@vue/test-utils';
import DesignPresentation from '~/design_management_new/components/design_presentation.vue';
import DesignOverlay from '~/design_management_new/components/design_overlay.vue';
const mockOverlayData = {
overlayDimensions: {
width: 100,
height: 100,
},
overlayPosition: {
top: '0',
left: '0',
},
};
describe('Design management design presentation component', () => {
let wrapper;
function createComponent(
{
image,
imageName,
discussions = [],
isAnnotating = false,
resolvedDiscussionsExpanded = false,
} = {},
data = {},
stubs = {},
) {
wrapper = shallowMount(DesignPresentation, {
propsData: {
image,
imageName,
discussions,
isAnnotating,
resolvedDiscussionsExpanded,
},
stubs,
});
wrapper.setData(data);
wrapper.element.scrollTo = jest.fn();
}
const findOverlayCommentButton = () => wrapper.find('.image-diff-overlay-add-comment');
/**
* Spy on $refs and mock given values
* @param {Object} viewportDimensions {width, height}
* @param {Object} childDimensions {width, height}
* @param {Float} scrollTopPerc 0 < x < 1
* @param {Float} scrollLeftPerc 0 < x < 1
*/
function mockRefDimensions(
ref,
viewportDimensions,
childDimensions,
scrollTopPerc,
scrollLeftPerc,
) {
jest.spyOn(ref, 'scrollWidth', 'get').mockReturnValue(childDimensions.width);
jest.spyOn(ref, 'scrollHeight', 'get').mockReturnValue(childDimensions.height);
jest.spyOn(ref, 'offsetWidth', 'get').mockReturnValue(viewportDimensions.width);
jest.spyOn(ref, 'offsetHeight', 'get').mockReturnValue(viewportDimensions.height);
jest
.spyOn(ref, 'scrollLeft', 'get')
.mockReturnValue((childDimensions.width - viewportDimensions.width) * scrollLeftPerc);
jest
.spyOn(ref, 'scrollTop', 'get')
.mockReturnValue((childDimensions.height - viewportDimensions.height) * scrollTopPerc);
}
function clickDragExplore(startCoords, endCoords, { useTouchEvents, mouseup } = {}) {
const event = useTouchEvents
? {
mousedown: 'touchstart',
mousemove: 'touchmove',
mouseup: 'touchend',
}
: {
mousedown: 'mousedown',
mousemove: 'mousemove',
mouseup: 'mouseup',
};
const addCommentOverlay = findOverlayCommentButton();
// triggering mouse events on this element best simulates
// reality, as it is the lowest-level node that needs to
// respond to mouse events
addCommentOverlay.trigger(event.mousedown, {
clientX: startCoords.clientX,
clientY: startCoords.clientY,
});
return wrapper.vm
.$nextTick()
.then(() => {
addCommentOverlay.trigger(event.mousemove, {
clientX: endCoords.clientX,
clientY: endCoords.clientY,
});
return wrapper.vm.$nextTick();
})
.then(() => {
if (mouseup) {
addCommentOverlay.trigger(event.mouseup);
return wrapper.vm.$nextTick();
}
return undefined;
});
}
afterEach(() => {
wrapper.destroy();
});
it('renders image and overlay when image provided', () => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
mockOverlayData,
);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('renders empty state when no image provided', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('openCommentForm event emits correct data', () => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
mockOverlayData,
);
wrapper.vm.openCommentForm({ x: 1, y: 1 });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('openCommentForm')).toEqual([
[{ ...mockOverlayData.overlayDimensions, x: 1, y: 1 }],
]);
});
});
describe('currentCommentForm', () => {
it('is null when isAnnotating is false', () => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
mockOverlayData,
);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.currentCommentForm).toBeNull();
expect(wrapper.element).toMatchSnapshot();
});
});
it('is null when isAnnotating is true but annotation position is falsey', () => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
isAnnotating: true,
},
mockOverlayData,
);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.currentCommentForm).toBeNull();
expect(wrapper.element).toMatchSnapshot();
});
});
it('is equal to current annotation position when isAnnotating is true', () => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
isAnnotating: true,
},
{
...mockOverlayData,
currentAnnotationPosition: {
x: 1,
y: 1,
width: 100,
height: 100,
},
},
);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.currentCommentForm).toEqual({
x: 1,
y: 1,
width: 100,
height: 100,
});
expect(wrapper.element).toMatchSnapshot();
});
});
});
describe('setOverlayPosition', () => {
beforeEach(() => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
mockOverlayData,
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('sets overlay position correctly when overlay is smaller than viewport', () => {
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200);
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200);
wrapper.vm.setOverlayPosition();
expect(wrapper.vm.overlayPosition).toEqual({
left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`,
top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`,
});
});
it('sets overlay position correctly when overlay width is larger than viewports', () => {
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(50);
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200);
wrapper.vm.setOverlayPosition();
expect(wrapper.vm.overlayPosition).toEqual({
left: '0',
top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`,
});
});
it('sets overlay position correctly when overlay height is larger than viewports', () => {
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200);
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(50);
wrapper.vm.setOverlayPosition();
expect(wrapper.vm.overlayPosition).toEqual({
left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`,
top: '0',
});
});
});
describe('getViewportCenter', () => {
beforeEach(() => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
mockOverlayData,
);
});
it('calculate center correctly with no scroll', () => {
mockRefDimensions(
wrapper.vm.$refs.presentationViewport,
{ width: 10, height: 10 },
{ width: 20, height: 20 },
0,
0,
);
expect(wrapper.vm.getViewportCenter()).toEqual({
x: 5,
y: 5,
});
});
it('calculate center correctly with some scroll', () => {
mockRefDimensions(
wrapper.vm.$refs.presentationViewport,
{ width: 10, height: 10 },
{ width: 20, height: 20 },
0.5,
0.5,
);
expect(wrapper.vm.getViewportCenter()).toEqual({
x: 10,
y: 10,
});
});
it('Returns default case if no overflow (scrollWidth==offsetWidth, etc.)', () => {
mockRefDimensions(
wrapper.vm.$refs.presentationViewport,
{ width: 20, height: 20 },
{ width: 20, height: 20 },
0.5,
0.5,
);
expect(wrapper.vm.getViewportCenter()).toEqual({
x: 10,
y: 10,
});
});
});
describe('scaleZoomFocalPoint', () => {
it('scales focal point correctly when zooming in', () => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
{
...mockOverlayData,
zoomFocalPoint: {
x: 5,
y: 5,
width: 50,
height: 50,
},
},
);
wrapper.vm.scaleZoomFocalPoint();
expect(wrapper.vm.zoomFocalPoint).toEqual({
x: 10,
y: 10,
width: 100,
height: 100,
});
});
it('scales focal point correctly when zooming out', () => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
{
...mockOverlayData,
zoomFocalPoint: {
x: 10,
y: 10,
width: 200,
height: 200,
},
},
);
wrapper.vm.scaleZoomFocalPoint();
expect(wrapper.vm.zoomFocalPoint).toEqual({
x: 5,
y: 5,
width: 100,
height: 100,
});
});
});
describe('onImageResize', () => {
it('sets zoom focal point on initial load', () => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
mockOverlayData,
);
wrapper.setMethods({
shiftZoomFocalPoint: jest.fn(),
scaleZoomFocalPoint: jest.fn(),
scrollToFocalPoint: jest.fn(),
});
wrapper.vm.onImageResize({ width: 10, height: 10 });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.shiftZoomFocalPoint).toHaveBeenCalled();
expect(wrapper.vm.initialLoad).toBe(false);
});
});
it('calls scaleZoomFocalPoint and scrollToFocalPoint after initial load', () => {
wrapper.vm.onImageResize({ width: 10, height: 10 });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.scaleZoomFocalPoint).toHaveBeenCalled();
expect(wrapper.vm.scrollToFocalPoint).toHaveBeenCalled();
});
});
});
describe('onPresentationMousedown', () => {
it.each`
scenario | width | height
${'width overflows'} | ${101} | ${100}
${'height overflows'} | ${100} | ${101}
${'width and height overflows'} | ${200} | ${200}
`('sets lastDragPosition when design $scenario', ({ width, height }) => {
createComponent();
mockRefDimensions(
wrapper.vm.$refs.presentationViewport,
{ width: 100, height: 100 },
{ width, height },
);
const newLastDragPosition = { x: 2, y: 2 };
wrapper.vm.onPresentationMousedown({
clientX: newLastDragPosition.x,
clientY: newLastDragPosition.y,
});
expect(wrapper.vm.lastDragPosition).toStrictEqual(newLastDragPosition);
});
it('does not set lastDragPosition if design does not overflow', () => {
const lastDragPosition = { x: 1, y: 1 };
createComponent({}, { lastDragPosition });
mockRefDimensions(
wrapper.vm.$refs.presentationViewport,
{ width: 100, height: 100 },
{ width: 50, height: 50 },
);
wrapper.vm.onPresentationMousedown({ clientX: 2, clientY: 2 });
// check lastDragPosition is unchanged
expect(wrapper.vm.lastDragPosition).toStrictEqual(lastDragPosition);
});
});
describe('getAnnotationPositon', () => {
it.each`
coordinates | overlayDimensions | position
${{ x: 100, y: 100 }} | ${{ width: 50, height: 50 }} | ${{ x: 100, y: 100, width: 50, height: 50 }}
${{ x: 100.2, y: 100.5 }} | ${{ width: 50.6, height: 50.0 }} | ${{ x: 100, y: 101, width: 51, height: 50 }}
`('returns correct annotation position', ({ coordinates, overlayDimensions, position }) => {
createComponent(undefined, {
overlayDimensions: {
width: overlayDimensions.width,
height: overlayDimensions.height,
},
});
expect(wrapper.vm.getAnnotationPositon(coordinates)).toStrictEqual(position);
});
});
describe('when design is overflowing', () => {
beforeEach(() => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
mockOverlayData,
{
'design-overlay': DesignOverlay,
},
);
// mock a design that overflows
mockRefDimensions(
wrapper.vm.$refs.presentationViewport,
{ width: 10, height: 10 },
{ width: 20, height: 20 },
0,
0,
);
});
it('opens a comment form if design was not dragged', () => {
const addCommentOverlay = findOverlayCommentButton();
const startCoords = {
clientX: 1,
clientY: 1,
};
addCommentOverlay.trigger('mousedown', {
clientX: startCoords.clientX,
clientY: startCoords.clientY,
});
return wrapper.vm
.$nextTick()
.then(() => {
addCommentOverlay.trigger('mouseup');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.emitted('openCommentForm')).toBeDefined();
});
});
describe('when clicking and dragging', () => {
it.each`
description | useTouchEvents
${'with touch events'} | ${true}
${'without touch events'} | ${false}
`('calls scrollTo with correct arguments $description', ({ useTouchEvents }) => {
return clickDragExplore(
{ clientX: 0, clientY: 0 },
{ clientX: 10, clientY: 10 },
{ useTouchEvents },
).then(() => {
expect(wrapper.element.scrollTo).toHaveBeenCalledTimes(1);
expect(wrapper.element.scrollTo).toHaveBeenCalledWith(-10, -10);
});
});
it('does not open a comment form when drag position exceeds buffer', () => {
return clickDragExplore(
{ clientX: 0, clientY: 0 },
{ clientX: 10, clientY: 10 },
{ mouseup: true },
).then(() => {
expect(wrapper.emitted('openCommentForm')).toBeFalsy();
});
});
it('opens a comment form when drag position is within buffer', () => {
return clickDragExplore(
{ clientX: 0, clientY: 0 },
{ clientX: 1, clientY: 0 },
{ mouseup: true },
).then(() => {
expect(wrapper.emitted('openCommentForm')).toBeDefined();
});
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import DesignScaler from '~/design_management_new/components/design_scaler.vue';
describe('Design management design scaler component', () => {
let wrapper;
function createComponent(propsData, data = {}) {
wrapper = shallowMount(DesignScaler, {
propsData,
});
wrapper.setData(data);
}
afterEach(() => {
wrapper.destroy();
});
const getButton = type => {
const buttonTypeOrder = ['minus', 'reset', 'plus'];
const buttons = wrapper.findAll('button');
return buttons.at(buttonTypeOrder.indexOf(type));
};
it('emits @scale event when "plus" button clicked', () => {
createComponent();
getButton('plus').trigger('click');
expect(wrapper.emitted('scale')).toEqual([[1.2]]);
});
it('emits @scale event when "reset" button clicked (scale > 1)', () => {
createComponent({}, { scale: 1.6 });
return wrapper.vm.$nextTick().then(() => {
getButton('reset').trigger('click');
expect(wrapper.emitted('scale')).toEqual([[1]]);
});
});
it('emits @scale event when "minus" button clicked (scale > 1)', () => {
createComponent({}, { scale: 1.6 });
return wrapper.vm.$nextTick().then(() => {
getButton('minus').trigger('click');
expect(wrapper.emitted('scale')).toEqual([[1.4]]);
});
});
it('minus and reset buttons are disabled when scale === 1', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('minus and reset buttons are enabled when scale > 1', () => {
createComponent({}, { scale: 1.2 });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('plus button is disabled when scale === 2', () => {
createComponent({}, { scale: 2 });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlCollapse, GlPopover } from '@gitlab/ui';
import Cookies from 'js-cookie';
import DesignSidebar from '~/design_management_new/components/design_sidebar.vue';
import Participants from '~/sidebar/components/participants/participants.vue';
import DesignDiscussion from '~/design_management_new/components/design_notes/design_discussion.vue';
import design from '../mock_data/design';
import updateActiveDiscussionMutation from '~/design_management_new/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']]);
});
it('changes prop correctly on opening discussion form', () => {
findFirstDiscussion().vm.$emit('openForm', 'some-id');
return wrapper.vm.$nextTick().then(() => {
expect(findFirstDiscussion().props('discussionWithOpenForm')).toBe('some-id');
});
});
});
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 });
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
import DesignImage from '~/design_management_new/components/image.vue';
describe('Design management large image component', () => {
let wrapper;
function createComponent(propsData, data = {}) {
wrapper = shallowMount(DesignImage, {
propsData,
});
wrapper.setData(data);
}
afterEach(() => {
wrapper.destroy();
});
it('renders loading state', () => {
createComponent({
isLoading: true,
});
expect(wrapper.element).toMatchSnapshot();
});
it('renders image', () => {
createComponent({
isLoading: false,
image: 'test.jpg',
name: 'test',
});
expect(wrapper.element).toMatchSnapshot();
});
it('sets correct classes and styles if imageStyle is set', () => {
createComponent(
{
isLoading: false,
image: 'test.jpg',
name: 'test',
},
{
imageStyle: {
width: '100px',
height: '100px',
},
},
);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('renders media broken icon on error', () => {
createComponent({
isLoading: false,
image: 'test.jpg',
name: 'test',
});
const image = wrapper.find('img');
image.trigger('error');
return wrapper.vm.$nextTick().then(() => {
expect(image.isVisible()).toBe(false);
expect(wrapper.find(GlIcon).element).toMatchSnapshot();
});
});
describe('zoom', () => {
const baseImageWidth = 100;
const baseImageHeight = 100;
beforeEach(() => {
createComponent(
{
isLoading: false,
image: 'test.jpg',
name: 'test',
},
{
imageStyle: {
width: `${baseImageWidth}px`,
height: `${baseImageHeight}px`,
},
baseImageSize: {
width: baseImageWidth,
height: baseImageHeight,
},
},
);
jest.spyOn(wrapper.vm.$refs.contentImg, 'offsetWidth', 'get').mockReturnValue(baseImageWidth);
jest
.spyOn(wrapper.vm.$refs.contentImg, 'offsetHeight', 'get')
.mockReturnValue(baseImageHeight);
});
it('emits @resize event on zoom', () => {
const zoomAmount = 2;
wrapper.vm.zoom(zoomAmount);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('resize')).toEqual([
[{ width: baseImageWidth * zoomAmount, height: baseImageHeight * zoomAmount }],
]);
});
});
it('emits @resize event with base image size when scale=1', () => {
wrapper.vm.zoom(1);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('resize')).toEqual([
[{ width: baseImageWidth, height: baseImageHeight }],
]);
});
});
it('sets image style when zoomed', () => {
const zoomAmount = 2;
wrapper.vm.zoom(zoomAmount);
expect(wrapper.vm.imageStyle).toEqual({
width: `${baseImageWidth * zoomAmount}px`,
height: `${baseImageHeight * zoomAmount}px`,
});
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management list item component when item appears in view after image is loaded renders media broken icon when image onerror triggered 1`] = `
<gl-icon-stub
class="text-secondary"
name="media-broken"
size="32"
/>
`;
exports[`Design management list item component with no notes renders item with correct status icon for creation event 1`] = `
<router-link-stub
class="card cursor-pointer text-plain js-design-list-item design-list-item"
to="[object Object]"
>
<div
class="card-body p-0 d-flex-center overflow-hidden position-relative"
>
<div
class="design-event position-absolute"
>
<span
aria-label="Added in this version"
title="Added in this version"
>
<icon-stub
class="text-success-500"
name="file-addition-solid"
size="18"
/>
</span>
</div>
<gl-intersection-observer-stub
options="[object Object]"
>
<!---->
<img
alt="test"
class="block mx-auto mw-100 mh-100 design-img"
data-qa-selector="design_image"
src=""
/>
</gl-intersection-observer-stub>
</div>
<div
class="card-footer d-flex w-100"
>
<div
class="d-flex flex-column str-truncated-100"
>
<span
class="bold str-truncated-100"
data-qa-selector="design_file_name"
>
test
</span>
<span
class="str-truncated-100"
>
Updated
<timeago-stub
cssclass=""
time="01-01-2019"
tooltipplacement="bottom"
/>
</span>
</div>
<!---->
</div>
</router-link-stub>
`;
exports[`Design management list item component with no notes renders item with correct status icon for deletion event 1`] = `
<router-link-stub
class="card cursor-pointer text-plain js-design-list-item design-list-item"
to="[object Object]"
>
<div
class="card-body p-0 d-flex-center overflow-hidden position-relative"
>
<div
class="design-event position-absolute"
>
<span
aria-label="Deleted in this version"
title="Deleted in this version"
>
<icon-stub
class="text-danger-500"
name="file-deletion-solid"
size="18"
/>
</span>
</div>
<gl-intersection-observer-stub
options="[object Object]"
>
<!---->
<img
alt="test"
class="block mx-auto mw-100 mh-100 design-img"
data-qa-selector="design_image"
src=""
/>
</gl-intersection-observer-stub>
</div>
<div
class="card-footer d-flex w-100"
>
<div
class="d-flex flex-column str-truncated-100"
>
<span
class="bold str-truncated-100"
data-qa-selector="design_file_name"
>
test
</span>
<span
class="str-truncated-100"
>
Updated
<timeago-stub
cssclass=""
time="01-01-2019"
tooltipplacement="bottom"
/>
</span>
</div>
<!---->
</div>
</router-link-stub>
`;
exports[`Design management list item component with no notes renders item with correct status icon for modification event 1`] = `
<router-link-stub
class="card cursor-pointer text-plain js-design-list-item design-list-item"
to="[object Object]"
>
<div
class="card-body p-0 d-flex-center overflow-hidden position-relative"
>
<div
class="design-event position-absolute"
>
<span
aria-label="Modified in this version"
title="Modified in this version"
>
<icon-stub
class="text-primary-500"
name="file-modified-solid"
size="18"
/>
</span>
</div>
<gl-intersection-observer-stub
options="[object Object]"
>
<!---->
<img
alt="test"
class="block mx-auto mw-100 mh-100 design-img"
data-qa-selector="design_image"
src=""
/>
</gl-intersection-observer-stub>
</div>
<div
class="card-footer d-flex w-100"
>
<div
class="d-flex flex-column str-truncated-100"
>
<span
class="bold str-truncated-100"
data-qa-selector="design_file_name"
>
test
</span>
<span
class="str-truncated-100"
>
Updated
<timeago-stub
cssclass=""
time="01-01-2019"
tooltipplacement="bottom"
/>
</span>
</div>
<!---->
</div>
</router-link-stub>
`;
exports[`Design management list item component with no notes renders item with no status icon for none event 1`] = `
<router-link-stub
class="card cursor-pointer text-plain js-design-list-item design-list-item"
to="[object Object]"
>
<div
class="card-body p-0 d-flex-center overflow-hidden position-relative"
>
<!---->
<gl-intersection-observer-stub
options="[object Object]"
>
<!---->
<img
alt="test"
class="block mx-auto mw-100 mh-100 design-img"
data-qa-selector="design_image"
src=""
/>
</gl-intersection-observer-stub>
</div>
<div
class="card-footer d-flex w-100"
>
<div
class="d-flex flex-column str-truncated-100"
>
<span
class="bold str-truncated-100"
data-qa-selector="design_file_name"
>
test
</span>
<span
class="str-truncated-100"
>
Updated
<timeago-stub
cssclass=""
time="01-01-2019"
tooltipplacement="bottom"
/>
</span>
</div>
<!---->
</div>
</router-link-stub>
`;
exports[`Design management list item component with no notes renders loading spinner when isUploading is true 1`] = `
<router-link-stub
class="card cursor-pointer text-plain js-design-list-item design-list-item"
to="[object Object]"
>
<div
class="card-body p-0 d-flex-center overflow-hidden position-relative"
>
<!---->
<gl-intersection-observer-stub
options="[object Object]"
>
<gl-loading-icon-stub
color="orange"
label="Loading"
size="md"
/>
<img
alt="test"
class="block mx-auto mw-100 mh-100 design-img"
data-qa-selector="design_image"
src=""
style="display: none;"
/>
</gl-intersection-observer-stub>
</div>
<div
class="card-footer d-flex w-100"
>
<div
class="d-flex flex-column str-truncated-100"
>
<span
class="bold str-truncated-100"
data-qa-selector="design_file_name"
>
test
</span>
<span
class="str-truncated-100"
>
Updated
<timeago-stub
cssclass=""
time="01-01-2019"
tooltipplacement="bottom"
/>
</span>
</div>
<!---->
</div>
</router-link-stub>
`;
exports[`Design management list item component with notes renders item with multiple comments 1`] = `
<router-link-stub
class="card cursor-pointer text-plain js-design-list-item design-list-item"
to="[object Object]"
>
<div
class="card-body p-0 d-flex-center overflow-hidden position-relative"
>
<!---->
<gl-intersection-observer-stub
options="[object Object]"
>
<!---->
<img
alt="test"
class="block mx-auto mw-100 mh-100 design-img"
data-qa-selector="design_image"
src=""
/>
</gl-intersection-observer-stub>
</div>
<div
class="card-footer d-flex w-100"
>
<div
class="d-flex flex-column str-truncated-100"
>
<span
class="bold str-truncated-100"
data-qa-selector="design_file_name"
>
test
</span>
<span
class="str-truncated-100"
>
Updated
<timeago-stub
cssclass=""
time="01-01-2019"
tooltipplacement="bottom"
/>
</span>
</div>
<div
class="ml-auto d-flex align-items-center text-secondary"
>
<icon-stub
class="ml-1"
name="comments"
size="16"
/>
<span
aria-label="2 comments"
class="ml-1"
>
2
</span>
</div>
</div>
</router-link-stub>
`;
exports[`Design management list item component with notes renders item with single comment 1`] = `
<router-link-stub
class="card cursor-pointer text-plain js-design-list-item design-list-item"
to="[object Object]"
>
<div
class="card-body p-0 d-flex-center overflow-hidden position-relative"
>
<!---->
<gl-intersection-observer-stub
options="[object Object]"
>
<!---->
<img
alt="test"
class="block mx-auto mw-100 mh-100 design-img"
data-qa-selector="design_image"
src=""
/>
</gl-intersection-observer-stub>
</div>
<div
class="card-footer d-flex w-100"
>
<div
class="d-flex flex-column str-truncated-100"
>
<span
class="bold str-truncated-100"
data-qa-selector="design_file_name"
>
test
</span>
<span
class="str-truncated-100"
>
Updated
<timeago-stub
cssclass=""
time="01-01-2019"
tooltipplacement="bottom"
/>
</span>
</div>
<div
class="ml-auto d-flex align-items-center text-secondary"
>
<icon-stub
class="ml-1"
name="comments"
size="16"
/>
<span
aria-label="1 comment"
class="ml-1"
>
1
</span>
</div>
</div>
</router-link-stub>
`;
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import VueRouter from 'vue-router';
import Item from '~/design_management_new/components/list/item.vue';
const localVue = createLocalVue();
localVue.use(VueRouter);
const router = new VueRouter();
// Referenced from: doc/api/graphql/reference/gitlab_schema.graphql:DesignVersionEvent
const DESIGN_VERSION_EVENT = {
CREATION: 'CREATION',
DELETION: 'DELETION',
MODIFICATION: 'MODIFICATION',
NO_CHANGE: 'NONE',
};
describe('Design management list item component', () => {
let wrapper;
function createComponent({
notesCount = 0,
event = DESIGN_VERSION_EVENT.NO_CHANGE,
isUploading = false,
imageLoading = false,
} = {}) {
wrapper = shallowMount(Item, {
localVue,
router,
propsData: {
id: 1,
filename: 'test',
image: 'http://via.placeholder.com/300',
isUploading,
event,
notesCount,
updatedAt: '01-01-2019',
},
data() {
return {
imageLoading,
};
},
stubs: ['router-link'],
});
}
afterEach(() => {
wrapper.destroy();
});
describe('when item is not in view', () => {
it('image is not rendered', () => {
createComponent();
const image = wrapper.find('img');
expect(image.attributes('src')).toBe('');
});
});
describe('when item appears in view', () => {
let image;
let glIntersectionObserver;
beforeEach(() => {
createComponent();
image = wrapper.find('img');
glIntersectionObserver = wrapper.find(GlIntersectionObserver);
glIntersectionObserver.vm.$emit('appear');
return wrapper.vm.$nextTick();
});
describe('before image is loaded', () => {
it('renders loading spinner', () => {
expect(wrapper.find(GlLoadingIcon)).toExist();
});
});
describe('after image is loaded', () => {
beforeEach(() => {
image.trigger('load');
return wrapper.vm.$nextTick();
});
it('renders an image', () => {
expect(image.attributes('src')).toBe('http://via.placeholder.com/300');
expect(image.isVisible()).toBe(true);
});
it('renders media broken icon when image onerror triggered', () => {
image.trigger('error');
return wrapper.vm.$nextTick().then(() => {
expect(image.isVisible()).toBe(false);
expect(wrapper.find(GlIcon).element).toMatchSnapshot();
});
});
describe('when imageV432x230 and image provided', () => {
it('renders imageV432x230 image', () => {
const mockSrc = 'mock-imageV432x230-url';
wrapper.setProps({ imageV432x230: mockSrc });
return wrapper.vm.$nextTick().then(() => {
expect(image.attributes('src')).toBe(mockSrc);
});
});
});
describe('when image disappears from view and then reappears', () => {
beforeEach(() => {
glIntersectionObserver.vm.$emit('appear');
return wrapper.vm.$nextTick();
});
it('renders an image', () => {
expect(image.isVisible()).toBe(true);
});
});
});
});
describe('with notes', () => {
it('renders item with single comment', () => {
createComponent({ notesCount: 1 });
expect(wrapper.element).toMatchSnapshot();
});
it('renders item with multiple comments', () => {
createComponent({ notesCount: 2 });
expect(wrapper.element).toMatchSnapshot();
});
});
describe('with no notes', () => {
it('renders item with no status icon for none event', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('renders item with correct status icon for modification event', () => {
createComponent({ event: DESIGN_VERSION_EVENT.MODIFICATION });
expect(wrapper.element).toMatchSnapshot();
});
it('renders item with correct status icon for deletion event', () => {
createComponent({ event: DESIGN_VERSION_EVENT.DELETION });
expect(wrapper.element).toMatchSnapshot();
});
it('renders item with correct status icon for creation event', () => {
createComponent({ event: DESIGN_VERSION_EVENT.CREATION });
expect(wrapper.element).toMatchSnapshot();
});
it('renders loading spinner when isUploading is true', () => {
createComponent({ isUploading: true });
expect(wrapper.element).toMatchSnapshot();
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management toolbar component renders design and updated data 1`] = `
<header
class="d-flex p-2 bg-white align-items-center js-design-header"
>
<a
aria-label="Go back to designs"
class="mr-3 text-plain d-flex justify-content-center align-items-center"
>
<icon-stub
name="close"
size="18"
/>
</a>
<div
class="overflow-hidden d-flex align-items-center"
>
<h2
class="m-0 str-truncated-100 gl-font-base"
>
test.jpg
</h2>
<small
class="text-secondary"
>
Updated 1 hour ago by Test Name
</small>
</div>
<pagination-stub
class="ml-auto flex-shrink-0"
id="1"
/>
<gl-deprecated-button-stub
class="mr-2"
href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d"
size="md"
variant="secondary"
>
<icon-stub
name="download"
size="18"
/>
</gl-deprecated-button-stub>
<delete-button-stub
buttonclass=""
buttonvariant="danger"
hasselecteddesigns="true"
>
<icon-stub
name="remove"
size="18"
/>
</delete-button-stub>
</header>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management pagination button component disables button when no design is passed 1`] = `
<router-link-stub
aria-label="Test title"
class="btn btn-default disabled"
disabled="true"
to="[object Object]"
>
<icon-stub
name="angle-right"
size="16"
/>
</router-link-stub>
`;
exports[`Design management pagination button component renders router-link 1`] = `
<router-link-stub
aria-label="Test title"
class="btn btn-default"
to="[object Object]"
>
<icon-stub
name="angle-right"
size="16"
/>
</router-link-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management pagination component hides components when designs are empty 1`] = `<!---->`;
exports[`Design management pagination component renders pagination buttons 1`] = `
<div
class="d-flex align-items-center"
>
0 of 2
<div
class="btn-group ml-3 mr-3"
>
<pagination-button-stub
class="js-previous-design"
iconname="angle-left"
title="Go to previous design"
/>
<pagination-button-stub
class="js-next-design"
design="[object Object]"
iconname="angle-right"
title="Go to next design"
/>
</div>
</div>
`;
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueRouter from 'vue-router';
import Toolbar from '~/design_management_new/components/toolbar/index.vue';
import DeleteButton from '~/design_management_new/components/delete_button.vue';
import { DESIGNS_ROUTE_NAME } from '~/design_management_new/router/constants';
import { GlDeprecatedButton } from '@gitlab/ui';
const localVue = createLocalVue();
localVue.use(VueRouter);
const router = new VueRouter();
const RouterLinkStub = {
props: {
to: {
type: Object,
},
},
render(createElement) {
return createElement('a', {}, this.$slots.default);
},
};
describe('Design management toolbar component', () => {
let wrapper;
function createComponent(isLoading = false, createDesign = true, props) {
const updatedAt = new Date();
updatedAt.setHours(updatedAt.getHours() - 1);
wrapper = shallowMount(Toolbar, {
localVue,
router,
propsData: {
id: '1',
isLatestVersion: true,
isLoading,
isDeleting: false,
filename: 'test.jpg',
updatedAt: updatedAt.toString(),
updatedBy: {
name: 'Test Name',
},
image: '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d',
...props,
},
stubs: {
'router-link': RouterLinkStub,
},
});
wrapper.setData({
permissions: {
createDesign,
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('renders design and updated data', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('links back to designs list', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
const link = wrapper.find('a');
expect(link.props('to')).toEqual({
name: DESIGNS_ROUTE_NAME,
query: {
version: undefined,
},
});
});
});
it('renders delete button on latest designs version with logged in user', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DeleteButton).exists()).toBe(true);
});
});
it('does not render delete button on non-latest version', () => {
createComponent(false, true, { isLatestVersion: false });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DeleteButton).exists()).toBe(false);
});
});
it('does not render delete button when user is not logged in', () => {
createComponent(false, false);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DeleteButton).exists()).toBe(false);
});
});
it('emits `delete` event on deleteButton `deleteSelectedDesigns` event', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
wrapper.find(DeleteButton).vm.$emit('deleteSelectedDesigns');
expect(wrapper.emitted().delete).toBeTruthy();
});
});
it('renders download button with correct link', () => {
expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe(
'/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d',
);
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueRouter from 'vue-router';
import PaginationButton from '~/design_management_new/components/toolbar/pagination_button.vue';
import { DESIGN_ROUTE_NAME } from '~/design_management_new/router/constants';
const localVue = createLocalVue();
localVue.use(VueRouter);
const router = new VueRouter();
describe('Design management pagination button component', () => {
let wrapper;
function createComponent(design = null) {
wrapper = shallowMount(PaginationButton, {
localVue,
router,
propsData: {
design,
title: 'Test title',
iconName: 'angle-right',
},
stubs: ['router-link'],
});
}
afterEach(() => {
wrapper.destroy();
});
it('disables button when no design is passed', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('renders router-link', () => {
createComponent({ id: '2' });
expect(wrapper.element).toMatchSnapshot();
});
describe('designLink', () => {
it('returns empty link when design is null', () => {
createComponent();
expect(wrapper.vm.designLink).toEqual({});
});
it('returns design link', () => {
createComponent({ id: '2', filename: 'test' });
wrapper.vm.$router.replace('/root/test-project/issues/1/designs/test?version=1');
expect(wrapper.vm.designLink).toEqual({
name: DESIGN_ROUTE_NAME,
params: { id: 'test' },
query: { version: '1' },
});
});
});
});
/* global Mousetrap */
import 'mousetrap';
import { shallowMount } from '@vue/test-utils';
import Pagination from '~/design_management_new/components/toolbar/pagination.vue';
import { DESIGN_ROUTE_NAME } from '~/design_management_new/router/constants';
const push = jest.fn();
const $router = {
push,
};
const $route = {
path: '/designs/design-2',
query: {},
};
describe('Design management pagination component', () => {
let wrapper;
function createComponent() {
wrapper = shallowMount(Pagination, {
propsData: {
id: '2',
},
mocks: {
$router,
$route,
},
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('hides components when designs are empty', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders pagination buttons', () => {
wrapper.setData({
designs: [{ id: '1' }, { id: '2' }],
});
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
describe('keyboard buttons navigation', () => {
beforeEach(() => {
wrapper.setData({
designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }],
});
});
it('routes to previous design on Left button', () => {
Mousetrap.trigger('left');
expect(push).toHaveBeenCalledWith({
name: DESIGN_ROUTE_NAME,
params: { id: '1' },
query: {},
});
});
it('routes to next design on Right button', () => {
Mousetrap.trigger('right');
expect(push).toHaveBeenCalledWith({
name: DESIGN_ROUTE_NAME,
params: { id: '3' },
query: {},
});
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management upload button component renders inverted upload design button 1`] = `
<div
isinverted="true"
>
<gl-deprecated-button-stub
size="md"
title="Adding a design with the same filename replaces the file in a new version."
variant="success"
>
Upload designs
<!---->
</gl-deprecated-button-stub>
<input
accept="image/*"
class="hide"
multiple="multiple"
name="design_file"
type="file"
/>
</div>
`;
exports[`Design management upload button component renders loading icon 1`] = `
<div>
<gl-deprecated-button-stub
disabled="true"
size="md"
title="Adding a design with the same filename replaces the file in a new version."
variant="success"
>
Upload designs
<gl-loading-icon-stub
class="ml-1"
color="orange"
inline="true"
label="Loading"
size="sm"
/>
</gl-deprecated-button-stub>
<input
accept="image/*"
class="hide"
multiple="multiple"
name="design_file"
type="file"
/>
</div>
`;
exports[`Design management upload button component renders upload design button 1`] = `
<div>
<gl-deprecated-button-stub
size="md"
title="Adding a design with the same filename replaces the file in a new version."
variant="success"
>
Upload designs
<!---->
</gl-deprecated-button-stub>
<input
accept="image/*"
class="hide"
multiple="multiple"
name="design_file"
type="file"
/>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management dropzone component when dragging renders correct template when drag event contains files 1`] = `
<div
class="w-100 position-relative"
>
<button
class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
>
<div
class="d-flex-center flex-column text-center"
>
<gl-icon-stub
class="mb-4"
name="doc-new"
size="48"
/>
<p>
<gl-sprintf-stub
message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
/>
</p>
</div>
</button>
<input
accept="image/*"
class="hide"
multiple="multiple"
name="design_file"
type="file"
/>
<transition-stub
name="design-dropzone-fade"
>
<div
class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
style=""
>
<div
class="mw-50 text-center"
style="display: none;"
>
<h3>
Oh no!
</h3>
<span>
You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
</span>
</div>
<div
class="mw-50 text-center"
style=""
>
<h3>
Incoming!
</h3>
<span>
Drop your designs to start your upload.
</span>
</div>
</div>
</transition-stub>
</div>
`;
exports[`Design management dropzone component when dragging renders correct template when drag event contains files and text 1`] = `
<div
class="w-100 position-relative"
>
<button
class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
>
<div
class="d-flex-center flex-column text-center"
>
<gl-icon-stub
class="mb-4"
name="doc-new"
size="48"
/>
<p>
<gl-sprintf-stub
message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
/>
</p>
</div>
</button>
<input
accept="image/*"
class="hide"
multiple="multiple"
name="design_file"
type="file"
/>
<transition-stub
name="design-dropzone-fade"
>
<div
class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
style=""
>
<div
class="mw-50 text-center"
style="display: none;"
>
<h3>
Oh no!
</h3>
<span>
You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
</span>
</div>
<div
class="mw-50 text-center"
style=""
>
<h3>
Incoming!
</h3>
<span>
Drop your designs to start your upload.
</span>
</div>
</div>
</transition-stub>
</div>
`;
exports[`Design management dropzone component when dragging renders correct template when drag event contains text 1`] = `
<div
class="w-100 position-relative"
>
<button
class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
>
<div
class="d-flex-center flex-column text-center"
>
<gl-icon-stub
class="mb-4"
name="doc-new"
size="48"
/>
<p>
<gl-sprintf-stub
message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
/>
</p>
</div>
</button>
<input
accept="image/*"
class="hide"
multiple="multiple"
name="design_file"
type="file"
/>
<transition-stub
name="design-dropzone-fade"
>
<div
class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
style=""
>
<div
class="mw-50 text-center"
>
<h3>
Oh no!
</h3>
<span>
You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
</span>
</div>
<div
class="mw-50 text-center"
style="display: none;"
>
<h3>
Incoming!
</h3>
<span>
Drop your designs to start your upload.
</span>
</div>
</div>
</transition-stub>
</div>
`;
exports[`Design management dropzone component when dragging renders correct template when drag event is empty 1`] = `
<div
class="w-100 position-relative"
>
<button
class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
>
<div
class="d-flex-center flex-column text-center"
>
<gl-icon-stub
class="mb-4"
name="doc-new"
size="48"
/>
<p>
<gl-sprintf-stub
message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
/>
</p>
</div>
</button>
<input
accept="image/*"
class="hide"
multiple="multiple"
name="design_file"
type="file"
/>
<transition-stub
name="design-dropzone-fade"
>
<div
class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
style=""
>
<div
class="mw-50 text-center"
>
<h3>
Oh no!
</h3>
<span>
You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
</span>
</div>
<div
class="mw-50 text-center"
style="display: none;"
>
<h3>
Incoming!
</h3>
<span>
Drop your designs to start your upload.
</span>
</div>
</div>
</transition-stub>
</div>
`;
exports[`Design management dropzone component when dragging renders correct template when dragging stops 1`] = `
<div
class="w-100 position-relative"
>
<button
class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
>
<div
class="d-flex-center flex-column text-center"
>
<gl-icon-stub
class="mb-4"
name="doc-new"
size="48"
/>
<p>
<gl-sprintf-stub
message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
/>
</p>
</div>
</button>
<input
accept="image/*"
class="hide"
multiple="multiple"
name="design_file"
type="file"
/>
<transition-stub
name="design-dropzone-fade"
>
<div
class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
style="display: none;"
>
<div
class="mw-50 text-center"
>
<h3>
Oh no!
</h3>
<span>
You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
</span>
</div>
<div
class="mw-50 text-center"
style="display: none;"
>
<h3>
Incoming!
</h3>
<span>
Drop your designs to start your upload.
</span>
</div>
</div>
</transition-stub>
</div>
`;
exports[`Design management dropzone component when no slot provided renders default dropzone card 1`] = `
<div
class="w-100 position-relative"
>
<button
class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
>
<div
class="d-flex-center flex-column text-center"
>
<gl-icon-stub
class="mb-4"
name="doc-new"
size="48"
/>
<p>
<gl-sprintf-stub
message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
/>
</p>
</div>
</button>
<input
accept="image/*"
class="hide"
multiple="multiple"
name="design_file"
type="file"
/>
<transition-stub
name="design-dropzone-fade"
>
<div
class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
style="display: none;"
>
<div
class="mw-50 text-center"
>
<h3>
Oh no!
</h3>
<span>
You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
</span>
</div>
<div
class="mw-50 text-center"
style="display: none;"
>
<h3>
Incoming!
</h3>
<span>
Drop your designs to start your upload.
</span>
</div>
</div>
</transition-stub>
</div>
`;
exports[`Design management dropzone component when slot provided renders dropzone with slot content 1`] = `
<div
class="w-100 position-relative"
>
<div>
dropzone slot
</div>
<transition-stub
name="design-dropzone-fade"
>
<div
class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
style="display: none;"
>
<div
class="mw-50 text-center"
>
<h3>
Oh no!
</h3>
<span>
You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
</span>
</div>
<div
class="mw-50 text-center"
style="display: none;"
>
<h3>
Incoming!
</h3>
<span>
Drop your designs to start your upload.
</span>
</div>
</div>
</transition-stub>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management design version dropdown component renders design version dropdown button 1`] = `
<gl-dropdown-stub
class="design-version-dropdown"
issueiid=""
projectpath=""
text="Showing Latest Version"
variant="link"
>
<gl-dropdown-item-stub>
<router-link-stub
class="d-flex js-version-link"
to="[object Object]"
>
<div
class="flex-grow-1 ml-2"
>
<div>
<strong>
Version 2
<span>
(latest)
</span>
</strong>
</div>
</div>
<i
class="fa fa-check pull-right"
/>
</router-link-stub>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub>
<router-link-stub
class="d-flex js-version-link"
to="[object Object]"
>
<div
class="flex-grow-1 ml-2"
>
<div>
<strong>
Version 1
<!---->
</strong>
</div>
</div>
<!---->
</router-link-stub>
</gl-dropdown-item-stub>
</gl-dropdown-stub>
`;
exports[`Design management design version dropdown component renders design version list 1`] = `
<gl-dropdown-stub
class="design-version-dropdown"
issueiid=""
projectpath=""
text="Showing Latest Version"
variant="link"
>
<gl-dropdown-item-stub>
<router-link-stub
class="d-flex js-version-link"
to="[object Object]"
>
<div
class="flex-grow-1 ml-2"
>
<div>
<strong>
Version 2
<span>
(latest)
</span>
</strong>
</div>
</div>
<i
class="fa fa-check pull-right"
/>
</router-link-stub>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub>
<router-link-stub
class="d-flex js-version-link"
to="[object Object]"
>
<div
class="flex-grow-1 ml-2"
>
<div>
<strong>
Version 1
<!---->
</strong>
</div>
</div>
<!---->
</router-link-stub>
</gl-dropdown-item-stub>
</gl-dropdown-stub>
`;
import { shallowMount } from '@vue/test-utils';
import UploadButton from '~/design_management_new/components/upload/button.vue';
describe('Design management upload button component', () => {
let wrapper;
function createComponent(isSaving = false, isInverted = false) {
wrapper = shallowMount(UploadButton, {
propsData: {
isSaving,
isInverted,
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('renders upload design button', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('renders inverted upload design button', () => {
createComponent(false, true);
expect(wrapper.element).toMatchSnapshot();
});
it('renders loading icon', () => {
createComponent(true);
expect(wrapper.element).toMatchSnapshot();
});
describe('onFileUploadChange', () => {
it('emits upload event', () => {
createComponent();
wrapper.vm.onFileUploadChange({ target: { files: 'test' } });
expect(wrapper.emitted().upload[0]).toEqual(['test']);
});
});
describe('openFileUpload', () => {
it('triggers click on input', () => {
createComponent();
const clickSpy = jest.spyOn(wrapper.find('input').element, 'click');
wrapper.vm.openFileUpload();
expect(clickSpy).toHaveBeenCalled();
});
});
});
import { shallowMount } from '@vue/test-utils';
import DesignDropzone from '~/design_management_new/components/upload/design_dropzone.vue';
import createFlash from '~/flash';
jest.mock('~/flash');
describe('Design management dropzone component', () => {
let wrapper;
const mockDragEvent = ({ types = ['Files'], files = [] }) => {
return { dataTransfer: { types, files } };
};
const findDropzoneCard = () => wrapper.find('.design-dropzone-card');
function createComponent({ slots = {}, data = {} } = {}) {
wrapper = shallowMount(DesignDropzone, {
slots,
data() {
return data;
},
});
}
afterEach(() => {
wrapper.destroy();
});
describe('when slot provided', () => {
it('renders dropzone with slot content', () => {
createComponent({
slots: {
default: ['<div>dropzone slot</div>'],
},
});
expect(wrapper.element).toMatchSnapshot();
});
});
describe('when no slot provided', () => {
it('renders default dropzone card', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('triggers click event on file input element when clicked', () => {
createComponent();
const clickSpy = jest.spyOn(wrapper.find('input').element, 'click');
findDropzoneCard().trigger('click');
expect(clickSpy).toHaveBeenCalled();
});
});
describe('when dragging', () => {
it.each`
description | eventPayload
${'is empty'} | ${{}}
${'contains text'} | ${mockDragEvent({ types: ['text'] })}
${'contains files and text'} | ${mockDragEvent({ types: ['Files', 'text'] })}
${'contains files'} | ${mockDragEvent({ types: ['Files'] })}
`('renders correct template when drag event $description', ({ eventPayload }) => {
createComponent();
wrapper.trigger('dragenter', eventPayload);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('renders correct template when dragging stops', () => {
createComponent();
wrapper.trigger('dragenter');
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.trigger('dragleave');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
});
describe('when dropping', () => {
it('emits upload event', () => {
createComponent();
const mockFile = { name: 'test', type: 'image/jpg' };
const mockEvent = mockDragEvent({ files: [mockFile] });
wrapper.trigger('dragenter', mockEvent);
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.trigger('drop', mockEvent);
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.emitted().change[0]).toEqual([[mockFile]]);
});
});
});
describe('ondrop', () => {
const mockData = { dragCounter: 1, isDragDataValid: true };
describe('when drag data is valid', () => {
it('emits upload event for valid files', () => {
createComponent({ data: mockData });
const mockFile = { type: 'image/jpg' };
const mockEvent = mockDragEvent({ files: [mockFile] });
wrapper.vm.ondrop(mockEvent);
expect(wrapper.emitted().change[0]).toEqual([[mockFile]]);
});
it('calls createFlash when files are invalid', () => {
createComponent({ data: mockData });
const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] });
wrapper.vm.ondrop(mockEvent);
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import DesignVersionDropdown from '~/design_management_new/components/upload/design_version_dropdown.vue';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import mockAllVersions from './mock_data/all_versions';
const LATEST_VERSION_ID = 3;
const PREVIOUS_VERSION_ID = 2;
const designRouteFactory = versionId => ({
path: `/designs?version=${versionId}`,
query: {
version: `${versionId}`,
},
});
const MOCK_ROUTE = {
path: '/designs',
query: {},
};
describe('Design management design version dropdown component', () => {
let wrapper;
function createComponent({ maxVersions = -1, $route = MOCK_ROUTE } = {}) {
wrapper = shallowMount(DesignVersionDropdown, {
propsData: {
projectPath: '',
issueIid: '',
},
mocks: {
$route,
},
stubs: ['router-link'],
});
wrapper.setData({
allVersions: maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions,
});
}
afterEach(() => {
wrapper.destroy();
});
const findVersionLink = index => wrapper.findAll('.js-version-link').at(index);
it('renders design version dropdown button', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('renders design version list', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
describe('selected version name', () => {
it('has "latest" on most recent version item', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(findVersionLink(0).text()).toContain('latest');
});
});
});
describe('versions list', () => {
it('displays latest version text by default', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing Latest Version');
});
});
it('displays latest version text when only 1 version is present', () => {
createComponent({ maxVersions: 1 });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing Latest Version');
});
});
it('displays version text when the current version is not the latest', () => {
createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(GlDropdown).attributes('text')).toBe(`Showing Version #1`);
});
});
it('displays latest version text when the current version is the latest', () => {
createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing Latest Version');
});
});
it('should have the same length as apollo query', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.findAll(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length);
});
});
});
});
export default [
{
node: {
id: 'gid://gitlab/DesignManagement::Version/3',
sha: '0945756378e0b1588b9dd40d5a6b99e8b7198f55',
},
},
{
node: {
id: 'gid://gitlab/DesignManagement::Version/2',
sha: '5b063fef0cd7213b312db65b30e24f057df21b20',
},
},
];
export default [
{
node: {
id: 'gid://gitlab/DesignManagement::Version/1',
sha: 'b389071a06c153509e11da1f582005b316667001',
},
},
];
export default {
id: 'design-id',
filename: 'test.jpg',
fullPath: 'full-design-path',
image: 'test.jpg',
updatedAt: '01-01-2019',
updatedBy: {
name: 'test',
},
issue: {
title: 'My precious issue',
webPath: 'full-issue-path',
webUrl: 'full-issue-url',
participants: {
edges: [
{
node: {
name: 'Administrator',
username: 'root',
webUrl: 'link-to-author',
avatarUrl: 'link-to-avatar',
},
},
],
},
},
discussions: {
nodes: [
{
id: 'discussion-id',
replyId: 'discussion-reply-id',
resolved: false,
notes: {
nodes: [
{
id: 'note-id',
body: '123',
author: {
name: 'Administrator',
username: 'root',
webUrl: 'link-to-author',
avatarUrl: 'link-to-avatar',
},
},
],
},
},
{
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: {
headSha: 'headSha',
baseSha: 'baseSha',
startSha: 'startSha',
},
};
import design from './design';
export default {
project: {
issue: {
designCollection: {
designs: {
edges: [
{
node: design,
},
],
},
},
},
},
};
export default {
project: {
issue: {
designCollection: {
designs: {
edges: [],
},
},
},
},
};
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,
},
];
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management index page designs does not render toolbar when there is no permission 1`] = `
<div>
<!---->
<div
class="mt-4"
>
<ol
class="list-unstyled row"
>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-dropzone-stub
class="design-list-item"
/>
</li>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-dropzone-stub>
<design-stub
event="NONE"
filename="design-1-name"
id="design-1"
image="design-1-image"
notescount="0"
/>
</design-dropzone-stub>
<!---->
</li>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-dropzone-stub>
<design-stub
event="NONE"
filename="design-2-name"
id="design-2"
image="design-2-image"
notescount="1"
/>
</design-dropzone-stub>
<!---->
</li>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-dropzone-stub>
<design-stub
event="NONE"
filename="design-3-name"
id="design-3"
image="design-3-image"
notescount="0"
/>
</design-dropzone-stub>
<!---->
</li>
</ol>
</div>
<router-view-stub
name="default"
/>
</div>
`;
exports[`Design management index page designs renders designs list and header with upload button 1`] = `
<div>
<header
class="row-content-block border-top-0 p-2 d-flex"
>
<div
class="d-flex justify-content-between align-items-center w-100"
>
<design-version-dropdown-stub />
<div
class="qa-selector-toolbar d-flex"
>
<gl-deprecated-button-stub
class="mr-2 js-select-all"
size="md"
variant="link"
>
Select all
</gl-deprecated-button-stub>
<div>
<delete-button-stub
buttonclass="btn-danger btn-inverted mr-2"
buttonvariant=""
>
Delete selected
<!---->
</delete-button-stub>
</div>
<upload-button-stub />
</div>
</div>
</header>
<div
class="mt-4"
>
<ol
class="list-unstyled row"
>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-dropzone-stub
class="design-list-item"
/>
</li>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-dropzone-stub>
<design-stub
event="NONE"
filename="design-1-name"
id="design-1"
image="design-1-image"
notescount="0"
/>
</design-dropzone-stub>
<input
class="design-checkbox"
type="checkbox"
/>
</li>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-dropzone-stub>
<design-stub
event="NONE"
filename="design-2-name"
id="design-2"
image="design-2-image"
notescount="1"
/>
</design-dropzone-stub>
<input
class="design-checkbox"
type="checkbox"
/>
</li>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-dropzone-stub>
<design-stub
event="NONE"
filename="design-3-name"
id="design-3"
image="design-3-image"
notescount="0"
/>
</design-dropzone-stub>
<input
class="design-checkbox"
type="checkbox"
/>
</li>
</ol>
</div>
<router-view-stub
name="default"
/>
</div>
`;
exports[`Design management index page designs renders error 1`] = `
<div>
<!---->
<div
class="mt-4"
>
<gl-alert-stub
dismisslabel="Dismiss"
primarybuttonlink=""
primarybuttontext=""
secondarybuttonlink=""
secondarybuttontext=""
title=""
variant="danger"
>
An error occurred while loading designs. Please try again.
</gl-alert-stub>
</div>
<router-view-stub
name="default"
/>
</div>
`;
exports[`Design management index page designs renders loading icon 1`] = `
<div>
<!---->
<div
class="mt-4"
>
<gl-loading-icon-stub
color="orange"
label="Loading"
size="md"
/>
</div>
<router-view-stub
name="default"
/>
</div>
`;
exports[`Design management index page when has no designs renders empty text 1`] = `
<div>
<!---->
<div
class="mt-4"
>
<ol
class="list-unstyled row"
>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-dropzone-stub
class="design-list-item"
/>
</li>
</ol>
</div>
<router-view-stub
name="default"
/>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management design index page renders design index 1`] = `
<div
class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
>
<div
class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"
>
<design-destroyer-stub
filenames="test.jpg"
iid="1"
projectpath=""
/>
<!---->
<design-presentation-stub
discussions="[object Object],[object Object]"
image="test.jpg"
imagename="test.jpg"
scale="1"
/>
<div
class="design-scaler-wrapper position-absolute mb-4 d-flex-center"
>
<design-scaler-stub />
</div>
</div>
<div
class="image-notes"
>
<h2
class="gl-font-weight-bold gl-mt-0"
>
My precious issue
</h2>
<a
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="gl-mb-4"
numberoflessparticipants="7"
participants="[object Object]"
/>
<!---->
<design-discussion-stub
data-testid="unresolved-discussion"
designid="test"
discussion="[object Object]"
discussionwithopenform=""
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"
>
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>
<a
href="#"
rel="noopener noreferrer"
target="_blank"
>
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]"
discussionwithopenform=""
markdownpreviewpath="//preview_markdown?target_type=Issue"
noteableid="design-id"
/>
</gl-collapse-stub>
</div>
</div>
`;
exports[`Design management design index page sets loading state 1`] = `
<div
class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
>
<gl-loading-icon-stub
class="align-self-center"
color="orange"
label="Loading"
size="xl"
/>
</div>
`;
exports[`Design management design index page with error GlAlert is rendered in correct position with correct content 1`] = `
<div
class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
>
<div
class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"
>
<design-destroyer-stub
filenames="test.jpg"
iid="1"
projectpath=""
/>
<div
class="p-3"
>
<gl-alert-stub
dismissible="true"
dismisslabel="Dismiss"
primarybuttonlink=""
primarybuttontext=""
secondarybuttonlink=""
secondarybuttontext=""
title=""
variant="danger"
>
woops
</gl-alert-stub>
</div>
<design-presentation-stub
discussions=""
image="test.jpg"
imagename="test.jpg"
scale="1"
/>
<div
class="design-scaler-wrapper position-absolute mb-4 d-flex-center"
>
<design-scaler-stub />
</div>
</div>
<div
class="image-notes"
>
<h2
class="gl-font-weight-bold gl-mt-0"
>
My precious issue
</h2>
<a
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="gl-mb-4"
numberoflessparticipants="7"
participants="[object Object]"
/>
<h2
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
</h2>
<!---->
</div>
</div>
`;
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueRouter from 'vue-router';
import { GlAlert } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
import createFlash from '~/flash';
import DesignIndex from '~/design_management_new/pages/design/index.vue';
import DesignSidebar from '~/design_management_new/components/design_sidebar.vue';
import DesignPresentation from '~/design_management_new/components/design_presentation.vue';
import createImageDiffNoteMutation from '~/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql';
import design from '../../mock_data/design';
import mockResponseWithDesigns from '../../mock_data/designs';
import mockResponseNoDesigns from '../../mock_data/no_designs';
import mockAllVersions from '../../mock_data/all_versions';
import {
DESIGN_NOT_FOUND_ERROR,
DESIGN_VERSION_NOT_EXIST_ERROR,
} from '~/design_management_new/utils/error_messages';
import { DESIGNS_ROUTE_NAME } from '~/design_management_new/router/constants';
import createRouter from '~/design_management_new/router';
import * as utils from '~/design_management_new/utils/design_management_utils';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management_new/constants';
jest.mock('~/flash');
jest.mock('mousetrap', () => ({
bind: jest.fn(),
unbind: jest.fn(),
}));
const focusInput = jest.fn();
const DesignReplyForm = {
template: '<div><textarea ref="textarea"></textarea></div>',
methods: {
focusInput,
},
};
const localVue = createLocalVue();
localVue.use(VueRouter);
describe('Design management design index page', () => {
let wrapper;
let router;
const newComment = 'new comment';
const annotationCoordinates = {
x: 10,
y: 10,
width: 100,
height: 100,
};
const createDiscussionMutationVariables = {
mutation: createImageDiffNoteMutation,
update: expect.anything(),
variables: {
input: {
body: newComment,
noteableId: design.id,
position: {
headSha: 'headSha',
baseSha: 'baseSha',
startSha: 'startSha',
paths: {
newPath: 'full-design-path',
},
...annotationCoordinates,
},
},
},
};
const mutate = jest.fn().mockResolvedValue();
const findDiscussionForm = () => wrapper.find(DesignReplyForm);
const findSidebar = () => wrapper.find(DesignSidebar);
const findDesignPresentation = () => wrapper.find(DesignPresentation);
function createComponent(loading = false, data = {}) {
const $apollo = {
queries: {
design: {
loading,
},
},
mutate,
};
router = createRouter();
wrapper = shallowMount(DesignIndex, {
propsData: { id: '1' },
mocks: { $apollo },
stubs: {
ApolloMutation,
DesignSidebar,
DesignReplyForm,
},
data() {
return {
issueIid: '1',
activeDiscussion: {
id: null,
source: null,
},
...data,
};
},
localVue,
router,
});
}
afterEach(() => {
wrapper.destroy();
});
describe('when navigating', () => {
it('applies fullscreen layout', () => {
const mockEl = {
classList: {
add: jest.fn(),
remove: jest.fn(),
},
};
jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockEl);
createComponent(true);
wrapper.vm.$router.push('/designs/test');
expect(mockEl.classList.add).toHaveBeenCalledTimes(1);
expect(mockEl.classList.add).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
});
});
it('sets loading state', () => {
createComponent(true);
expect(wrapper.element).toMatchSnapshot();
});
it('renders design index', () => {
createComponent(false, { design });
expect(wrapper.element).toMatchSnapshot();
expect(wrapper.find(GlAlert).exists()).toBe(false);
});
it('passes correct props to sidebar component', () => {
createComponent(false, { design });
expect(findSidebar().props()).toEqual({
design,
markdownPreviewPath: '//preview_markdown?target_type=Issue',
resolvedDiscussionsExpanded: false,
});
});
it('opens a new discussion form', () => {
createComponent(false, {
design: {
...design,
discussions: {
nodes: [],
},
},
});
findDesignPresentation().vm.$emit('openCommentForm', { x: 0, y: 0 });
return wrapper.vm.$nextTick().then(() => {
expect(findDiscussionForm().exists()).toBe(true);
});
});
it('keeps new discussion form focused', () => {
createComponent(false, {
design: {
...design,
discussions: {
nodes: [],
},
},
annotationCoordinates,
});
findDesignPresentation().vm.$emit('openCommentForm', { x: 10, y: 10 });
expect(focusInput).toHaveBeenCalled();
});
it('sends a mutation on submitting form and closes form', () => {
createComponent(false, {
design: {
...design,
discussions: {
nodes: [],
},
},
annotationCoordinates,
comment: newComment,
});
findDiscussionForm().vm.$emit('submitForm');
expect(mutate).toHaveBeenCalledWith(createDiscussionMutationVariables);
return wrapper.vm
.$nextTick()
.then(() => {
return mutate({ variables: createDiscussionMutationVariables });
})
.then(() => {
expect(findDiscussionForm().exists()).toBe(false);
});
});
it('closes the form and clears the comment on canceling form', () => {
createComponent(false, {
design: {
...design,
discussions: {
nodes: [],
},
},
annotationCoordinates,
comment: newComment,
});
findDiscussionForm().vm.$emit('cancelForm');
expect(wrapper.vm.comment).toBe('');
return wrapper.vm.$nextTick().then(() => {
expect(findDiscussionForm().exists()).toBe(false);
});
});
describe('with error', () => {
beforeEach(() => {
createComponent(false, {
design: {
...design,
discussions: {
nodes: [],
},
},
errorMessage: 'woops',
});
});
it('GlAlert is rendered in correct position with correct content', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
describe('onDesignQueryResult', () => {
describe('with no designs', () => {
it('redirects to /designs', () => {
createComponent(true);
router.push = jest.fn();
wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false });
return wrapper.vm.$nextTick().then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(DESIGN_NOT_FOUND_ERROR);
expect(router.push).toHaveBeenCalledTimes(1);
expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME });
});
});
});
describe('when no design exists for given version', () => {
it('redirects to /designs', () => {
createComponent(true);
wrapper.setData({
allVersions: mockAllVersions,
});
// attempt to query for a version of the design that doesn't exist
router.push({ query: { version: '999' } });
router.push = jest.fn();
wrapper.vm.onDesignQueryResult({ data: mockResponseWithDesigns, loading: false });
return wrapper.vm.$nextTick().then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(DESIGN_VERSION_NOT_EXIST_ERROR);
expect(router.push).toHaveBeenCalledTimes(1);
expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME });
});
});
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { ApolloMutation } from 'vue-apollo';
import VueRouter from 'vue-router';
import { GlEmptyState } from '@gitlab/ui';
import Index from '~/design_management_new/pages/index.vue';
import uploadDesignQuery from '~/design_management_new/graphql/mutations/upload_design.mutation.graphql';
import DesignDestroyer from '~/design_management_new/components/design_destroyer.vue';
import DesignDropzone from '~/design_management_new/components/upload/design_dropzone.vue';
import DeleteButton from '~/design_management_new/components/delete_button.vue';
import { DESIGNS_ROUTE_NAME } from '~/design_management_new/router/constants';
import {
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
} from '~/design_management_new/utils/error_messages';
import createFlash from '~/flash';
import createRouter from '~/design_management_new/router';
import * as utils from '~/design_management_new/utils/design_management_utils';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management_new/constants';
jest.mock('~/flash.js');
const mockPageEl = {
classList: {
remove: jest.fn(),
},
};
jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageEl);
const localVue = createLocalVue();
const router = createRouter();
localVue.use(VueRouter);
const mockDesigns = [
{
id: 'design-1',
image: 'design-1-image',
filename: 'design-1-name',
event: 'NONE',
notesCount: 0,
},
{
id: 'design-2',
image: 'design-2-image',
filename: 'design-2-name',
event: 'NONE',
notesCount: 1,
},
{
id: 'design-3',
image: 'design-3-image',
filename: 'design-3-name',
event: 'NONE',
notesCount: 0,
},
];
const mockVersion = {
node: {
id: 'gid://gitlab/DesignManagement::Version/1',
},
};
describe('Design management index page', () => {
let mutate;
let wrapper;
const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
const findSelectAllButton = () => wrapper.find('.js-select-all');
const findToolbar = () => wrapper.find('.qa-selector-toolbar');
const findDeleteButton = () => wrapper.find(DeleteButton);
const findDropzone = () => wrapper.findAll(DesignDropzone).at(0);
const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1);
function createComponent({
loading = false,
designs = [],
allVersions = [],
createDesign = true,
stubs = {},
mockMutate = jest.fn().mockResolvedValue(),
} = {}) {
mutate = mockMutate;
const $apollo = {
queries: {
designs: {
loading,
},
permissions: {
loading,
},
},
mutate,
};
wrapper = shallowMount(Index, {
mocks: { $apollo },
localVue,
router,
stubs: { DesignDestroyer, ApolloMutation, ...stubs },
attachToDocument: true,
});
wrapper.setData({
designs,
allVersions,
issueIid: '1',
permissions: {
createDesign,
},
});
}
afterEach(() => {
wrapper.destroy();
});
describe('designs', () => {
it('renders loading icon', () => {
createComponent({ loading: true });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('renders error', () => {
createComponent();
wrapper.setData({ error: true });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('renders a toolbar with buttons when there are designs', () => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
return wrapper.vm.$nextTick().then(() => {
expect(findToolbar().exists()).toBe(true);
});
});
it('renders designs list and header with upload button', () => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('does not render toolbar when there is no permission', () => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
});
describe('when has no designs', () => {
beforeEach(() => {
createComponent();
});
it('renders empty text', () =>
wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
}));
it('does not render a toolbar with buttons', () =>
wrapper.vm.$nextTick().then(() => {
expect(findToolbar().exists()).toBe(false);
}));
});
describe('uploading designs', () => {
it('calls mutation on upload', () => {
createComponent({ stubs: { GlEmptyState } });
const mutationVariables = {
update: expect.anything(),
context: {
hasUpload: true,
},
mutation: uploadDesignQuery,
variables: {
files: [{ name: 'test' }],
projectPath: '',
iid: '1',
},
optimisticResponse: {
__typename: 'Mutation',
designManagementUpload: {
__typename: 'DesignManagementUploadPayload',
designs: [
{
__typename: 'Design',
id: expect.anything(),
image: '',
imageV432x230: '',
filename: 'test',
fullPath: '',
event: 'NONE',
notesCount: 0,
diffRefs: {
__typename: 'DiffRefs',
baseSha: '',
startSha: '',
headSha: '',
},
discussions: {
__typename: 'DesignDiscussion',
nodes: [],
},
versions: {
__typename: 'DesignVersionConnection',
edges: {
__typename: 'DesignVersionEdge',
node: {
__typename: 'DesignVersion',
id: expect.anything(),
sha: expect.anything(),
},
},
},
},
],
skippedDesigns: [],
errors: [],
},
},
};
return wrapper.vm.$nextTick().then(() => {
findDropzone().vm.$emit('change', [{ name: 'test' }]);
expect(mutate).toHaveBeenCalledWith(mutationVariables);
expect(wrapper.vm.filesToBeSaved).toEqual([{ name: 'test' }]);
expect(wrapper.vm.isSaving).toBeTruthy();
});
});
it('sets isSaving', () => {
createComponent();
const uploadDesign = wrapper.vm.onUploadDesign([
{
name: 'test',
},
]);
expect(wrapper.vm.isSaving).toBe(true);
return uploadDesign.then(() => {
expect(wrapper.vm.isSaving).toBe(false);
});
});
it('updates state appropriately after upload complete', () => {
createComponent({ stubs: { GlEmptyState } });
wrapper.setData({ filesToBeSaved: [{ name: 'test' }] });
wrapper.vm.onUploadDesignDone();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.filesToBeSaved).toEqual([]);
expect(wrapper.vm.isSaving).toBeFalsy();
expect(wrapper.vm.isLatestVersion).toBe(true);
});
});
it('updates state appropriately after upload error', () => {
createComponent({ stubs: { GlEmptyState } });
wrapper.setData({ filesToBeSaved: [{ name: 'test' }] });
wrapper.vm.onUploadDesignError();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.filesToBeSaved).toEqual([]);
expect(wrapper.vm.isSaving).toBeFalsy();
expect(createFlash).toHaveBeenCalled();
createFlash.mockReset();
});
});
it('does not call mutation if createDesign is false', () => {
createComponent({ createDesign: false });
wrapper.vm.onUploadDesign([]);
expect(mutate).not.toHaveBeenCalled();
});
describe('upload count limit', () => {
const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
afterEach(() => {
createFlash.mockReset();
});
it('does not warn when the max files are uploaded', () => {
createComponent();
wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT).fill(mockDesigns[0]));
expect(createFlash).not.toHaveBeenCalled();
});
it('warns when too many files are uploaded', () => {
createComponent();
wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT + 1).fill(mockDesigns[0]));
expect(createFlash).toHaveBeenCalled();
});
});
it('flashes warning if designs are skipped', () => {
createComponent({
mockMutate: () =>
Promise.resolve({
data: { designManagementUpload: { skippedDesigns: [{ filename: 'test.jpg' }] } },
}),
});
const uploadDesign = wrapper.vm.onUploadDesign([
{
name: 'test',
},
]);
return uploadDesign.then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Upload skipped. test.jpg did not change.',
'warning',
);
});
});
describe('dragging onto an existing design', () => {
beforeEach(() => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
});
it('calls onUploadDesign with valid upload', () => {
wrapper.setMethods({
onUploadDesign: jest.fn(),
});
const mockUploadPayload = [
{
name: mockDesigns[0].filename,
},
];
const designDropzone = findFirstDropzoneWithDesign();
designDropzone.vm.$emit('change', mockUploadPayload);
expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1);
expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith(mockUploadPayload);
});
it.each`
description | eventPayload | message
${'> 1 file'} | ${[{ name: 'test' }, { name: 'test-2' }]} | ${EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE}
${'different filename'} | ${[{ name: 'wrong-name' }]} | ${EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE}
`('calls createFlash when upload has $description', ({ eventPayload, message }) => {
const designDropzone = findFirstDropzoneWithDesign();
designDropzone.vm.$emit('change', eventPayload);
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(message);
});
});
});
describe('on latest version when has designs', () => {
beforeEach(() => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
});
it('renders design checkboxes', () => {
expect(findDesignCheckboxes()).toHaveLength(mockDesigns.length);
});
it('renders toolbar buttons', () => {
expect(findToolbar().exists()).toBe(true);
expect(findToolbar().classes()).toContain('d-flex');
expect(findToolbar().classes()).not.toContain('d-none');
});
it('adds two designs to selected designs when their checkboxes are checked', () => {
findDesignCheckboxes()
.at(0)
.trigger('click');
return wrapper.vm
.$nextTick()
.then(() => {
findDesignCheckboxes()
.at(1)
.trigger('click');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findDeleteButton().exists()).toBe(true);
expect(findSelectAllButton().text()).toBe('Deselect all');
findDeleteButton().vm.$emit('deleteSelectedDesigns');
const [{ variables }] = mutate.mock.calls[0];
expect(variables.filenames).toStrictEqual([
mockDesigns[0].filename,
mockDesigns[1].filename,
]);
});
});
it('adds all designs to selected designs when Select All button is clicked', () => {
findSelectAllButton().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteButton().props().hasSelectedDesigns).toBe(true);
expect(findSelectAllButton().text()).toBe('Deselect all');
expect(wrapper.vm.selectedDesigns).toEqual(mockDesigns.map(design => design.filename));
});
});
it('removes all designs from selected designs when at least one design was selected', () => {
findDesignCheckboxes()
.at(0)
.trigger('click');
return wrapper.vm
.$nextTick()
.then(() => {
findSelectAllButton().vm.$emit('click');
})
.then(() => {
expect(findDeleteButton().props().hasSelectedDesigns).toBe(false);
expect(findSelectAllButton().text()).toBe('Select all');
expect(wrapper.vm.selectedDesigns).toEqual([]);
});
});
});
it('on latest version when has no designs does not render toolbar buttons', () => {
createComponent({ designs: [], allVersions: [mockVersion] });
expect(findToolbar().exists()).toBe(false);
});
describe('on non-latest version', () => {
beforeEach(() => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
router.replace({
name: DESIGNS_ROUTE_NAME,
query: {
version: '2',
},
});
});
it('does not render design checkboxes', () => {
expect(findDesignCheckboxes()).toHaveLength(0);
});
it('does not render Delete selected button', () => {
expect(findDeleteButton().exists()).toBe(false);
});
it('does not render Select All button', () => {
expect(findSelectAllButton().exists()).toBe(false);
});
});
describe('pasting a design', () => {
let event;
beforeEach(() => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
wrapper.setMethods({
onUploadDesign: jest.fn(),
});
event = new Event('paste');
router.replace({
name: DESIGNS_ROUTE_NAME,
query: {
version: '2',
},
});
});
it('calls onUploadDesign with valid paste', () => {
event.clipboardData = {
files: [{ name: 'image.png', type: 'image/png' }],
getData: () => 'test.png',
};
document.dispatchEvent(event);
expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1);
expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([
new File([{ name: 'image.png' }], 'test.png'),
]);
});
it('renames a design if it has an image.png filename', () => {
event.clipboardData = {
files: [{ name: 'image.png', type: 'image/png' }],
getData: () => 'image.png',
};
document.dispatchEvent(event);
expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1);
expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([
new File([{ name: 'image.png' }], `design_${Date.now()}.png`),
]);
});
it('does not call onUploadDesign with invalid paste', () => {
event.clipboardData = {
items: [{ type: 'text/plain' }, { type: 'text' }],
files: [],
};
document.dispatchEvent(event);
expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled();
});
});
describe('when navigating', () => {
it('ensures fullscreen layout is not applied', () => {
createComponent(true);
wrapper.vm.$router.push('/designs');
expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(1);
expect(mockPageEl.classList.remove).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
});
});
});
import { mount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueRouter from 'vue-router';
import App from '~/design_management_new/components/app.vue';
import Designs from '~/design_management_new/pages/index.vue';
import DesignDetail from '~/design_management_new/pages/design/index.vue';
import createRouter from '~/design_management_new/router';
import {
ROOT_ROUTE_NAME,
DESIGNS_ROUTE_NAME,
DESIGN_ROUTE_NAME,
} from '~/design_management_new/router/constants';
import '~/commons/bootstrap';
function factory(routeArg) {
const localVue = createLocalVue();
localVue.use(VueRouter);
window.gon = { sprite_icons: '' };
const router = createRouter('/');
if (routeArg !== undefined) {
router.push(routeArg);
}
return mount(App, {
localVue,
router,
mocks: {
$apollo: {
queries: {
designs: { loading: true },
design: { loading: true },
permissions: { loading: true },
},
mutate: jest.fn(),
},
},
});
}
jest.mock('mousetrap', () => ({
bind: jest.fn(),
unbind: jest.fn(),
}));
describe('Design management router', () => {
afterEach(() => {
window.location.hash = '';
});
describe.each([['/'], [{ name: ROOT_ROUTE_NAME }]])('root route', routeArg => {
it('pushes home component', () => {
const wrapper = factory(routeArg);
expect(wrapper.find(Designs).exists()).toBe(true);
});
});
describe.each([['/designs'], [{ name: DESIGNS_ROUTE_NAME }]])('designs route', routeArg => {
it('pushes designs root component', () => {
const wrapper = factory(routeArg);
expect(wrapper.find(Designs).exists()).toBe(true);
});
});
describe.each([['/designs/1'], [{ name: DESIGN_ROUTE_NAME, params: { id: '1' } }]])(
'designs detail route',
routeArg => {
it('pushes designs detail component', () => {
const wrapper = factory(routeArg);
return nextTick().then(() => {
const detail = wrapper.find(DesignDetail);
expect(detail.exists()).toBe(true);
expect(detail.props('id')).toEqual('1');
});
});
},
);
});
import { InMemoryCache } from 'apollo-cache-inmemory';
import {
updateStoreAfterDesignsDelete,
updateStoreAfterAddDiscussionComment,
updateStoreAfterAddImageDiffNote,
updateStoreAfterUploadDesign,
updateStoreAfterUpdateImageDiffNote,
} from '~/design_management_new/utils/cache_update';
import {
designDeletionError,
ADD_DISCUSSION_COMMENT_ERROR,
ADD_IMAGE_DIFF_NOTE_ERROR,
UPDATE_IMAGE_DIFF_NOTE_ERROR,
} from '~/design_management_new/utils/error_messages';
import design from '../mock_data/design';
import createFlash from '~/flash';
jest.mock('~/flash.js');
describe('Design Management cache update', () => {
const mockErrors = ['code red!'];
let mockStore;
beforeEach(() => {
mockStore = new InMemoryCache();
});
describe('error handling', () => {
it.each`
fnName | subject | errorMessage | extraArgs
${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]}
${'updateStoreAfterAddDiscussionComment'} | ${updateStoreAfterAddDiscussionComment} | ${ADD_DISCUSSION_COMMENT_ERROR} | ${[]}
${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]}
${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]}
${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterUpdateImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]}
`('$fnName handles errors in response', ({ subject, extraArgs, errorMessage }) => {
expect(createFlash).not.toHaveBeenCalled();
expect(() => subject(mockStore, { errors: mockErrors }, {}, ...extraArgs)).toThrow();
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(errorMessage);
});
});
});
import {
extractCurrentDiscussion,
extractDiscussions,
findVersionId,
designUploadOptimisticResponse,
updateImageDiffNoteOptimisticResponse,
isValidDesignFile,
extractDesign,
} from '~/design_management_new/utils/design_management_utils';
import mockResponseNoDesigns from '../mock_data/no_designs';
import mockResponseWithDesigns from '../mock_data/designs';
import mockDesign from '../mock_data/design';
jest.mock('lodash/uniqueId', () => () => 1);
describe('extractCurrentDiscussion', () => {
let discussions;
beforeEach(() => {
discussions = {
nodes: [
{ id: 101, payload: 'w' },
{ id: 102, payload: 'x' },
{ id: 103, payload: 'y' },
{ id: 104, payload: 'z' },
],
};
});
it('finds the relevant discussion if it exists', () => {
const id = 103;
expect(extractCurrentDiscussion(discussions, id)).toEqual({ id, payload: 'y' });
});
it('returns null if the relevant discussion does not exist', () => {
expect(extractCurrentDiscussion(discussions, 0)).not.toBeDefined();
});
});
describe('extractDiscussions', () => {
let discussions;
beforeEach(() => {
discussions = {
nodes: [
{ id: 1, notes: { nodes: ['a'] } },
{ id: 2, notes: { nodes: ['b'] } },
{ id: 3, notes: { nodes: ['c'] } },
{ id: 4, notes: { nodes: ['d'] } },
],
};
});
it('discards the edges.node artifacts of GraphQL', () => {
expect(extractDiscussions(discussions)).toEqual([
{ id: 1, notes: ['a'], index: 1 },
{ id: 2, notes: ['b'], index: 2 },
{ id: 3, notes: ['c'], index: 3 },
{ id: 4, notes: ['d'], index: 4 },
]);
});
});
describe('version parser', () => {
it('correctly extracts version ID from a valid version string', () => {
const testVersionId = '123';
const testVersionString = `gid://gitlab/DesignManagement::Version/${testVersionId}`;
expect(findVersionId(testVersionString)).toEqual(testVersionId);
});
it('fails to extract version ID from an invalid version string', () => {
const testInvalidVersionString = `gid://gitlab/DesignManagement::Version`;
expect(findVersionId(testInvalidVersionString)).toBeUndefined();
});
});
describe('optimistic responses', () => {
it('correctly generated for designManagementUpload', () => {
const expectedResponse = {
__typename: 'Mutation',
designManagementUpload: {
__typename: 'DesignManagementUploadPayload',
designs: [
{
__typename: 'Design',
id: -1,
image: '',
imageV432x230: '',
filename: 'test',
fullPath: '',
notesCount: 0,
event: 'NONE',
diffRefs: { __typename: 'DiffRefs', baseSha: '', startSha: '', headSha: '' },
discussions: { __typename: 'DesignDiscussion', nodes: [] },
versions: {
__typename: 'DesignVersionConnection',
edges: {
__typename: 'DesignVersionEdge',
node: { __typename: 'DesignVersion', id: -1, sha: -1 },
},
},
},
],
errors: [],
skippedDesigns: [],
},
};
expect(designUploadOptimisticResponse([{ name: 'test' }])).toEqual(expectedResponse);
});
it('correctly generated for updateImageDiffNoteOptimisticResponse', () => {
const mockNote = {
id: 'test-note-id',
};
const mockPosition = {
x: 10,
y: 10,
width: 10,
height: 10,
};
const expectedResponse = {
__typename: 'Mutation',
updateImageDiffNote: {
__typename: 'UpdateImageDiffNotePayload',
note: {
...mockNote,
position: mockPosition,
},
errors: [],
},
};
expect(updateImageDiffNoteOptimisticResponse(mockNote, { position: mockPosition })).toEqual(
expectedResponse,
);
});
});
describe('isValidDesignFile', () => {
// test every filetype that Design Management supports
// https://docs.gitlab.com/ee/user/project/issues/design_management.html#limitations
it.each`
mimetype | isValid
${'image/svg'} | ${true}
${'image/png'} | ${true}
${'image/jpg'} | ${true}
${'image/jpeg'} | ${true}
${'image/gif'} | ${true}
${'image/bmp'} | ${true}
${'image/tiff'} | ${true}
${'image/ico'} | ${true}
${'image/svg'} | ${true}
${'video/mpeg'} | ${false}
${'audio/midi'} | ${false}
${'application/octet-stream'} | ${false}
`('returns $isValid for file type $mimetype', ({ mimetype, isValid }) => {
expect(isValidDesignFile({ type: mimetype })).toBe(isValid);
});
});
describe('extractDesign', () => {
describe('with no designs', () => {
it('returns undefined', () => {
expect(extractDesign(mockResponseNoDesigns)).toBeUndefined();
});
});
describe('with designs', () => {
it('returns the first design available', () => {
expect(extractDesign(mockResponseWithDesigns)).toEqual(mockDesign);
});
});
});
import {
designDeletionError,
designUploadSkippedWarning,
} from '~/design_management_new/utils/error_messages';
const mockFilenames = n =>
Array(n)
.fill(0)
.map((_, i) => ({ filename: `${i + 1}.jpg` }));
describe('Error message', () => {
describe('designDeletionError', () => {
const singularMsg = 'Could not delete a design. Please try again.';
const pluralMsg = 'Could not delete designs. Please try again.';
describe('when [singular=true]', () => {
it.each([[undefined], [true]])('uses singular grammar', singularOption => {
expect(designDeletionError({ singular: singularOption })).toEqual(singularMsg);
});
});
describe('when [singular=false]', () => {
it('uses plural grammar', () => {
expect(designDeletionError({ singular: false })).toEqual(pluralMsg);
});
});
});
describe.each([
[[], [], null],
[mockFilenames(1), mockFilenames(1), 'Upload skipped. 1.jpg did not change.'],
[
mockFilenames(2),
mockFilenames(2),
'Upload skipped. The designs you tried uploading did not change.',
],
[
mockFilenames(2),
mockFilenames(1),
'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg.',
],
[
mockFilenames(6),
mockFilenames(5),
'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg.',
],
[
mockFilenames(7),
mockFilenames(6),
'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 1 more.',
],
[
mockFilenames(8),
mockFilenames(7),
'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 2 more.',
],
])('designUploadSkippedWarning', (uploadedFiles, skippedFiles, expected) => {
test('returns expected warning message', () => {
expect(designUploadSkippedWarning(uploadedFiles, skippedFiles)).toBe(expected);
});
});
});
import { mockTracking } from 'helpers/tracking_helper';
import { trackDesignDetailView } from '~/design_management_new/utils/tracking';
function getTrackingSpy(key) {
return mockTracking(key, undefined, jest.spyOn);
}
describe('Tracking Events', () => {
describe('trackDesignDetailView', () => {
const eventKey = 'projects:issues:design';
const eventName = 'design_viewed';
it('trackDesignDetailView fires a tracking event when called', () => {
const trackingSpy = getTrackingSpy(eventKey);
trackDesignDetailView();
expect(trackingSpy).toHaveBeenCalledWith(
eventKey,
eventName,
expect.objectContaining({
label: eventName,
value: {
'internal-object-refrerer': '',
'design-collection-owner': '',
'design-version-number': 1,
'design-is-current-version': false,
},
}),
);
});
it('trackDesignDetailView allows to customize the value payload', () => {
const trackingSpy = getTrackingSpy(eventKey);
trackDesignDetailView('from-a-test', 'test', 100, true);
expect(trackingSpy).toHaveBeenCalledWith(
eventKey,
eventName,
expect.objectContaining({
label: eventName,
value: {
'internal-object-refrerer': 'from-a-test',
'design-collection-owner': 'test',
'design-version-number': 100,
'design-is-current-version': true,
},
}),
);
});
});
});
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