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

Make a cleanup after Design Management feature flag is removed

parent 9c62516d
......@@ -4,7 +4,7 @@ import App from './components/app.vue';
import apolloProvider from './graphql';
export default () => {
const el = document.querySelector('.js-design-management-new');
const el = document.querySelector('.js-design-management');
const { issueIid, projectPath, issuePath } = el.dataset;
const router = createRouter(issuePath);
......
<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 { GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
export default {
name: 'DesignNotePin',
components: {
GlIcon,
},
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 gl-p-0"
type="button"
@mousedown="$emit('mousedown', $event)"
@mouseup="$emit('mouseup', $event)"
@click="$emit('click', $event)"
>
<gl-icon v-if="isNewNote" name="image-comment-dark" :size="24" />
<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-500 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message">
{{ __('Resolved by') }}
<gl-link
class="gl-text-gray-500 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>
/* eslint-disable vue/no-v-html */
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-500">{{ __('Last reply by') }}</span>
<gl-link
:href="lastReply.author.webUrl"
target="_blank"
class="link-inherit-color gl-text-body 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-500"
/>
</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 Cookies from 'js-cookie';
import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui';
import { s__ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
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-400 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-body 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 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,
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" data-testid="designEvent" class="design-event position-absolute">
<span :title="icon.tooltip" :aria-label="icon.tooltip">
<gl-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">
<gl-icon name="comments" class="ml-1" />
<span :aria-label="notesLabel" class="ml-1">
{{ notesCount }}
</span>
</div>
</div>
</router-link>
</template>
<script>
import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
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: {
GlIcon,
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"
>
<gl-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">
<gl-icon :size="18" name="download" />
</gl-deprecated-button>
<delete-button
v-if="isLatestVersion && canDeleteDesign"
:is-deleting="isDeleting"
button-variant="danger"
@deleteSelectedDesigns="$emit('delete')"
>
<gl-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 { GlIcon } from '@gitlab/ui';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
components: {
GlIcon,
},
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"
>
<gl-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 { deprecatedCreateFlash as 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 { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import allVersionsMixin from '../../mixins/all_versions';
import { findVersionId } from '../../utils/design_management_utils';
export default {
components: {
GlDeprecatedDropdown,
GlDeprecatedDropdownItem,
},
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-deprecated-dropdown :text="dropdownText" variant="link" class="design-version-dropdown">
<gl-deprecated-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 float-right gl-mr-2"
></i>
</router-link>
</gl-deprecated-dropdown-item>
</gl-deprecated-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 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 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
}
// 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';
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');
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 { deprecatedCreateFlash as 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 { deprecatedCreateFlash as 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 { deprecatedCreateFlash as 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_legacy/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 { deprecatedCreateFlash as 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';
// Tracking Constants
const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0';
const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
const DESIGN_TRACKING_EVENT_NAME = 'view_design';
export function trackDesignDetailView(
referer = '',
owner = '',
designVersion = 1,
latestVersion = false,
) {
Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, {
label: DESIGN_TRACKING_EVENT_NAME,
context: {
schema: DESIGN_TRACKING_CONTEXT_SCHEMA,
data: {
'design-version-number': designVersion,
'design-is-current-version': latestVersion,
'internal-object-referrer': referer,
'design-collection-owner': owner,
},
},
});
}
......@@ -25,12 +25,6 @@ export default function() {
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
// 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_legacy')
.then(module => module.default())
.catch(() => {});
import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then(module => module.default())
.catch(() => {});
......
......@@ -4,20 +4,7 @@
- enable_lfs_message = s_("DesignManagement|To upload designs, you'll need to enable LFS. %{requirements_link_start}More information%{requirements_link_end}").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end }
- if @project.design_management_enabled?
- if Feature.enabled?(:design_management_moved, @project, default_enabled: true)
.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
- if Feature.enabled?(:design_management_moved, @project, default_enabled: true)
.gl-border-solid.gl-border-1.gl-border-gray-100.gl-rounded-base.gl-mt-5.gl-p-3.gl-text-center
= enable_lfs_message
- else
.mt-4
.row.empty-state
.col-12
.text-content
%h4.center
= _('The one place for your designs')
%p.center
= enable_lfs_message
%ul.nav-tabs.nav.nav-links{ role: 'tablist' }
%li
= link_to '#discussion-tab', class: 'active js-issue-tabs', id: 'discussion', role: 'tab', 'aria-controls': 'js-discussion', 'aria-selected': 'true', data: { toggle: 'tab', target: '#discussion-tab', qa_selector: 'discussion_tab_link' } do
= _('Discussion')
%span.badge.badge-pill.js-discussions-count
%li
= link_to '#designs-tab', class: 'js-issue-tabs', id: 'designs', role: 'tab', 'aria-controls': 'js-designs', 'aria-selected': 'false', data: { toggle: 'tab', target: '#designs-tab', qa_selector: 'designs_tab_link' } do
= _('Designs')
%span.badge.badge-pill.js-designs-count
.tab-content
#discussion-tab.tab-pane.show.active{ role: 'tabpanel', 'aria-labelledby': 'discussion', data: { qa_selector: 'discussion_tab_content' } }
= render 'projects/issues/discussion'
#designs-tab.tab-pane{ role: 'tabpanel', 'aria-labelledby': 'designs', data: { qa_selector: 'designs_tab_content' } }
= render 'projects/issues/design_management'
......@@ -76,7 +76,6 @@
- if @issue.sentry_issue.present?
#js-sentry-error-stack-trace{ data: error_details_data(@project, @issue.sentry_issue.sentry_issue_identifier) }
- if Feature.enabled?(:design_management_moved, @project, default_enabled: true)
= render 'projects/issues/design_management'
= render_if_exists 'projects/issues/related_issues'
......@@ -97,9 +96,6 @@
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@issue), notes_filters: UserPreference.notes_filters.to_json } }
= render 'new_branch' if show_new_branch_button?
- if Feature.enabled?(:design_management_moved, @project, default_enabled: true)
= render 'projects/issues/discussion'
- else
= render 'projects/issues/tabs'
= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees
......@@ -72,39 +72,12 @@ and connect to GitLab through a personal access token. The details are explained
## The Design Management section
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/223193) in GitLab 13.2, Designs are displayed directly on the issue description rather than on a separate tab.
> - The new display is deployed behind a feature flag, enabled by default.
> - It's enabled on GitLab.com.
> - It cannot be enabled or disabled per-project.
> - It's recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#enable-or-disable-displaying-designs-on-the-issue-description-core-only). If disabled, it will move Designs back to the **Designs** tab.
> - New display's feature flag [removed](https://gitlab.com/gitlab-org/gitlab/-/issues/223197) in GitLab 13.4.
You can find to the **Design Management** section in the issue description:
![Designs section](img/design_management_v13_2.png)
### Enable or disable displaying Designs on the issue description **(CORE ONLY)**
Displaying Designs on the issue description is under development but ready for
production use. It is deployed behind a feature flag that is **enabled by
default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
can opt to disable it for your instance.
To disable it:
```ruby
Feature.disable(:design_management_moved)
```
To enable it:
```ruby
Feature.enable(:design_management_moved)
```
By disabling this feature, designs will be displayed on the **Designs** tab
instead of directly on the issue description.
## Adding designs
To upload Design images, drag files from your computer and drop them in the Design Management section,
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Issue page tabs', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:issue) { create(:issue, author: user, assignees: [user], project: project) }
describe 'discussions tab counter' do
before do
allow(Ability).to receive(:allowed?) { true }
stub_feature_flags(design_management_moved: false)
end
subject do
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests
find('#discussion')
end
context 'new issue' do
it 'displays count of 0' do
is_expected.to have_content('Discussion 0')
end
end
context 'issue with 2 system notes and 1 discussion' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: project, note: "This is good") }
before do
create(:system_note, noteable: issue, project: project, author: user, note: 'description updated')
create(:system_note, noteable: issue, project: project, author: user, note: 'description updated')
end
it 'displays count of 1' do
is_expected.to have_content('Discussion 1')
end
context 'with 1 reply' do
before do
create(:note, noteable: issue, in_reply_to: discussion, discussion_id: discussion.discussion_id, note: 'I also think this is good')
end
it 'displays count of 2' do
is_expected.to have_content('Discussion 2')
end
end
end
end
end
......@@ -515,9 +515,6 @@ msgstr ""
msgid "%{level_name} is not allowed since the fork source project has lower visibility."
msgstr ""
msgid "%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
msgstr ""
msgid "%{link_start}Learn more%{link_end} about what information is shared with GitLab Inc."
msgstr ""
......@@ -7039,9 +7036,6 @@ msgstr ""
msgid "Could not create wiki page"
msgstr ""
msgid "Could not delete %{design}. Please try again."
msgstr ""
msgid "Could not delete chat nickname %{chat_name}."
msgstr ""
......@@ -8043,9 +8037,6 @@ msgstr ""
msgid "Deleted chat nickname: %{chat_name}!"
msgstr ""
msgid "Deleted in this version"
msgstr ""
msgid "Deleted projects"
msgstr ""
......@@ -8484,9 +8475,6 @@ msgstr ""
msgid "DesignManagement|Are you sure you want to cancel creating this comment?"
msgstr ""
msgid "DesignManagement|Are you sure you want to delete the selected designs?"
msgstr ""
msgid "DesignManagement|Cancel changes"
msgstr ""
......@@ -8517,15 +8505,6 @@ msgstr ""
msgid "DesignManagement|Could not update note. Please try again."
msgstr ""
msgid "DesignManagement|Delete"
msgstr ""
msgid "DesignManagement|Delete designs confirmation"
msgstr ""
msgid "DesignManagement|Delete selected"
msgstr ""
msgid "DesignManagement|Deselect all"
msgstr ""
......@@ -8742,9 +8721,6 @@ msgstr ""
msgid "Discuss a specific suggestion or question."
msgstr ""
msgid "Discussion"
msgstr ""
msgid "Discussion to reply to cannot be found"
msgstr ""
......@@ -22747,12 +22723,6 @@ msgstr ""
msgid "Showing %{pageSize} of %{total} issues"
msgstr ""
msgid "Showing Latest Version"
msgstr ""
msgid "Showing Version #%{versionNumber}"
msgstr ""
msgid "Showing all issues"
msgstr ""
......@@ -24656,9 +24626,6 @@ msgstr ""
msgid "The number of times an upload record could not find its file"
msgstr ""
msgid "The one place for your designs"
msgstr ""
msgid "The parent epic is confidential and can only contain confidential epics and issues"
msgstr ""
......
......@@ -8,36 +8,6 @@ RSpec.describe 'User paginates issue designs', :js do
let(:project) { create(:project_empty_repo, :public) }
let(:issue) { create(:issue, project: project) }
context 'design_management_moved flag disabled' do
before do
stub_feature_flags(design_management_moved: false)
enable_design_management
create_list(:design, 2, :with_file, issue: issue)
visit project_issue_path(project, issue)
click_link 'Designs'
wait_for_requests
find('.js-design-list-item', match: :first).click
end
it 'paginates to next design' do
expect(find('.js-previous-design')[:disabled]).to eq('true')
page.within(find('.js-design-header')) do
expect(page).to have_content('1 of 2')
end
find('.js-next-design').click
expect(find('.js-previous-design')[:disabled]).not_to eq('true')
page.within(find('.js-design-header')) do
expect(page).to have_content('2 of 2')
end
end
end
context 'design_management_moved flag enabled' do
before do
enable_design_management
create_list(:design, 2, :with_file, issue: issue)
......@@ -60,5 +30,4 @@ RSpec.describe 'User paginates issue designs', :js do
expect(page).to have_content('2 of 2')
end
end
end
end
......@@ -8,24 +8,6 @@ RSpec.describe 'User design permissions', :js do
let(:project) { create(:project_empty_repo, :public) }
let(:issue) { create(:issue, project: project) }
context 'design_management_moved flag disabled' do
before do
enable_design_management
stub_feature_flags(design_management_moved: false)
visit project_issue_path(project, issue)
click_link 'Designs'
wait_for_requests
end
it 'user does not have permissions to upload design' do
expect(page).not_to have_field('design_file')
end
end
context 'design_management_moved flag enabled' do
before do
enable_design_management
......@@ -35,5 +17,4 @@ RSpec.describe 'User design permissions', :js do
it 'user does not have permissions to upload design' do
expect(page).not_to have_field('design_file')
end
end
end
......@@ -11,53 +11,7 @@ RSpec.describe 'User uploads new design', :js do
before do
sign_in(user)
end
context 'design_management_moved flag disabled' do
before do
enable_design_management(feature_enabled)
stub_feature_flags(design_management_moved: false)
visit project_issue_path(project, issue)
click_link 'Designs'
wait_for_requests
end
context "when the feature is available" do
let(:feature_enabled) { true }
it 'uploads designs' do
upload_design(logo_fixture, count: 1)
expect(page).to have_selector('.js-design-list-item', count: 1)
within first('#designs-tab .js-design-list-item') do
expect(page).to have_content('dk.png')
end
upload_design(gif_fixture, count: 2)
# Known bug in the legacy implementation: new designs are inserted
# in the beginning on the frontend.
expect(page).to have_selector('.js-design-list-item', count: 2)
expect(page.all('.js-design-list-item').map(&:text)).to eq(['banana_sample.gif', 'dk.png'])
end
end
context 'when the feature is not available' do
let(:feature_enabled) { false }
it 'shows the message about requirements' do
expect(page).to have_content("To upload designs, you'll need to enable LFS.")
end
end
end
context 'design_management_moved flag enabled' do
before do
enable_design_management(feature_enabled)
stub_feature_flags(design_management_moved: true)
visit project_issue_path(project, issue)
end
......@@ -87,7 +41,6 @@ RSpec.describe 'User uploads new design', :js do
expect(page).to have_content("To upload designs, you'll need to enable LFS.")
end
end
end
def logo_fixture
Rails.root.join('spec', 'fixtures', 'dk.png')
......
......@@ -9,14 +9,10 @@ RSpec.describe 'User views issue designs', :js do
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:design) { create(:design, :with_file, issue: issue) }
context 'design_management_moved flag disabled' do
before do
enable_design_management
stub_feature_flags(design_management_moved: false)
visit project_issue_path(project, issue)
click_link 'Designs'
end
it 'opens design detail' do
......@@ -28,23 +24,4 @@ RSpec.describe 'User views issue designs', :js do
expect(page).to have_selector('.js-design-image')
end
end
context 'design_management_moved flag enabled' do
before do
enable_design_management
visit project_issue_path(project, issue)
end
it 'opens design detail' do
click_link design.filename
page.within(find('.js-design-header')) do
expect(page).to have_content(design.filename)
end
expect(page).to have_selector('.js-design-image')
end
end
end
......@@ -9,17 +9,13 @@ RSpec.describe 'User views issue designs', :js do
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:design) { create(:design, :with_file, issue: issue) }
context 'design_management_moved flag disabled' do
before do
enable_design_management
stub_feature_flags(design_management_moved: false)
end
context 'navigates from the issue view' do
before do
visit project_issue_path(project, issue)
click_link 'Designs'
wait_for_requests
end
it 'fetches list of designs' do
......@@ -46,41 +42,4 @@ RSpec.describe 'User views issue designs', :js do
expect(page).to have_selector('.js-design-detail')
end
end
end
context 'design_management_moved flag enabled' do
before do
enable_design_management
end
context 'navigates from the issue view' do
before do
visit project_issue_path(project, issue)
end
it 'fetches list of designs' do
expect(page).to have_selector('.js-design-list-item', count: 1)
end
end
context 'navigates directly to the design collection view' do
before do
visit designs_project_issue_path(project, issue)
end
it 'expands the sidebar' do
expect(page).to have_selector('.layout-page.right-sidebar-expanded')
end
end
context 'navigates directly to the individual design view' do
before do
visit designs_project_issue_path(project, issue, vueroute: design.filename)
end
it 'sees the design' do
expect(page).to have_selector('.js-design-detail')
end
end
end
end
// 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 gl-p-0 btn-transparent comment-indicator"
style="left: 10px; top: 10px; cursor: move;"
type="button"
>
<gl-icon-stub
name="image-comment-dark"
size="24"
/>
</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 gl-p-0 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 gl-p-0 btn-transparent comment-indicator"
style="left: 10px; top: 10px;"
type="button"
>
<gl-icon-stub
name="image-comment-dark"
size="24"
/>
</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_legacy/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_legacy/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_legacy/components/design_notes/design_discussion.vue';
import DesignNote from '~/design_management_legacy/components/design_notes/design_note.vue';
import DesignReplyForm from '~/design_management_legacy/components/design_notes/design_reply_form.vue';
import createNoteMutation from '~/design_management_legacy/graphql/mutations/create_note.mutation.graphql';
import toggleResolveDiscussionMutation from '~/design_management_legacy/graphql/mutations/toggle_resolve_discussion.mutation.graphql';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import ToggleRepliesWidget from '~/design_management_legacy/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('onClick');
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_legacy/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_legacy/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_legacy/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_legacy/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_legacy/components/design_overlay.vue';
import updateActiveDiscussion from '~/design_management_legacy/graphql/mutations/update_active_discussion.mutation.graphql';
import notes from '../mock_data/notes';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '~/design_management_legacy/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_legacy/components/design_presentation.vue';
import DesignOverlay from '~/design_management_legacy/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_legacy/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_legacy/components/design_sidebar.vue';
import Participants from '~/sidebar/components/participants/participants.vue';
import DesignDiscussion from '~/design_management_legacy/components/design_notes/design_discussion.vue';
import design from '../mock_data/design';
import updateActiveDiscussionMutation from '~/design_management_legacy/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_legacy/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 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>
<!---->
<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"
>
<gl-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>
<!---->
<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"
>
<gl-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_legacy/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;
const findDesignEvent = () => wrapper.find('[data-testid="designEvent"]');
const findEventIcon = () => findDesignEvent().find(GlIcon);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
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();
});
});
it('renders loading spinner when isUploading is true', () => {
createComponent({ isUploading: true });
expect(findLoadingIcon().exists()).toBe(true);
});
it('renders item with no status icon for none event', () => {
createComponent();
expect(findDesignEvent().exists()).toBe(false);
});
describe('with associated event', () => {
it.each`
event | icon | className
${DESIGN_VERSION_EVENT.MODIFICATION} | ${'file-modified-solid'} | ${'text-primary-500'}
${DESIGN_VERSION_EVENT.DELETION} | ${'file-deletion-solid'} | ${'text-danger-500'}
${DESIGN_VERSION_EVENT.CREATION} | ${'file-addition-solid'} | ${'text-success-500'}
`('renders item with correct status icon for $event event', ({ event, icon, className }) => {
createComponent({ event });
const eventIcon = findEventIcon();
expect(eventIcon.exists()).toBe(true);
expect(eventIcon.props('name')).toBe(icon);
expect(eventIcon.classes()).toContain(className);
});
});
});
// 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"
>
<gl-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"
>
<gl-icon-stub
name="download"
size="18"
/>
</gl-deprecated-button-stub>
<delete-button-stub
buttonclass=""
buttonvariant="danger"
hasselecteddesigns="true"
>
<gl-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]"
>
<gl-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]"
>
<gl-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 { GlDeprecatedButton } from '@gitlab/ui';
import Toolbar from '~/design_management_legacy/components/toolbar/index.vue';
import DeleteButton from '~/design_management_legacy/components/delete_button.vue';
import { DESIGNS_ROUTE_NAME } from '~/design_management_legacy/router/constants';
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_legacy/components/toolbar/pagination_button.vue';
import { DESIGN_ROUTE_NAME } from '~/design_management_legacy/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_legacy/components/toolbar/pagination.vue';
import { DESIGN_ROUTE_NAME } from '~/design_management_legacy/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-deprecated-dropdown-stub
class="design-version-dropdown"
issueiid=""
projectpath=""
text="Showing Latest Version"
variant="link"
>
<gl-deprecated-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 float-right gl-mr-2"
/>
</router-link-stub>
</gl-deprecated-dropdown-item-stub>
<gl-deprecated-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-deprecated-dropdown-item-stub>
</gl-deprecated-dropdown-stub>
`;
exports[`Design management design version dropdown component renders design version list 1`] = `
<gl-deprecated-dropdown-stub
class="design-version-dropdown"
issueiid=""
projectpath=""
text="Showing Latest Version"
variant="link"
>
<gl-deprecated-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 float-right gl-mr-2"
/>
</router-link-stub>
</gl-deprecated-dropdown-item-stub>
<gl-deprecated-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-deprecated-dropdown-item-stub>
</gl-deprecated-dropdown-stub>
`;
import { shallowMount } from '@vue/test-utils';
import UploadButton from '~/design_management_legacy/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_legacy/components/upload/design_dropzone.vue';
import { deprecatedCreateFlash as 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 { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui';
import DesignVersionDropdown from '~/design_management_legacy/components/upload/design_version_dropdown.vue';
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(GlDeprecatedDropdown).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(GlDeprecatedDropdown).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(GlDeprecatedDropdown).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(GlDeprecatedDropdown).attributes('text')).toBe(
'Showing Latest Version',
);
});
});
it('should have the same length as apollo query', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.findAll(GlDeprecatedDropdownItem)).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-400 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="primary"
class="link-inherit-color gl-text-body 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-400 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 { deprecatedCreateFlash as createFlash } from '~/flash';
import DesignIndex from '~/design_management_legacy/pages/design/index.vue';
import DesignSidebar from '~/design_management_legacy/components/design_sidebar.vue';
import DesignPresentation from '~/design_management_legacy/components/design_presentation.vue';
import createImageDiffNoteMutation from '~/design_management_legacy/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_legacy/utils/error_messages';
import { DESIGNS_ROUTE_NAME } from '~/design_management_legacy/router/constants';
import createRouter from '~/design_management_legacy/router';
import * as utils from '~/design_management_legacy/utils/design_management_utils';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management_legacy/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_legacy/pages/index.vue';
import uploadDesignQuery from '~/design_management_legacy/graphql/mutations/upload_design.mutation.graphql';
import DesignDestroyer from '~/design_management_legacy/components/design_destroyer.vue';
import DesignDropzone from '~/design_management_legacy/components/upload/design_dropzone.vue';
import DeleteButton from '~/design_management_legacy/components/delete_button.vue';
import { DESIGNS_ROUTE_NAME } from '~/design_management_legacy/router/constants';
import {
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
} from '~/design_management_legacy/utils/error_messages';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import createRouter from '~/design_management_legacy/router';
import * as utils from '~/design_management_legacy/utils/design_management_utils';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management_legacy/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] });
});
it('does not render design checkboxes', async () => {
await router.replace({
name: DESIGNS_ROUTE_NAME,
query: {
version: '2',
},
});
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');
});
it('calls onUploadDesign with valid paste', async () => {
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', async () => {
createComponent(true);
await wrapper.vm.$router.replace('/');
await wrapper.vm.$router.replace('/designs');
expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(2);
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_legacy/components/app.vue';
import Designs from '~/design_management_legacy/pages/index.vue';
import DesignDetail from '~/design_management_legacy/pages/design/index.vue';
import createRouter from '~/design_management_legacy/router';
import {
ROOT_ROUTE_NAME,
DESIGNS_ROUTE_NAME,
DESIGN_ROUTE_NAME,
} from '~/design_management_legacy/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_legacy/utils/cache_update';
import {
designDeletionError,
ADD_DISCUSSION_COMMENT_ERROR,
ADD_IMAGE_DIFF_NOTE_ERROR,
UPDATE_IMAGE_DIFF_NOTE_ERROR,
} from '~/design_management_legacy/utils/error_messages';
import design from '../mock_data/design';
import { deprecatedCreateFlash as 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_legacy/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_legacy/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) => {
it('returns expected warning message', () => {
expect(designUploadSkippedWarning(uploadedFiles, skippedFiles)).toBe(expected);
});
});
});
import { mockTracking } from 'helpers/tracking_helper';
import { trackDesignDetailView } from '~/design_management_legacy/utils/tracking';
function getTrackingSpy(key) {
return mockTracking(key, undefined, jest.spyOn);
}
describe('Tracking Events', () => {
describe('trackDesignDetailView', () => {
const eventKey = 'projects:issues:design';
const eventName = 'view_design';
it('trackDesignDetailView fires a tracking event when called', () => {
const trackingSpy = getTrackingSpy(eventKey);
trackDesignDetailView();
expect(trackingSpy).toHaveBeenCalledWith(
eventKey,
eventName,
expect.objectContaining({
label: eventName,
context: {
schema: expect.any(String),
data: {
'design-version-number': 1,
'design-is-current-version': false,
'internal-object-referrer': '',
'design-collection-owner': '',
},
},
}),
);
});
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,
context: {
schema: expect.any(String),
data: {
'design-version-number': 100,
'design-is-current-version': true,
'internal-object-referrer': 'from-a-test',
'design-collection-owner': 'test',
},
},
}),
);
});
});
});
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