Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
fa7ac2663b
commit
d6348d22dd
|
|
@ -1,3 +1,6 @@
|
|||
// 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';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<script>
|
||||
import { __, sprintf } from '~/locale';
|
||||
import Icon from '~/vue_shared/components/icon.vue';
|
||||
|
||||
export default {
|
||||
name: 'DesignNotePin',
|
||||
components: {
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
position: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
repositioning: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isNewNote() {
|
||||
return this.label === null;
|
||||
},
|
||||
pinStyle() {
|
||||
return this.repositioning ? { ...this.position, cursor: 'move' } : this.position;
|
||||
},
|
||||
pinLabel() {
|
||||
return this.isNewNote
|
||||
? __('Comment form position')
|
||||
: sprintf(__("Comment '%{label}' position"), { label: this.label });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:style="pinStyle"
|
||||
:aria-label="pinLabel"
|
||||
:class="{
|
||||
'btn-transparent comment-indicator': isNewNote,
|
||||
'js-image-badge badge badge-pill': !isNewNote,
|
||||
}"
|
||||
class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center"
|
||||
type="button"
|
||||
@mousedown="$emit('mousedown', $event)"
|
||||
@mouseup="$emit('mouseup', $event)"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<icon v-if="isNewNote" name="image-comment-dark" />
|
||||
<template v-else>
|
||||
{{ label }}
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
<script>
|
||||
import { ApolloMutation } from 'vue-apollo';
|
||||
import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui';
|
||||
import { s__ } from '~/locale';
|
||||
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import allVersionsMixin from '../../mixins/all_versions';
|
||||
import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql';
|
||||
import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql';
|
||||
import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
|
||||
import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql';
|
||||
import DesignNote from './design_note.vue';
|
||||
import DesignReplyForm from './design_reply_form.vue';
|
||||
import { updateStoreAfterAddDiscussionComment } from '../../utils/cache_update';
|
||||
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
|
||||
import ToggleRepliesWidget from './toggle_replies_widget.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ApolloMutation,
|
||||
DesignNote,
|
||||
ReplyPlaceholder,
|
||||
DesignReplyForm,
|
||||
GlIcon,
|
||||
GlLoadingIcon,
|
||||
GlLink,
|
||||
ToggleRepliesWidget,
|
||||
TimeAgoTooltip,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
mixins: [allVersionsMixin],
|
||||
props: {
|
||||
discussion: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
noteableId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
designId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
markdownPreviewPath: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
resolvedDiscussionsExpanded: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
discussionWithOpenForm: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
activeDiscussion: {
|
||||
query: activeDiscussionQuery,
|
||||
result({ data }) {
|
||||
const discussionId = data.activeDiscussion.id;
|
||||
if (this.discussion.resolved && !this.resolvedDiscussionsExpanded) {
|
||||
return;
|
||||
}
|
||||
// We watch any changes to the active discussion from the design pins and scroll to this discussion if it exists
|
||||
// We don't want scrollIntoView to be triggered from the discussion click itself
|
||||
if (
|
||||
discussionId &&
|
||||
data.activeDiscussion.source === ACTIVE_DISCUSSION_SOURCE_TYPES.pin &&
|
||||
discussionId === this.discussion.notes[0].id
|
||||
) {
|
||||
this.$el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
inline: 'start',
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
discussionComment: '',
|
||||
isFormRendered: false,
|
||||
activeDiscussion: {},
|
||||
isResolving: false,
|
||||
shouldChangeResolvedStatus: false,
|
||||
areRepliesCollapsed: this.discussion.resolved,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
mutationPayload() {
|
||||
return {
|
||||
noteableId: this.noteableId,
|
||||
body: this.discussionComment,
|
||||
discussionId: this.discussion.id,
|
||||
};
|
||||
},
|
||||
designVariables() {
|
||||
return {
|
||||
fullPath: this.projectPath,
|
||||
iid: this.issueIid,
|
||||
filenames: [this.$route.params.id],
|
||||
atVersion: this.designsVersion,
|
||||
};
|
||||
},
|
||||
isDiscussionHighlighted() {
|
||||
return this.discussion.notes[0].id === this.activeDiscussion.id;
|
||||
},
|
||||
resolveCheckboxText() {
|
||||
return this.discussion.resolved
|
||||
? s__('DesignManagement|Unresolve thread')
|
||||
: s__('DesignManagement|Resolve thread');
|
||||
},
|
||||
firstNote() {
|
||||
return this.discussion.notes[0];
|
||||
},
|
||||
discussionReplies() {
|
||||
return this.discussion.notes.slice(1);
|
||||
},
|
||||
areRepliesShown() {
|
||||
return !this.discussion.resolved || !this.areRepliesCollapsed;
|
||||
},
|
||||
resolveIconName() {
|
||||
return this.discussion.resolved ? 'check-circle-filled' : 'check-circle';
|
||||
},
|
||||
isRepliesWidgetVisible() {
|
||||
return this.discussion.resolved && this.discussionReplies.length > 0;
|
||||
},
|
||||
isReplyPlaceholderVisible() {
|
||||
return this.areRepliesShown || !this.discussionReplies.length;
|
||||
},
|
||||
isFormVisible() {
|
||||
return this.isFormRendered && this.discussionWithOpenForm === this.discussion.id;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addDiscussionComment(
|
||||
store,
|
||||
{
|
||||
data: { createNote },
|
||||
},
|
||||
) {
|
||||
updateStoreAfterAddDiscussionComment(
|
||||
store,
|
||||
createNote,
|
||||
getDesignQuery,
|
||||
this.designVariables,
|
||||
this.discussion.id,
|
||||
);
|
||||
},
|
||||
onDone() {
|
||||
this.discussionComment = '';
|
||||
this.hideForm();
|
||||
if (this.shouldChangeResolvedStatus) {
|
||||
this.toggleResolvedStatus();
|
||||
}
|
||||
},
|
||||
onCreateNoteError(err) {
|
||||
this.$emit('createNoteError', err);
|
||||
},
|
||||
hideForm() {
|
||||
this.isFormRendered = false;
|
||||
this.discussionComment = '';
|
||||
},
|
||||
showForm() {
|
||||
this.$emit('openForm', this.discussion.id);
|
||||
this.isFormRendered = true;
|
||||
},
|
||||
toggleResolvedStatus() {
|
||||
this.isResolving = true;
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: toggleResolveDiscussionMutation,
|
||||
variables: { id: this.discussion.id, resolve: !this.discussion.resolved },
|
||||
})
|
||||
.then(({ data }) => {
|
||||
if (data.errors?.length > 0) {
|
||||
this.$emit('resolveDiscussionError', data.errors[0]);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
this.$emit('resolveDiscussionError', err);
|
||||
})
|
||||
.finally(() => {
|
||||
this.isResolving = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
createNoteMutation,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="design-discussion-wrapper">
|
||||
<div
|
||||
class="badge badge-pill gl-display-flex gl-align-items-center gl-justify-content-center"
|
||||
:class="{ resolved: discussion.resolved }"
|
||||
type="button"
|
||||
>
|
||||
{{ discussion.index }}
|
||||
</div>
|
||||
<ul
|
||||
class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none"
|
||||
data-qa-selector="design_discussion_content"
|
||||
>
|
||||
<design-note
|
||||
:note="firstNote"
|
||||
:markdown-preview-path="markdownPreviewPath"
|
||||
:is-resolving="isResolving"
|
||||
:class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
|
||||
@error="$emit('updateNoteError', $event)"
|
||||
>
|
||||
<template v-if="discussion.resolvable" #resolveDiscussion>
|
||||
<button
|
||||
v-gl-tooltip
|
||||
:class="{ 'is-active': discussion.resolved }"
|
||||
:title="resolveCheckboxText"
|
||||
:aria-label="resolveCheckboxText"
|
||||
type="button"
|
||||
class="line-resolve-btn note-action-button gl-mr-3"
|
||||
data-testid="resolve-button"
|
||||
@click.stop="toggleResolvedStatus"
|
||||
>
|
||||
<gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" />
|
||||
<gl-loading-icon v-else inline />
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="discussion.resolved" #resolvedStatus>
|
||||
<p class="gl-text-gray-700 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message">
|
||||
{{ __('Resolved by') }}
|
||||
<gl-link
|
||||
class="gl-text-gray-700 gl-text-decoration-none gl-font-sm link-inherit-color"
|
||||
:href="discussion.resolvedBy.webUrl"
|
||||
target="_blank"
|
||||
>{{ discussion.resolvedBy.name }}</gl-link
|
||||
>
|
||||
<time-ago-tooltip :time="discussion.resolvedAt" tooltip-placement="bottom" />
|
||||
</p>
|
||||
</template>
|
||||
</design-note>
|
||||
<toggle-replies-widget
|
||||
v-if="isRepliesWidgetVisible"
|
||||
:collapsed="areRepliesCollapsed"
|
||||
:replies="discussionReplies"
|
||||
@toggle="areRepliesCollapsed = !areRepliesCollapsed"
|
||||
/>
|
||||
<design-note
|
||||
v-for="note in discussionReplies"
|
||||
v-show="areRepliesShown"
|
||||
:key="note.id"
|
||||
:note="note"
|
||||
:markdown-preview-path="markdownPreviewPath"
|
||||
:is-resolving="isResolving"
|
||||
:class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
|
||||
@error="$emit('updateNoteError', $event)"
|
||||
/>
|
||||
<li v-show="isReplyPlaceholderVisible" class="reply-wrapper">
|
||||
<reply-placeholder
|
||||
v-if="!isFormVisible"
|
||||
class="qa-discussion-reply"
|
||||
:button-text="__('Reply...')"
|
||||
@onClick="showForm"
|
||||
/>
|
||||
<apollo-mutation
|
||||
v-else
|
||||
#default="{ mutate, loading }"
|
||||
:mutation="$options.createNoteMutation"
|
||||
:variables="{
|
||||
input: mutationPayload,
|
||||
}"
|
||||
:update="addDiscussionComment"
|
||||
@done="onDone"
|
||||
@error="onCreateNoteError"
|
||||
>
|
||||
<design-reply-form
|
||||
v-model="discussionComment"
|
||||
:is-saving="loading"
|
||||
:markdown-preview-path="markdownPreviewPath"
|
||||
@submitForm="mutate"
|
||||
@cancelForm="hideForm"
|
||||
>
|
||||
<template v-if="discussion.resolvable" #resolveCheckbox>
|
||||
<label data-testid="resolve-checkbox">
|
||||
<input v-model="shouldChangeResolvedStatus" type="checkbox" />
|
||||
{{ resolveCheckboxText }}
|
||||
</label>
|
||||
</template>
|
||||
</design-reply-form>
|
||||
</apollo-mutation>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
<script>
|
||||
import { ApolloMutation } from 'vue-apollo';
|
||||
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
|
||||
import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql';
|
||||
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
|
||||
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import DesignReplyForm from './design_reply_form.vue';
|
||||
import { findNoteId } from '../../utils/design_management_utils';
|
||||
import { hasErrors } from '../../utils/cache_update';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
UserAvatarLink,
|
||||
TimelineEntryItem,
|
||||
TimeAgoTooltip,
|
||||
DesignReplyForm,
|
||||
ApolloMutation,
|
||||
GlIcon,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
markdownPreviewPath: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
noteText: this.note.body,
|
||||
isEditing: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
author() {
|
||||
return this.note.author;
|
||||
},
|
||||
noteAnchorId() {
|
||||
return findNoteId(this.note.id);
|
||||
},
|
||||
isNoteLinked() {
|
||||
return this.$route.hash === `#note_${this.noteAnchorId}`;
|
||||
},
|
||||
mutationPayload() {
|
||||
return {
|
||||
id: this.note.id,
|
||||
body: this.noteText,
|
||||
};
|
||||
},
|
||||
isEditButtonVisible() {
|
||||
return !this.isEditing && this.note.userPermissions.adminNote;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.isNoteLinked) {
|
||||
this.$refs.anchor.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' });
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hideForm() {
|
||||
this.isEditing = false;
|
||||
this.noteText = this.note.body;
|
||||
},
|
||||
onDone({ data }) {
|
||||
this.hideForm();
|
||||
if (hasErrors(data.updateNote)) {
|
||||
this.$emit('error', data.errors[0]);
|
||||
}
|
||||
},
|
||||
},
|
||||
updateNoteMutation,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<timeline-entry-item :id="`note_${noteAnchorId}`" ref="anchor" class="design-note note-form">
|
||||
<user-avatar-link
|
||||
:link-href="author.webUrl"
|
||||
:img-src="author.avatarUrl"
|
||||
:img-alt="author.username"
|
||||
:img-size="40"
|
||||
/>
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<a
|
||||
v-once
|
||||
:href="author.webUrl"
|
||||
class="js-user-link"
|
||||
:data-user-id="author.id"
|
||||
:data-username="author.username"
|
||||
>
|
||||
<span class="note-header-author-name bold">{{ author.name }}</span>
|
||||
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
|
||||
<span class="note-headline-light">@{{ author.username }}</span>
|
||||
</a>
|
||||
<span class="note-headline-light note-headline-meta">
|
||||
<span class="system-note-message"> <slot></slot> </span>
|
||||
<template v-if="note.createdAt">
|
||||
<span class="system-note-separator"></span>
|
||||
<a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`">
|
||||
<time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
|
||||
</a>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="gl-display-flex">
|
||||
<slot name="resolveDiscussion"></slot>
|
||||
<button
|
||||
v-if="isEditButtonVisible"
|
||||
v-gl-tooltip
|
||||
type="button"
|
||||
:title="__('Edit comment')"
|
||||
class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
|
||||
@click="isEditing = true"
|
||||
>
|
||||
<gl-icon name="pencil" class="link-highlight" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="!isEditing">
|
||||
<div
|
||||
class="note-text js-note-text md"
|
||||
data-qa-selector="note_content"
|
||||
v-html="note.bodyHtml"
|
||||
></div>
|
||||
<slot name="resolvedStatus"></slot>
|
||||
</template>
|
||||
<apollo-mutation
|
||||
v-else
|
||||
#default="{ mutate, loading }"
|
||||
:mutation="$options.updateNoteMutation"
|
||||
:variables="{
|
||||
input: mutationPayload,
|
||||
}"
|
||||
@error="$emit('error', $event)"
|
||||
@done="onDone"
|
||||
>
|
||||
<design-reply-form
|
||||
v-model="noteText"
|
||||
:is-saving="loading"
|
||||
:markdown-preview-path="markdownPreviewPath"
|
||||
:is-new-comment="false"
|
||||
class="mt-5"
|
||||
@submitForm="mutate"
|
||||
@cancelForm="hideForm"
|
||||
/>
|
||||
</apollo-mutation>
|
||||
</timeline-entry-item>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<script>
|
||||
import { GlIcon, GlButton, GlLink } from '@gitlab/ui';
|
||||
import { __, n__ } from '~/locale';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
|
||||
export default {
|
||||
name: 'ToggleNotesWidget',
|
||||
components: {
|
||||
GlIcon,
|
||||
GlButton,
|
||||
GlLink,
|
||||
TimeAgoTooltip,
|
||||
},
|
||||
props: {
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
replies: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
lastReply() {
|
||||
return this.replies[this.replies.length - 1];
|
||||
},
|
||||
iconName() {
|
||||
return this.collapsed ? 'chevron-right' : 'chevron-down';
|
||||
},
|
||||
toggleText() {
|
||||
return this.collapsed
|
||||
? `${this.replies.length} ${n__('reply', 'replies', this.replies.length)}`
|
||||
: __('Collapse replies');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3"
|
||||
:class="{ expanded: !collapsed }"
|
||||
data-testid="toggle-comments-wrapper"
|
||||
>
|
||||
<gl-icon :name="iconName" class="gl-ml-3" @click.stop="$emit('toggle')" />
|
||||
<gl-button
|
||||
variant="link"
|
||||
class="toggle-comments-button gl-ml-2 gl-mr-2"
|
||||
@click.stop="$emit('toggle')"
|
||||
>
|
||||
{{ toggleText }}
|
||||
</gl-button>
|
||||
<template v-if="collapsed">
|
||||
<span class="gl-text-gray-700">{{ __('Last reply by') }}</span>
|
||||
<gl-link
|
||||
:href="lastReply.author.webUrl"
|
||||
target="_blank"
|
||||
class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2"
|
||||
>
|
||||
{{ lastReply.author.name }}
|
||||
</gl-link>
|
||||
<time-ago-tooltip
|
||||
:time="lastReply.createdAt"
|
||||
tooltip-placement="bottom"
|
||||
class="gl-text-gray-700"
|
||||
/>
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
<script>
|
||||
import { s__ } from '~/locale';
|
||||
import Cookies from 'js-cookie';
|
||||
import { parseBoolean } from '~/lib/utils/common_utils';
|
||||
import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui';
|
||||
import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
|
||||
import { extractDiscussions, extractParticipants } from '../utils/design_management_utils';
|
||||
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
|
||||
import DesignDiscussion from './design_notes/design_discussion.vue';
|
||||
import Participants from '~/sidebar/components/participants/participants.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DesignDiscussion,
|
||||
Participants,
|
||||
GlCollapse,
|
||||
GlButton,
|
||||
GlPopover,
|
||||
},
|
||||
props: {
|
||||
design: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
resolvedDiscussionsExpanded: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
markdownPreviewPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)),
|
||||
discussionWithOpenForm: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
discussions() {
|
||||
return extractDiscussions(this.design.discussions);
|
||||
},
|
||||
issue() {
|
||||
return {
|
||||
...this.design.issue,
|
||||
webPath: this.design.issue.webPath.substr(1),
|
||||
};
|
||||
},
|
||||
discussionParticipants() {
|
||||
return extractParticipants(this.issue.participants);
|
||||
},
|
||||
resolvedDiscussions() {
|
||||
return this.discussions.filter(discussion => discussion.resolved);
|
||||
},
|
||||
unresolvedDiscussions() {
|
||||
return this.discussions.filter(discussion => !discussion.resolved);
|
||||
},
|
||||
resolvedCommentsToggleIcon() {
|
||||
return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleSidebarClick() {
|
||||
this.isResolvedCommentsPopoverHidden = true;
|
||||
Cookies.set(this.$options.cookieKey, 'true', { expires: 365 * 10 });
|
||||
this.updateActiveDiscussion();
|
||||
},
|
||||
updateActiveDiscussion(id) {
|
||||
this.$apollo.mutate({
|
||||
mutation: updateActiveDiscussionMutation,
|
||||
variables: {
|
||||
id,
|
||||
source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion,
|
||||
},
|
||||
});
|
||||
},
|
||||
closeCommentForm() {
|
||||
this.comment = '';
|
||||
this.$emit('closeCommentForm');
|
||||
},
|
||||
updateDiscussionWithOpenForm(id) {
|
||||
this.discussionWithOpenForm = id;
|
||||
},
|
||||
},
|
||||
resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
|
||||
cookieKey: 'hide_design_resolved_comments_popover',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="image-notes" @click="handleSidebarClick">
|
||||
<h2 class="gl-font-weight-bold gl-mt-0">
|
||||
{{ issue.title }}
|
||||
</h2>
|
||||
<a
|
||||
class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block"
|
||||
:href="issue.webUrl"
|
||||
>{{ issue.webPath }}</a
|
||||
>
|
||||
<participants
|
||||
:participants="discussionParticipants"
|
||||
:show-participant-label="false"
|
||||
class="gl-mb-4"
|
||||
/>
|
||||
<h2
|
||||
v-if="unresolvedDiscussions.length === 0"
|
||||
class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4"
|
||||
data-testid="new-discussion-disclaimer"
|
||||
>
|
||||
{{ s__("DesignManagement|Click the image where you'd like to start a new discussion") }}
|
||||
</h2>
|
||||
<design-discussion
|
||||
v-for="discussion in unresolvedDiscussions"
|
||||
:key="discussion.id"
|
||||
:discussion="discussion"
|
||||
:design-id="$route.params.id"
|
||||
:noteable-id="design.id"
|
||||
:markdown-preview-path="markdownPreviewPath"
|
||||
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
|
||||
:discussion-with-open-form="discussionWithOpenForm"
|
||||
data-testid="unresolved-discussion"
|
||||
@createNoteError="$emit('onDesignDiscussionError', $event)"
|
||||
@updateNoteError="$emit('updateNoteError', $event)"
|
||||
@resolveDiscussionError="$emit('resolveDiscussionError', $event)"
|
||||
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
|
||||
@openForm="updateDiscussionWithOpenForm"
|
||||
/>
|
||||
<template v-if="resolvedDiscussions.length > 0">
|
||||
<gl-button
|
||||
id="resolved-comments"
|
||||
data-testid="resolved-comments"
|
||||
:icon="resolvedCommentsToggleIcon"
|
||||
variant="link"
|
||||
class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4"
|
||||
@click="$emit('toggleResolvedComments')"
|
||||
>{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }})
|
||||
</gl-button>
|
||||
<gl-popover
|
||||
v-if="!isResolvedCommentsPopoverHidden"
|
||||
:show="!isResolvedCommentsPopoverHidden"
|
||||
target="resolved-comments"
|
||||
container="popovercontainer"
|
||||
placement="top"
|
||||
:title="s__('DesignManagement|Resolved Comments')"
|
||||
>
|
||||
<p>
|
||||
{{
|
||||
s__(
|
||||
'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<a href="#" rel="noopener noreferrer" target="_blank">{{
|
||||
s__('DesignManagement|Learn more about resolving comments')
|
||||
}}</a>
|
||||
</gl-popover>
|
||||
<gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3">
|
||||
<design-discussion
|
||||
v-for="discussion in resolvedDiscussions"
|
||||
:key="discussion.id"
|
||||
:discussion="discussion"
|
||||
:design-id="$route.params.id"
|
||||
:noteable-id="design.id"
|
||||
:markdown-preview-path="markdownPreviewPath"
|
||||
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
|
||||
:discussion-with-open-form="discussionWithOpenForm"
|
||||
data-testid="resolved-discussion"
|
||||
@error="$emit('onDesignDiscussionError', $event)"
|
||||
@updateNoteError="$emit('updateNoteError', $event)"
|
||||
@openForm="updateDiscussionWithOpenForm"
|
||||
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
|
||||
/>
|
||||
</gl-collapse>
|
||||
</template>
|
||||
<slot name="replyForm"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
<script>
|
||||
import { GlLoadingIcon, GlIcon, GlIntersectionObserver } from '@gitlab/ui';
|
||||
import Icon from '~/vue_shared/components/icon.vue';
|
||||
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import { n__, __ } from '~/locale';
|
||||
import { DESIGN_ROUTE_NAME } from '../../router/constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlLoadingIcon,
|
||||
GlIntersectionObserver,
|
||||
GlIcon,
|
||||
Icon,
|
||||
Timeago,
|
||||
},
|
||||
props: {
|
||||
id: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
event: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
notesCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filename: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
updatedAt: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
isUploading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
imageV432x230: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageLoading: true,
|
||||
imageError: false,
|
||||
wasInView: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
icon() {
|
||||
const normalizedEvent = this.event.toLowerCase();
|
||||
const icons = {
|
||||
creation: {
|
||||
name: 'file-addition-solid',
|
||||
classes: 'text-success-500',
|
||||
tooltip: __('Added in this version'),
|
||||
},
|
||||
modification: {
|
||||
name: 'file-modified-solid',
|
||||
classes: 'text-primary-500',
|
||||
tooltip: __('Modified in this version'),
|
||||
},
|
||||
deletion: {
|
||||
name: 'file-deletion-solid',
|
||||
classes: 'text-danger-500',
|
||||
tooltip: __('Deleted in this version'),
|
||||
},
|
||||
};
|
||||
|
||||
return icons[normalizedEvent] ? icons[normalizedEvent] : {};
|
||||
},
|
||||
notesLabel() {
|
||||
return n__('%d comment', '%d comments', this.notesCount);
|
||||
},
|
||||
imageLink() {
|
||||
return this.wasInView ? this.imageV432x230 || this.image : '';
|
||||
},
|
||||
showLoadingSpinner() {
|
||||
return this.imageLoading || this.isUploading;
|
||||
},
|
||||
showImageErrorIcon() {
|
||||
return this.wasInView && this.imageError;
|
||||
},
|
||||
showImage() {
|
||||
return !this.showLoadingSpinner && !this.showImageErrorIcon;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onImageLoad() {
|
||||
this.imageLoading = false;
|
||||
this.imageError = false;
|
||||
},
|
||||
onImageError() {
|
||||
this.imageLoading = false;
|
||||
this.imageError = true;
|
||||
},
|
||||
onAppear() {
|
||||
// do nothing if image has previously
|
||||
// been in view
|
||||
if (this.wasInView) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.wasInView = true;
|
||||
this.imageLoading = true;
|
||||
},
|
||||
},
|
||||
DESIGN_ROUTE_NAME,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link
|
||||
:to="{
|
||||
name: $options.DESIGN_ROUTE_NAME,
|
||||
params: { id: filename },
|
||||
query: $route.query,
|
||||
}"
|
||||
class="card cursor-pointer text-plain js-design-list-item design-list-item"
|
||||
>
|
||||
<div class="card-body p-0 d-flex-center overflow-hidden position-relative">
|
||||
<div v-if="icon.name" class="design-event position-absolute">
|
||||
<span :title="icon.tooltip" :aria-label="icon.tooltip">
|
||||
<icon :name="icon.name" :size="18" :class="icon.classes" />
|
||||
</span>
|
||||
</div>
|
||||
<gl-intersection-observer @appear="onAppear">
|
||||
<gl-loading-icon v-if="showLoadingSpinner" size="md" />
|
||||
<gl-icon
|
||||
v-else-if="showImageErrorIcon"
|
||||
name="media-broken"
|
||||
class="text-secondary"
|
||||
:size="32"
|
||||
/>
|
||||
<img
|
||||
v-show="showImage"
|
||||
:src="imageLink"
|
||||
:alt="filename"
|
||||
class="block mx-auto mw-100 mh-100 design-img"
|
||||
data-qa-selector="design_image"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</gl-intersection-observer>
|
||||
</div>
|
||||
<div class="card-footer d-flex w-100">
|
||||
<div class="d-flex flex-column str-truncated-100">
|
||||
<span class="bold str-truncated-100" data-qa-selector="design_file_name">{{
|
||||
filename
|
||||
}}</span>
|
||||
<span v-if="updatedAt" class="str-truncated-100">
|
||||
{{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="notesCount" class="ml-auto d-flex align-items-center text-secondary">
|
||||
<icon name="comments" class="ml-1" />
|
||||
<span :aria-label="notesLabel" class="ml-1">
|
||||
{{ notesCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<script>
|
||||
import { GlDeprecatedButton } from '@gitlab/ui';
|
||||
import { __, sprintf } from '~/locale';
|
||||
import Icon from '~/vue_shared/components/icon.vue';
|
||||
import timeagoMixin from '~/vue_shared/mixins/timeago';
|
||||
import Pagination from './pagination.vue';
|
||||
import DeleteButton from '../delete_button.vue';
|
||||
import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql';
|
||||
import appDataQuery from '../../graphql/queries/app_data.query.graphql';
|
||||
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Icon,
|
||||
Pagination,
|
||||
DeleteButton,
|
||||
GlDeprecatedButton,
|
||||
},
|
||||
mixins: [timeagoMixin],
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isDeleting: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
filename: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
updatedAt: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
updatedBy: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
isLatestVersion: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
permissions: {
|
||||
createDesign: false,
|
||||
},
|
||||
projectPath: '',
|
||||
issueIid: null,
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
appData: {
|
||||
query: appDataQuery,
|
||||
manual: true,
|
||||
result({ data: { projectPath, issueIid } }) {
|
||||
this.projectPath = projectPath;
|
||||
this.issueIid = issueIid;
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
query: permissionsQuery,
|
||||
variables() {
|
||||
return {
|
||||
fullPath: this.projectPath,
|
||||
iid: this.issueIid,
|
||||
};
|
||||
},
|
||||
update: data => data.project.issue.userPermissions,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
updatedText() {
|
||||
return sprintf(__('Updated %{updated_at} by %{updated_by}'), {
|
||||
updated_at: this.timeFormatted(this.updatedAt),
|
||||
updated_by: this.updatedBy.name,
|
||||
});
|
||||
},
|
||||
canDeleteDesign() {
|
||||
return this.permissions.createDesign;
|
||||
},
|
||||
},
|
||||
DESIGNS_ROUTE_NAME,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="d-flex p-2 bg-white align-items-center js-design-header">
|
||||
<router-link
|
||||
:to="{
|
||||
name: $options.DESIGNS_ROUTE_NAME,
|
||||
query: $route.query,
|
||||
}"
|
||||
:aria-label="s__('DesignManagement|Go back to designs')"
|
||||
class="mr-3 text-plain d-flex justify-content-center align-items-center"
|
||||
>
|
||||
<icon :size="18" name="close" />
|
||||
</router-link>
|
||||
<div class="overflow-hidden d-flex align-items-center">
|
||||
<h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
|
||||
<small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
|
||||
</div>
|
||||
<pagination :id="id" class="ml-auto flex-shrink-0" />
|
||||
<gl-deprecated-button :href="image" class="mr-2">
|
||||
<icon :size="18" name="download" />
|
||||
</gl-deprecated-button>
|
||||
<delete-button
|
||||
v-if="isLatestVersion && canDeleteDesign"
|
||||
:is-deleting="isDeleting"
|
||||
button-variant="danger"
|
||||
@deleteSelectedDesigns="$emit('delete')"
|
||||
>
|
||||
<icon :size="18" name="remove" />
|
||||
</delete-button>
|
||||
</header>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<script>
|
||||
import Icon from '~/vue_shared/components/icon.vue';
|
||||
import { DESIGN_ROUTE_NAME } from '../../router/constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
design: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
designLink() {
|
||||
if (!this.design) return {};
|
||||
|
||||
return {
|
||||
name: DESIGN_ROUTE_NAME,
|
||||
params: { id: this.design.filename },
|
||||
query: this.$route.query,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link
|
||||
:to="designLink"
|
||||
:disabled="!design"
|
||||
:class="{ disabled: !design }"
|
||||
:aria-label="title"
|
||||
class="btn btn-default"
|
||||
>
|
||||
<icon :name="iconName" />
|
||||
</router-link>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
<script>
|
||||
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
|
||||
import createFlash from '~/flash';
|
||||
import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql';
|
||||
import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages';
|
||||
import { isValidDesignFile } from '../../utils/design_management_utils';
|
||||
import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlIcon,
|
||||
GlLink,
|
||||
GlSprintf,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dragCounter: 0,
|
||||
isDragDataValid: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
dragging() {
|
||||
return this.dragCounter !== 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isValidUpload(files) {
|
||||
return files.every(isValidDesignFile);
|
||||
},
|
||||
isValidDragDataType({ dataTransfer }) {
|
||||
return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE));
|
||||
},
|
||||
ondrop({ dataTransfer = {} }) {
|
||||
this.dragCounter = 0;
|
||||
// User already had feedback when dropzone was active, so bail here
|
||||
if (!this.isDragDataValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { files } = dataTransfer;
|
||||
if (!this.isValidUpload(Array.from(files))) {
|
||||
createFlash(UPLOAD_DESIGN_INVALID_FILETYPE_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('change', files);
|
||||
},
|
||||
ondragenter(e) {
|
||||
this.dragCounter += 1;
|
||||
this.isDragDataValid = this.isValidDragDataType(e);
|
||||
},
|
||||
ondragleave() {
|
||||
this.dragCounter -= 1;
|
||||
},
|
||||
openFileUpload() {
|
||||
this.$refs.fileUpload.click();
|
||||
},
|
||||
onDesignInputChange(e) {
|
||||
this.$emit('change', e.target.files);
|
||||
},
|
||||
},
|
||||
uploadDesignMutation,
|
||||
VALID_DESIGN_FILE_MIMETYPE,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-100 position-relative"
|
||||
@dragstart.prevent.stop
|
||||
@dragend.prevent.stop
|
||||
@dragover.prevent.stop
|
||||
@dragenter.prevent.stop="ondragenter"
|
||||
@dragleave.prevent.stop="ondragleave"
|
||||
@drop.prevent.stop="ondrop"
|
||||
>
|
||||
<slot>
|
||||
<button
|
||||
class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
|
||||
@click="openFileUpload"
|
||||
>
|
||||
<div class="d-flex-center flex-column text-center">
|
||||
<gl-icon name="doc-new" :size="48" class="mb-4" />
|
||||
<p>
|
||||
<gl-sprintf
|
||||
:message="
|
||||
__(
|
||||
'%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}.',
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #lineOne="{ content }"
|
||||
><span class="d-block">{{ content }}</span>
|
||||
</template>
|
||||
|
||||
<template #link="{ content }">
|
||||
<gl-link class="h-100 w-100" @click.stop="openFileUpload">{{ content }}</gl-link>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref="fileUpload"
|
||||
type="file"
|
||||
name="design_file"
|
||||
:accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
|
||||
class="hide"
|
||||
multiple
|
||||
@change="onDesignInputChange"
|
||||
/>
|
||||
</slot>
|
||||
<transition name="design-dropzone-fade">
|
||||
<div
|
||||
v-show="dragging"
|
||||
class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
|
||||
>
|
||||
<div v-show="!isDragDataValid" class="mw-50 text-center">
|
||||
<h3>{{ __('Oh no!') }}</h3>
|
||||
<span>{{
|
||||
__(
|
||||
'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.',
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-show="isDragDataValid" class="mw-50 text-center">
|
||||
<h3>{{ __('Incoming!') }}</h3>
|
||||
<span>{{ __('Drop your designs to start your upload.') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<script>
|
||||
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
|
||||
import { __, sprintf } from '~/locale';
|
||||
import allVersionsMixin from '../../mixins/all_versions';
|
||||
import { findVersionId } from '../../utils/design_management_utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
},
|
||||
mixins: [allVersionsMixin],
|
||||
computed: {
|
||||
queryVersion() {
|
||||
return this.$route.query.version;
|
||||
},
|
||||
currentVersionIdx() {
|
||||
if (!this.queryVersion) return 0;
|
||||
|
||||
const idx = this.allVersions.findIndex(
|
||||
version => this.findVersionId(version.node.id) === this.queryVersion,
|
||||
);
|
||||
|
||||
// if the currentVersionId isn't a valid version (i.e. not in allVersions)
|
||||
// then return the latest version (index 0)
|
||||
return idx !== -1 ? idx : 0;
|
||||
},
|
||||
currentVersionId() {
|
||||
if (this.queryVersion) return this.queryVersion;
|
||||
|
||||
const currentVersion = this.allVersions[this.currentVersionIdx];
|
||||
return this.findVersionId(currentVersion.node.id);
|
||||
},
|
||||
dropdownText() {
|
||||
if (this.isLatestVersion) {
|
||||
return __('Showing Latest Version');
|
||||
}
|
||||
// allVersions is sorted in reverse chronological order (latest first)
|
||||
const currentVersionNumber = this.allVersions.length - this.currentVersionIdx;
|
||||
|
||||
return sprintf(__('Showing Version #%{versionNumber}'), {
|
||||
versionNumber: currentVersionNumber,
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
findVersionId,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-dropdown :text="dropdownText" variant="link" class="design-version-dropdown">
|
||||
<gl-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id">
|
||||
<router-link
|
||||
class="d-flex js-version-link"
|
||||
:to="{ path: $route.path, query: { version: findVersionId(version.node.id) } }"
|
||||
>
|
||||
<div class="flex-grow-1 ml-2">
|
||||
<div>
|
||||
<strong
|
||||
>{{ __('Version') }} {{ allVersions.length - index }}
|
||||
<span v-if="findVersionId(version.node.id) === latestVersionId"
|
||||
>({{ __('latest') }})</span
|
||||
>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<i
|
||||
v-if="findVersionId(version.node.id) === currentVersionId"
|
||||
class="fa fa-check pull-right"
|
||||
></i>
|
||||
</router-link>
|
||||
</gl-dropdown-item>
|
||||
</gl-dropdown>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// 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'];
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
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,
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
#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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fragment DesignListItem on Design {
|
||||
id
|
||||
event
|
||||
filename
|
||||
notesCount
|
||||
image
|
||||
imageV432x230
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
#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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
fragment DesignDiffRefs on DiffRefs {
|
||||
baseSha
|
||||
startSha
|
||||
headSha
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
fragment ResolvedStatus on Discussion {
|
||||
resolvable
|
||||
resolved
|
||||
resolvedAt
|
||||
resolvedBy {
|
||||
name
|
||||
webUrl
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fragment DesignNotePermissions on NotePermissions {
|
||||
adminNote
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
fragment VersionListItem on DesignVersion {
|
||||
id
|
||||
sha
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
#import "../fragments/design_note.fragment.graphql"
|
||||
|
||||
mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
|
||||
createImageDiffNote(input: $input) {
|
||||
note {
|
||||
...DesignNote
|
||||
discussion {
|
||||
id
|
||||
replyId
|
||||
notes {
|
||||
edges {
|
||||
node {
|
||||
...DesignNote
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
#import "../fragments/design_note.fragment.graphql"
|
||||
|
||||
mutation createNote($input: CreateNoteInput!) {
|
||||
createNote(input: $input) {
|
||||
note {
|
||||
...DesignNote
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
#import "../fragments/version.fragment.graphql"
|
||||
|
||||
mutation destroyDesign($filenames: [String!]!, $projectPath: ID!, $iid: ID!) {
|
||||
designManagementDelete(input: { projectPath: $projectPath, iid: $iid, filenames: $filenames }) {
|
||||
version {
|
||||
...VersionListItem
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
#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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
mutation updateActiveDiscussion($id: String, $source: String) {
|
||||
updateActiveDiscussion (id: $id, source: $source ) @client
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
#import "../fragments/design_note.fragment.graphql"
|
||||
|
||||
mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) {
|
||||
updateImageDiffNote(input: $input) {
|
||||
errors
|
||||
note {
|
||||
...DesignNote
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
#import "../fragments/design_note.fragment.graphql"
|
||||
|
||||
mutation updateNote($input: UpdateNoteInput!) {
|
||||
updateNote(input: $input) {
|
||||
note {
|
||||
...DesignNote
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
#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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
query activeDiscussion {
|
||||
activeDiscussion @client {
|
||||
id
|
||||
source
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
query projectFullPath {
|
||||
projectPath @client
|
||||
issueIid @client
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
query permissions($fullPath: ID!, $iid: String!) {
|
||||
project(fullPath: $fullPath) {
|
||||
id
|
||||
issue(iid: $iid) {
|
||||
userPermissions {
|
||||
createDesign
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
#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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
#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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
type ActiveDiscussion {
|
||||
id: ID
|
||||
source: String
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
activeDiscussion: ActiveDiscussion
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
updateActiveDiscussion(id: ID!, source: String!): Boolean
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import $ from 'jquery';
|
||||
import Vue from 'vue';
|
||||
import createRouter from './router';
|
||||
import App from './components/app.vue';
|
||||
import apolloProvider from './graphql';
|
||||
import getDesignListQuery from './graphql/queries/get_design_list.query.graphql';
|
||||
import { DESIGNS_ROUTE_NAME, ROOT_ROUTE_NAME } from './router/constants';
|
||||
|
||||
export default () => {
|
||||
const el = document.querySelector('.js-design-management-new');
|
||||
const badge = document.querySelector('.js-designs-count');
|
||||
const { issueIid, projectPath, issuePath } = el.dataset;
|
||||
const router = createRouter(issuePath);
|
||||
|
||||
$('.js-issue-tabs').on('shown.bs.tab', ({ target: { id } }) => {
|
||||
if (id === 'designs' && router.currentRoute.name === ROOT_ROUTE_NAME) {
|
||||
router.push({ name: DESIGNS_ROUTE_NAME });
|
||||
} else if (id === 'discussion') {
|
||||
router.push({ name: ROOT_ROUTE_NAME });
|
||||
}
|
||||
});
|
||||
|
||||
apolloProvider.clients.defaultClient.cache.writeData({
|
||||
data: {
|
||||
projectPath,
|
||||
issueIid,
|
||||
activeDiscussion: {
|
||||
__typename: 'ActiveDiscussion',
|
||||
id: null,
|
||||
source: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
apolloProvider.clients.defaultClient
|
||||
.watchQuery({
|
||||
query: getDesignListQuery,
|
||||
variables: {
|
||||
fullPath: projectPath,
|
||||
iid: issueIid,
|
||||
atVersion: null,
|
||||
},
|
||||
})
|
||||
.subscribe(({ data }) => {
|
||||
if (badge) {
|
||||
badge.textContent = data.project.issue.designCollection.designs.edges.length;
|
||||
}
|
||||
});
|
||||
|
||||
return new Vue({
|
||||
el,
|
||||
router,
|
||||
apolloProvider,
|
||||
render(createElement) {
|
||||
return createElement(App);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { propertyOf } from 'lodash';
|
||||
import createFlash from '~/flash';
|
||||
import { s__ } from '~/locale';
|
||||
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
|
||||
import { extractNodes } from '../utils/design_management_utils';
|
||||
import allVersionsMixin from './all_versions';
|
||||
import { DESIGNS_ROUTE_NAME } from '../router/constants';
|
||||
|
||||
export default {
|
||||
mixins: [allVersionsMixin],
|
||||
apollo: {
|
||||
designs: {
|
||||
query: getDesignListQuery,
|
||||
variables() {
|
||||
return {
|
||||
fullPath: this.projectPath,
|
||||
iid: this.issueIid,
|
||||
atVersion: this.designsVersion,
|
||||
};
|
||||
},
|
||||
update: data => {
|
||||
const designEdges = propertyOf(data)(['project', 'issue', 'designCollection', 'designs']);
|
||||
if (designEdges) {
|
||||
return extractNodes(designEdges);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
error() {
|
||||
this.error = true;
|
||||
},
|
||||
result() {
|
||||
if (this.$route.query.version && !this.hasValidVersion) {
|
||||
createFlash(
|
||||
s__(
|
||||
'DesignManagement|Requested design version does not exist. Showing latest version instead',
|
||||
),
|
||||
);
|
||||
this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
designs: [],
|
||||
error: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,378 @@
|
|||
<script>
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
|
||||
import { ApolloMutation } from 'vue-apollo';
|
||||
import createFlash from '~/flash';
|
||||
import { fetchPolicies } from '~/lib/graphql';
|
||||
import allVersionsMixin from '../../mixins/all_versions';
|
||||
import Toolbar from '../../components/toolbar/index.vue';
|
||||
import DesignDestroyer from '../../components/design_destroyer.vue';
|
||||
import DesignScaler from '../../components/design_scaler.vue';
|
||||
import DesignPresentation from '../../components/design_presentation.vue';
|
||||
import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
|
||||
import DesignSidebar from '../../components/design_sidebar.vue';
|
||||
import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
|
||||
import appDataQuery from '../../graphql/queries/app_data.query.graphql';
|
||||
import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql';
|
||||
import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql';
|
||||
import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql';
|
||||
import {
|
||||
extractDiscussions,
|
||||
extractDesign,
|
||||
updateImageDiffNoteOptimisticResponse,
|
||||
} from '../../utils/design_management_utils';
|
||||
import {
|
||||
updateStoreAfterAddImageDiffNote,
|
||||
updateStoreAfterUpdateImageDiffNote,
|
||||
} from '../../utils/cache_update';
|
||||
import {
|
||||
ADD_DISCUSSION_COMMENT_ERROR,
|
||||
ADD_IMAGE_DIFF_NOTE_ERROR,
|
||||
UPDATE_IMAGE_DIFF_NOTE_ERROR,
|
||||
DESIGN_NOT_FOUND_ERROR,
|
||||
DESIGN_VERSION_NOT_EXIST_ERROR,
|
||||
UPDATE_NOTE_ERROR,
|
||||
designDeletionError,
|
||||
} from '../../utils/error_messages';
|
||||
import { trackDesignDetailView } from '../../utils/tracking';
|
||||
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
|
||||
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ApolloMutation,
|
||||
DesignReplyForm,
|
||||
DesignPresentation,
|
||||
DesignScaler,
|
||||
DesignDestroyer,
|
||||
Toolbar,
|
||||
GlLoadingIcon,
|
||||
GlAlert,
|
||||
DesignSidebar,
|
||||
},
|
||||
mixins: [allVersionsMixin],
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
design: {},
|
||||
comment: '',
|
||||
annotationCoordinates: null,
|
||||
projectPath: '',
|
||||
errorMessage: '',
|
||||
issueIid: '',
|
||||
scale: 1,
|
||||
resolvedDiscussionsExpanded: false,
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
appData: {
|
||||
query: appDataQuery,
|
||||
manual: true,
|
||||
result({ data: { projectPath, issueIid } }) {
|
||||
this.projectPath = projectPath;
|
||||
this.issueIid = issueIid;
|
||||
},
|
||||
},
|
||||
design: {
|
||||
query: getDesignQuery,
|
||||
// We want to see cached design version if we have one, and fetch newer version on the background to update discussions
|
||||
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
|
||||
variables() {
|
||||
return this.designVariables;
|
||||
},
|
||||
update: data => extractDesign(data),
|
||||
result(res) {
|
||||
this.onDesignQueryResult(res);
|
||||
},
|
||||
error() {
|
||||
this.onQueryError(DESIGN_NOT_FOUND_ERROR);
|
||||
},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isFirstLoading() {
|
||||
// We only want to show spinner on initial design load (when opened from a deep link to design)
|
||||
// If we already have cached a design, loading shouldn't be indicated to user
|
||||
return this.$apollo.queries.design.loading && !this.design.filename;
|
||||
},
|
||||
discussions() {
|
||||
if (!this.design.discussions) {
|
||||
return [];
|
||||
}
|
||||
return extractDiscussions(this.design.discussions);
|
||||
},
|
||||
markdownPreviewPath() {
|
||||
return `/${this.projectPath}/preview_markdown?target_type=Issue`;
|
||||
},
|
||||
isSubmitButtonDisabled() {
|
||||
return this.comment.trim().length === 0;
|
||||
},
|
||||
designVariables() {
|
||||
return {
|
||||
fullPath: this.projectPath,
|
||||
iid: this.issueIid,
|
||||
filenames: [this.$route.params.id],
|
||||
atVersion: this.designsVersion,
|
||||
};
|
||||
},
|
||||
mutationPayload() {
|
||||
const { x, y, width, height } = this.annotationCoordinates;
|
||||
return {
|
||||
noteableId: this.design.id,
|
||||
body: this.comment,
|
||||
position: {
|
||||
headSha: this.design.diffRefs.headSha,
|
||||
baseSha: this.design.diffRefs.baseSha,
|
||||
startSha: this.design.diffRefs.startSha,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
paths: {
|
||||
newPath: this.design.fullPath,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
isAnnotating() {
|
||||
return Boolean(this.annotationCoordinates);
|
||||
},
|
||||
resolvedDiscussions() {
|
||||
return this.discussions.filter(discussion => discussion.resolved);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
resolvedDiscussions(val) {
|
||||
if (!val.length) {
|
||||
this.resolvedDiscussionsExpanded = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
Mousetrap.bind('esc', this.closeDesign);
|
||||
this.trackEvent();
|
||||
// We need to reset the active discussion when opening a new design
|
||||
this.updateActiveDiscussion();
|
||||
},
|
||||
beforeDestroy() {
|
||||
Mousetrap.unbind('esc', this.closeDesign);
|
||||
},
|
||||
methods: {
|
||||
addImageDiffNoteToStore(
|
||||
store,
|
||||
{
|
||||
data: { createImageDiffNote },
|
||||
},
|
||||
) {
|
||||
updateStoreAfterAddImageDiffNote(
|
||||
store,
|
||||
createImageDiffNote,
|
||||
getDesignQuery,
|
||||
this.designVariables,
|
||||
);
|
||||
},
|
||||
updateImageDiffNoteInStore(
|
||||
store,
|
||||
{
|
||||
data: { updateImageDiffNote },
|
||||
},
|
||||
) {
|
||||
return updateStoreAfterUpdateImageDiffNote(
|
||||
store,
|
||||
updateImageDiffNote,
|
||||
getDesignQuery,
|
||||
this.designVariables,
|
||||
);
|
||||
},
|
||||
onMoveNote({ noteId, discussionId, position }) {
|
||||
const discussion = this.discussions.find(({ id }) => id === discussionId);
|
||||
const note = discussion.notes.find(
|
||||
({ discussion: noteDiscussion }) => noteDiscussion.id === discussionId,
|
||||
);
|
||||
|
||||
const mutationPayload = {
|
||||
optimisticResponse: updateImageDiffNoteOptimisticResponse(note, {
|
||||
position,
|
||||
}),
|
||||
variables: {
|
||||
input: {
|
||||
id: noteId,
|
||||
position,
|
||||
},
|
||||
},
|
||||
mutation: updateImageDiffNoteMutation,
|
||||
update: this.updateImageDiffNoteInStore,
|
||||
};
|
||||
|
||||
return this.$apollo.mutate(mutationPayload).catch(e => this.onUpdateImageDiffNoteError(e));
|
||||
},
|
||||
onDesignQueryResult({ data, loading }) {
|
||||
// On the initial load with cache-and-network policy data is undefined while loading is true
|
||||
// To prevent throwing an error, we don't perform any logic until loading is false
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !extractDesign(data)) {
|
||||
this.onQueryError(DESIGN_NOT_FOUND_ERROR);
|
||||
} else if (this.$route.query.version && !this.hasValidVersion) {
|
||||
this.onQueryError(DESIGN_VERSION_NOT_EXIST_ERROR);
|
||||
}
|
||||
},
|
||||
onQueryError(message) {
|
||||
// because we redirect user to /designs (the issue page),
|
||||
// we want to create these flashes on the issue page
|
||||
createFlash(message);
|
||||
this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME });
|
||||
},
|
||||
onError(message, e) {
|
||||
this.errorMessage = message;
|
||||
throw e;
|
||||
},
|
||||
onCreateImageDiffNoteError(e) {
|
||||
this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e);
|
||||
},
|
||||
onUpdateNoteError(e) {
|
||||
this.onError(UPDATE_NOTE_ERROR, e);
|
||||
},
|
||||
onDesignDiscussionError(e) {
|
||||
this.onError(ADD_DISCUSSION_COMMENT_ERROR, e);
|
||||
},
|
||||
onUpdateImageDiffNoteError(e) {
|
||||
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
|
||||
},
|
||||
onDesignDeleteError(e) {
|
||||
this.onError(designDeletionError({ singular: true }), e);
|
||||
},
|
||||
onResolveDiscussionError(e) {
|
||||
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
|
||||
},
|
||||
openCommentForm(annotationCoordinates) {
|
||||
this.annotationCoordinates = annotationCoordinates;
|
||||
if (this.$refs.newDiscussionForm) {
|
||||
this.$refs.newDiscussionForm.focusInput();
|
||||
}
|
||||
},
|
||||
closeCommentForm() {
|
||||
this.comment = '';
|
||||
this.annotationCoordinates = null;
|
||||
},
|
||||
closeDesign() {
|
||||
this.$router.push({
|
||||
name: this.$options.DESIGNS_ROUTE_NAME,
|
||||
query: this.$route.query,
|
||||
});
|
||||
},
|
||||
trackEvent() {
|
||||
// TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue
|
||||
trackDesignDetailView(
|
||||
'issue-design-collection',
|
||||
'issue',
|
||||
this.$route.query.version || this.latestVersionId,
|
||||
this.isLatestVersion,
|
||||
);
|
||||
},
|
||||
updateActiveDiscussion(id) {
|
||||
this.$apollo.mutate({
|
||||
mutation: updateActiveDiscussionMutation,
|
||||
variables: {
|
||||
id,
|
||||
source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion,
|
||||
},
|
||||
});
|
||||
},
|
||||
toggleResolvedComments() {
|
||||
this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded;
|
||||
},
|
||||
},
|
||||
createImageDiffNoteMutation,
|
||||
DESIGNS_ROUTE_NAME,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
|
||||
>
|
||||
<gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" />
|
||||
<template v-else>
|
||||
<div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative">
|
||||
<design-destroyer
|
||||
:filenames="[design.filename]"
|
||||
:project-path="projectPath"
|
||||
:iid="issueIid"
|
||||
@done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })"
|
||||
@error="onDesignDeleteError"
|
||||
>
|
||||
<template #default="{ mutate, loading }">
|
||||
<toolbar
|
||||
:id="id"
|
||||
:is-deleting="loading"
|
||||
:is-latest-version="isLatestVersion"
|
||||
v-bind="design"
|
||||
@delete="mutate"
|
||||
/>
|
||||
</template>
|
||||
</design-destroyer>
|
||||
|
||||
<div v-if="errorMessage" class="p-3">
|
||||
<gl-alert variant="danger" @dismiss="errorMessage = null">
|
||||
{{ errorMessage }}
|
||||
</gl-alert>
|
||||
</div>
|
||||
<design-presentation
|
||||
:image="design.image"
|
||||
:image-name="design.filename"
|
||||
:discussions="discussions"
|
||||
:is-annotating="isAnnotating"
|
||||
:scale="scale"
|
||||
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
|
||||
@openCommentForm="openCommentForm"
|
||||
@closeCommentForm="closeCommentForm"
|
||||
@moveNote="onMoveNote"
|
||||
/>
|
||||
|
||||
<div class="design-scaler-wrapper position-absolute mb-4 d-flex-center">
|
||||
<design-scaler @scale="scale = $event" />
|
||||
</div>
|
||||
</div>
|
||||
<design-sidebar
|
||||
:design="design"
|
||||
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
|
||||
:markdown-preview-path="markdownPreviewPath"
|
||||
@onDesignDiscussionError="onDesignDiscussionError"
|
||||
@onCreateImageDiffNoteError="onCreateImageDiffNoteError"
|
||||
@updateNoteError="onUpdateNoteError"
|
||||
@resolveDiscussionError="onResolveDiscussionError"
|
||||
@toggleResolvedComments="toggleResolvedComments"
|
||||
>
|
||||
<template #replyForm>
|
||||
<apollo-mutation
|
||||
v-if="isAnnotating"
|
||||
#default="{ mutate, loading }"
|
||||
:mutation="$options.createImageDiffNoteMutation"
|
||||
:variables="{
|
||||
input: mutationPayload,
|
||||
}"
|
||||
:update="addImageDiffNoteToStore"
|
||||
@done="closeCommentForm"
|
||||
@error="onCreateImageDiffNoteError"
|
||||
>
|
||||
<design-reply-form
|
||||
ref="newDiscussionForm"
|
||||
v-model="comment"
|
||||
:is-saving="loading"
|
||||
:markdown-preview-path="markdownPreviewPath"
|
||||
@submitForm="mutate"
|
||||
@cancelForm="closeCommentForm"
|
||||
/> </apollo-mutation
|
||||
></template>
|
||||
</design-sidebar>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
<script>
|
||||
import { GlLoadingIcon, GlDeprecatedButton, GlAlert } from '@gitlab/ui';
|
||||
import createFlash from '~/flash';
|
||||
import { s__, sprintf } from '~/locale';
|
||||
import UploadButton from '../components/upload/button.vue';
|
||||
import DeleteButton from '../components/delete_button.vue';
|
||||
import Design from '../components/list/item.vue';
|
||||
import DesignDestroyer from '../components/design_destroyer.vue';
|
||||
import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
|
||||
import DesignDropzone from '../components/upload/design_dropzone.vue';
|
||||
import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
|
||||
import permissionsQuery from '../graphql/queries/design_permissions.query.graphql';
|
||||
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
|
||||
import allDesignsMixin from '../mixins/all_designs';
|
||||
import {
|
||||
UPLOAD_DESIGN_ERROR,
|
||||
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
|
||||
EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
|
||||
designUploadSkippedWarning,
|
||||
designDeletionError,
|
||||
} from '../utils/error_messages';
|
||||
import { updateStoreAfterUploadDesign } from '../utils/cache_update';
|
||||
import {
|
||||
designUploadOptimisticResponse,
|
||||
isValidDesignFile,
|
||||
} from '../utils/design_management_utils';
|
||||
import { getFilename } from '~/lib/utils/file_upload';
|
||||
import { DESIGNS_ROUTE_NAME } from '../router/constants';
|
||||
|
||||
const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlLoadingIcon,
|
||||
GlAlert,
|
||||
GlDeprecatedButton,
|
||||
UploadButton,
|
||||
Design,
|
||||
DesignDestroyer,
|
||||
DesignVersionDropdown,
|
||||
DeleteButton,
|
||||
DesignDropzone,
|
||||
},
|
||||
mixins: [allDesignsMixin],
|
||||
apollo: {
|
||||
permissions: {
|
||||
query: permissionsQuery,
|
||||
variables() {
|
||||
return {
|
||||
fullPath: this.projectPath,
|
||||
iid: this.issueIid,
|
||||
};
|
||||
},
|
||||
update: data => data.project.issue.userPermissions,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
permissions: {
|
||||
createDesign: false,
|
||||
},
|
||||
filesToBeSaved: [],
|
||||
selectedDesigns: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isLoading() {
|
||||
return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading;
|
||||
},
|
||||
isSaving() {
|
||||
return this.filesToBeSaved.length > 0;
|
||||
},
|
||||
canCreateDesign() {
|
||||
return this.permissions.createDesign;
|
||||
},
|
||||
showToolbar() {
|
||||
return this.canCreateDesign && this.allVersions.length > 0;
|
||||
},
|
||||
hasDesigns() {
|
||||
return this.designs.length > 0;
|
||||
},
|
||||
hasSelectedDesigns() {
|
||||
return this.selectedDesigns.length > 0;
|
||||
},
|
||||
canDeleteDesigns() {
|
||||
return this.isLatestVersion && this.hasSelectedDesigns;
|
||||
},
|
||||
projectQueryBody() {
|
||||
return {
|
||||
query: getDesignListQuery,
|
||||
variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null },
|
||||
};
|
||||
},
|
||||
selectAllButtonText() {
|
||||
return this.hasSelectedDesigns
|
||||
? s__('DesignManagement|Deselect all')
|
||||
: s__('DesignManagement|Select all');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.toggleOnPasteListener(this.$route.name);
|
||||
},
|
||||
methods: {
|
||||
resetFilesToBeSaved() {
|
||||
this.filesToBeSaved = [];
|
||||
},
|
||||
/**
|
||||
* Determine if a design upload is valid, given [files]
|
||||
* @param {Array<File>} files
|
||||
*/
|
||||
isValidDesignUpload(files) {
|
||||
if (!this.canCreateDesign) return false;
|
||||
|
||||
if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
|
||||
createFlash(
|
||||
sprintf(
|
||||
s__(
|
||||
'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
|
||||
),
|
||||
{
|
||||
upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
onUploadDesign(files) {
|
||||
// convert to Array so that we have Array methods (.map, .some, etc.)
|
||||
this.filesToBeSaved = Array.from(files);
|
||||
if (!this.isValidDesignUpload(this.filesToBeSaved)) return null;
|
||||
|
||||
const mutationPayload = {
|
||||
optimisticResponse: designUploadOptimisticResponse(this.filesToBeSaved),
|
||||
variables: {
|
||||
files: this.filesToBeSaved,
|
||||
projectPath: this.projectPath,
|
||||
iid: this.issueIid,
|
||||
},
|
||||
context: {
|
||||
hasUpload: true,
|
||||
},
|
||||
mutation: uploadDesignMutation,
|
||||
update: this.afterUploadDesign,
|
||||
};
|
||||
|
||||
return this.$apollo
|
||||
.mutate(mutationPayload)
|
||||
.then(res => this.onUploadDesignDone(res))
|
||||
.catch(() => this.onUploadDesignError());
|
||||
},
|
||||
afterUploadDesign(
|
||||
store,
|
||||
{
|
||||
data: { designManagementUpload },
|
||||
},
|
||||
) {
|
||||
updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody);
|
||||
},
|
||||
onUploadDesignDone(res) {
|
||||
const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
|
||||
const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
|
||||
if (skippedWarningMessage) {
|
||||
createFlash(skippedWarningMessage, 'warning');
|
||||
}
|
||||
|
||||
// if this upload resulted in a new version being created, redirect user to the latest version
|
||||
if (!this.isLatestVersion) {
|
||||
this.$router.push({ name: DESIGNS_ROUTE_NAME });
|
||||
}
|
||||
this.resetFilesToBeSaved();
|
||||
},
|
||||
onUploadDesignError() {
|
||||
this.resetFilesToBeSaved();
|
||||
createFlash(UPLOAD_DESIGN_ERROR);
|
||||
},
|
||||
changeSelectedDesigns(filename) {
|
||||
if (this.isDesignSelected(filename)) {
|
||||
this.selectedDesigns = this.selectedDesigns.filter(design => design !== filename);
|
||||
} else {
|
||||
this.selectedDesigns.push(filename);
|
||||
}
|
||||
},
|
||||
toggleDesignsSelection() {
|
||||
if (this.hasSelectedDesigns) {
|
||||
this.selectedDesigns = [];
|
||||
} else {
|
||||
this.selectedDesigns = this.designs.map(design => design.filename);
|
||||
}
|
||||
},
|
||||
isDesignSelected(filename) {
|
||||
return this.selectedDesigns.includes(filename);
|
||||
},
|
||||
isDesignToBeSaved(filename) {
|
||||
return this.filesToBeSaved.some(file => file.name === filename);
|
||||
},
|
||||
canSelectDesign(filename) {
|
||||
return this.isLatestVersion && this.canCreateDesign && !this.isDesignToBeSaved(filename);
|
||||
},
|
||||
onDesignDelete() {
|
||||
this.selectedDesigns = [];
|
||||
if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME });
|
||||
},
|
||||
onDesignDeleteError() {
|
||||
const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 });
|
||||
createFlash(errorMessage);
|
||||
},
|
||||
onExistingDesignDropzoneChange(files, existingDesignFilename) {
|
||||
const filesArr = Array.from(files);
|
||||
|
||||
if (filesArr.length > 1) {
|
||||
createFlash(EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filesArr.some(({ name }) => existingDesignFilename === name)) {
|
||||
createFlash(EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
this.onUploadDesign(files);
|
||||
},
|
||||
onDesignPaste(event) {
|
||||
const { clipboardData } = event;
|
||||
const files = Array.from(clipboardData.files);
|
||||
if (clipboardData && files.length > 0) {
|
||||
if (!files.some(isValidDesignFile)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
let filename = getFilename(event);
|
||||
if (!filename || filename === 'image.png') {
|
||||
filename = `design_${Date.now()}.png`;
|
||||
}
|
||||
const newFile = new File([files[0]], filename);
|
||||
this.onUploadDesign([newFile]);
|
||||
}
|
||||
},
|
||||
toggleOnPasteListener(route) {
|
||||
if (route === DESIGNS_ROUTE_NAME) {
|
||||
document.addEventListener('paste', this.onDesignPaste);
|
||||
} else {
|
||||
document.removeEventListener('paste', this.onDesignPaste);
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeRouteUpdate(to, from, next) {
|
||||
this.toggleOnPasteListener(to.name);
|
||||
this.selectedDesigns = [];
|
||||
next();
|
||||
},
|
||||
beforeRouteLeave(to, from, next) {
|
||||
this.toggleOnPasteListener(to.name);
|
||||
next();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex">
|
||||
<div class="d-flex justify-content-between align-items-center w-100">
|
||||
<design-version-dropdown />
|
||||
<div :class="['qa-selector-toolbar', { 'd-flex': hasDesigns, 'd-none': !hasDesigns }]">
|
||||
<gl-deprecated-button
|
||||
v-if="isLatestVersion"
|
||||
variant="link"
|
||||
class="mr-2 js-select-all"
|
||||
@click="toggleDesignsSelection"
|
||||
>{{ selectAllButtonText }}</gl-deprecated-button
|
||||
>
|
||||
<design-destroyer
|
||||
#default="{ mutate, loading }"
|
||||
:filenames="selectedDesigns"
|
||||
:project-path="projectPath"
|
||||
:iid="issueIid"
|
||||
@done="onDesignDelete"
|
||||
@error="onDesignDeleteError"
|
||||
>
|
||||
<delete-button
|
||||
v-if="isLatestVersion"
|
||||
:is-deleting="loading"
|
||||
button-class="btn-danger btn-inverted mr-2"
|
||||
:has-selected-designs="hasSelectedDesigns"
|
||||
@deleteSelectedDesigns="mutate()"
|
||||
>
|
||||
{{ s__('DesignManagement|Delete selected') }}
|
||||
<gl-loading-icon v-if="loading" inline class="ml-1" />
|
||||
</delete-button>
|
||||
</design-destroyer>
|
||||
<upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="mt-4">
|
||||
<gl-loading-icon v-if="isLoading" size="md" />
|
||||
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
|
||||
{{ __('An error occurred while loading designs. Please try again.') }}
|
||||
</gl-alert>
|
||||
<ol v-else class="list-unstyled row">
|
||||
<li class="col-md-6 col-lg-4 mb-3">
|
||||
<design-dropzone class="design-list-item" @change="onUploadDesign" />
|
||||
</li>
|
||||
<li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-4 mb-3">
|
||||
<design-dropzone @change="onExistingDesignDropzoneChange($event, design.filename)"
|
||||
><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)"
|
||||
/></design-dropzone>
|
||||
|
||||
<input
|
||||
v-if="canSelectDesign(design.filename)"
|
||||
:checked="isDesignSelected(design.filename)"
|
||||
type="checkbox"
|
||||
class="design-checkbox"
|
||||
@change="changeSelectedDesigns(design.filename)"
|
||||
/>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<router-view :key="$route.fullPath" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export const ROOT_ROUTE_NAME = 'root';
|
||||
export const DESIGNS_ROUTE_NAME = 'designs';
|
||||
export const DESIGN_ROUTE_NAME = 'design';
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import $ from 'jquery';
|
||||
import Vue from 'vue';
|
||||
import VueRouter from 'vue-router';
|
||||
import routes from './routes';
|
||||
import { DESIGN_ROUTE_NAME } from './constants';
|
||||
import { getPageLayoutElement } from '~/design_management_new/utils/design_management_utils';
|
||||
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants';
|
||||
|
||||
Vue.use(VueRouter);
|
||||
|
||||
export default function createRouter(base) {
|
||||
const router = new VueRouter({
|
||||
base,
|
||||
mode: 'history',
|
||||
routes,
|
||||
});
|
||||
const pageEl = getPageLayoutElement();
|
||||
|
||||
router.beforeEach(({ meta: { el }, name }, _, next) => {
|
||||
$(`#${el}`).tab('show');
|
||||
|
||||
// apply a fullscreen layout style in Design View (a.k.a design detail)
|
||||
if (pageEl) {
|
||||
if (name === DESIGN_ROUTE_NAME) {
|
||||
pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
|
||||
} else {
|
||||
pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
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 }),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
/* eslint-disable @gitlab/require-i18n-strings */
|
||||
|
||||
import createFlash from '~/flash';
|
||||
import { extractCurrentDiscussion, extractDesign } from './design_management_utils';
|
||||
import {
|
||||
ADD_IMAGE_DIFF_NOTE_ERROR,
|
||||
UPDATE_IMAGE_DIFF_NOTE_ERROR,
|
||||
ADD_DISCUSSION_COMMENT_ERROR,
|
||||
designDeletionError,
|
||||
} from './error_messages';
|
||||
|
||||
const deleteDesignsFromStore = (store, query, selectedDesigns) => {
|
||||
const data = store.readQuery(query);
|
||||
|
||||
const changedDesigns = data.project.issue.designCollection.designs.edges.filter(
|
||||
({ node }) => !selectedDesigns.includes(node.filename),
|
||||
);
|
||||
data.project.issue.designCollection.designs.edges = [...changedDesigns];
|
||||
|
||||
store.writeQuery({
|
||||
...query,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new version of designs to store
|
||||
*
|
||||
* @param {Object} store
|
||||
* @param {Object} query
|
||||
* @param {Object} version
|
||||
*/
|
||||
const addNewVersionToStore = (store, query, version) => {
|
||||
if (!version) return;
|
||||
|
||||
const data = store.readQuery(query);
|
||||
const newEdge = { node: version, __typename: 'DesignVersionEdge' };
|
||||
|
||||
data.project.issue.designCollection.versions.edges = [
|
||||
newEdge,
|
||||
...data.project.issue.designCollection.versions.edges,
|
||||
];
|
||||
|
||||
store.writeQuery({
|
||||
...query,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
const addDiscussionCommentToStore = (store, createNote, query, queryVariables, discussionId) => {
|
||||
const data = store.readQuery({
|
||||
query,
|
||||
variables: queryVariables,
|
||||
});
|
||||
|
||||
const design = extractDesign(data);
|
||||
const currentDiscussion = extractCurrentDiscussion(design.discussions, discussionId);
|
||||
currentDiscussion.notes.nodes = [...currentDiscussion.notes.nodes, createNote.note];
|
||||
|
||||
design.notesCount += 1;
|
||||
if (
|
||||
!design.issue.participants.edges.some(
|
||||
participant => participant.node.username === createNote.note.author.username,
|
||||
)
|
||||
) {
|
||||
design.issue.participants.edges = [
|
||||
...design.issue.participants.edges,
|
||||
{
|
||||
__typename: 'UserEdge',
|
||||
node: {
|
||||
__typename: 'User',
|
||||
...createNote.note.author,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
store.writeQuery({
|
||||
query,
|
||||
variables: queryVariables,
|
||||
data: {
|
||||
...data,
|
||||
design: {
|
||||
...design,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) => {
|
||||
const data = store.readQuery({
|
||||
query,
|
||||
variables,
|
||||
});
|
||||
const newDiscussion = {
|
||||
__typename: 'Discussion',
|
||||
id: createImageDiffNote.note.discussion.id,
|
||||
replyId: createImageDiffNote.note.discussion.replyId,
|
||||
resolvable: true,
|
||||
resolved: false,
|
||||
resolvedAt: null,
|
||||
resolvedBy: null,
|
||||
notes: {
|
||||
__typename: 'NoteConnection',
|
||||
nodes: [createImageDiffNote.note],
|
||||
},
|
||||
};
|
||||
const design = extractDesign(data);
|
||||
const notesCount = design.notesCount + 1;
|
||||
design.discussions.nodes = [...design.discussions.nodes, newDiscussion];
|
||||
if (
|
||||
!design.issue.participants.edges.some(
|
||||
participant => participant.node.username === createImageDiffNote.note.author.username,
|
||||
)
|
||||
) {
|
||||
design.issue.participants.edges = [
|
||||
...design.issue.participants.edges,
|
||||
{
|
||||
__typename: 'UserEdge',
|
||||
node: {
|
||||
__typename: 'User',
|
||||
...createImageDiffNote.note.author,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
store.writeQuery({
|
||||
query,
|
||||
variables,
|
||||
data: {
|
||||
...data,
|
||||
design: {
|
||||
...design,
|
||||
notesCount,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => {
|
||||
const data = store.readQuery({
|
||||
query,
|
||||
variables,
|
||||
});
|
||||
|
||||
const design = extractDesign(data);
|
||||
const discussion = extractCurrentDiscussion(
|
||||
design.discussions,
|
||||
updateImageDiffNote.note.discussion.id,
|
||||
);
|
||||
|
||||
discussion.notes = {
|
||||
...discussion.notes,
|
||||
nodes: [updateImageDiffNote.note, ...discussion.notes.nodes.slice(1)],
|
||||
};
|
||||
|
||||
store.writeQuery({
|
||||
query,
|
||||
variables,
|
||||
data: {
|
||||
...data,
|
||||
design,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const addNewDesignToStore = (store, designManagementUpload, query) => {
|
||||
const data = store.readQuery(query);
|
||||
|
||||
const newDesigns = data.project.issue.designCollection.designs.edges.reduce((acc, design) => {
|
||||
if (!acc.find(d => d.filename === design.node.filename)) {
|
||||
acc.push(design.node);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, designManagementUpload.designs);
|
||||
|
||||
let newVersionNode;
|
||||
const findNewVersions = designManagementUpload.designs.find(design => design.versions);
|
||||
|
||||
if (findNewVersions) {
|
||||
const findNewVersionsEdges = findNewVersions.versions.edges;
|
||||
|
||||
if (findNewVersionsEdges && findNewVersionsEdges.length) {
|
||||
newVersionNode = [findNewVersionsEdges[0]];
|
||||
}
|
||||
}
|
||||
|
||||
const newVersions = [
|
||||
...(newVersionNode || []),
|
||||
...data.project.issue.designCollection.versions.edges,
|
||||
];
|
||||
|
||||
const updatedDesigns = {
|
||||
__typename: 'DesignCollection',
|
||||
designs: {
|
||||
__typename: 'DesignConnection',
|
||||
edges: newDesigns.map(design => ({
|
||||
__typename: 'DesignEdge',
|
||||
node: design,
|
||||
})),
|
||||
},
|
||||
versions: {
|
||||
__typename: 'DesignVersionConnection',
|
||||
edges: newVersions,
|
||||
},
|
||||
};
|
||||
|
||||
data.project.issue.designCollection = updatedDesigns;
|
||||
|
||||
store.writeQuery({
|
||||
...query,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
const onError = (data, message) => {
|
||||
createFlash(message);
|
||||
throw new Error(data.errors);
|
||||
};
|
||||
|
||||
export const hasErrors = ({ errors = [] }) => errors?.length;
|
||||
|
||||
/**
|
||||
* Updates a store after design deletion
|
||||
*
|
||||
* @param {Object} store
|
||||
* @param {Object} data
|
||||
* @param {Object} query
|
||||
* @param {Array} designs
|
||||
*/
|
||||
export const updateStoreAfterDesignsDelete = (store, data, query, designs) => {
|
||||
if (hasErrors(data)) {
|
||||
onError(data, designDeletionError({ singular: designs.length === 1 }));
|
||||
} else {
|
||||
deleteDesignsFromStore(store, query, designs);
|
||||
addNewVersionToStore(store, query, data.version);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateStoreAfterAddDiscussionComment = (
|
||||
store,
|
||||
data,
|
||||
query,
|
||||
queryVariables,
|
||||
discussionId,
|
||||
) => {
|
||||
if (hasErrors(data)) {
|
||||
onError(data, ADD_DISCUSSION_COMMENT_ERROR);
|
||||
} else {
|
||||
addDiscussionCommentToStore(store, data, query, queryVariables, discussionId);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateStoreAfterAddImageDiffNote = (store, data, query, queryVariables) => {
|
||||
if (hasErrors(data)) {
|
||||
onError(data, ADD_IMAGE_DIFF_NOTE_ERROR);
|
||||
} else {
|
||||
addImageDiffNoteToStore(store, data, query, queryVariables);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateStoreAfterUpdateImageDiffNote = (store, data, query, queryVariables) => {
|
||||
if (hasErrors(data)) {
|
||||
onError(data, UPDATE_IMAGE_DIFF_NOTE_ERROR);
|
||||
} else {
|
||||
updateImageDiffNoteInStore(store, data, query, queryVariables);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateStoreAfterUploadDesign = (store, data, query) => {
|
||||
if (hasErrors(data)) {
|
||||
onError(data, data.errors[0]);
|
||||
} else {
|
||||
addNewDesignToStore(store, data, query);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
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');
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
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);
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import Tracking from '~/tracking';
|
||||
|
||||
function assembleDesignPayload(payloadArr) {
|
||||
return {
|
||||
value: {
|
||||
'internal-object-refrerer': payloadArr[0],
|
||||
'design-collection-owner': payloadArr[1],
|
||||
'design-version-number': payloadArr[2],
|
||||
'design-is-current-version': payloadArr[3],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Tracking Constants
|
||||
const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function trackDesignDetailView(
|
||||
referer = '',
|
||||
owner = '',
|
||||
designVersion = 1,
|
||||
latestVersion = false,
|
||||
) {
|
||||
Tracking.event(DESIGN_TRACKING_PAGE_NAME, 'design_viewed', {
|
||||
label: 'design_viewed',
|
||||
...assembleDesignPayload([referer, owner, designVersion, latestVersion]),
|
||||
});
|
||||
}
|
||||
|
|
@ -13,14 +13,15 @@ export default function() {
|
|||
initSentryErrorStackTraceApp();
|
||||
initRelatedMergeRequestsApp();
|
||||
|
||||
// .js-design-management is currently EE-only.
|
||||
// This will be moved to CE as part of https://gitlab.com/gitlab-org/gitlab/-/issues/212566#frontend
|
||||
// at which point this conditional can be removed.
|
||||
if (document.querySelector('.js-design-management')) {
|
||||
import(/* webpackChunkName: 'design_management' */ '~/design_management')
|
||||
.then(module => module.default())
|
||||
.catch(() => {});
|
||||
}
|
||||
import(/* webpackChunkName: 'design_management' */ '~/design_management')
|
||||
.then(module => module.default())
|
||||
.catch(() => {});
|
||||
|
||||
// This will be removed when we remove the `design_management_moved` feature flag
|
||||
// See https://gitlab.com/gitlab-org/gitlab/-/issues/223197
|
||||
import(/* webpackChunkName: 'design_management' */ '~/design_management_new')
|
||||
.then(module => module.default())
|
||||
.catch(() => {});
|
||||
|
||||
new Issue(); // eslint-disable-line no-new
|
||||
new ShortcutsIssuable(); // eslint-disable-line no-new
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
- if @project.design_management_enabled?
|
||||
.js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
|
||||
- if Feature.enabled?(:design_management_moved, @project)
|
||||
.js-design-management-new{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
|
||||
- else
|
||||
.js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
|
||||
- else
|
||||
.mt-4
|
||||
.row.empty-state
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
= _('Milestone')
|
||||
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
|
||||
- if can_edit_issuable
|
||||
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" }
|
||||
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_milestone_link", track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" }
|
||||
.value.hide-collapsed
|
||||
- if milestone.present?
|
||||
= link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link', qa_title: milestone[:title] }
|
||||
|
|
@ -107,7 +107,7 @@
|
|||
= _('Labels')
|
||||
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
|
||||
- if can_edit_issuable
|
||||
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link qa-edit-link-labels float-right', data: { track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" }
|
||||
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_labels_link", track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" }
|
||||
.value.issuable-show-labels.dont-hide.hide-collapsed{ class: ("has-labels" if selected_labels.any?), data: { qa_selector: 'labels_block' } }
|
||||
- if selected_labels.any?
|
||||
- selected_labels.each do |label_hash|
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Allow files with .md.erb extension for the Static Site Editor
|
||||
merge_request: 35136
|
||||
author:
|
||||
type: added
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add project_key column to jira_tracker_data table
|
||||
merge_request: 34949
|
||||
author:
|
||||
type: other
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Use CodeQuality 0.85.10 in the CI template
|
||||
merge_request: 34329
|
||||
author:
|
||||
type: changed
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddProjectKeyToJiraTrackerData < ActiveRecord::Migration[6.0]
|
||||
DOWNTIME = false
|
||||
|
||||
# rubocop:disable Migration/AddLimitToTextColumns
|
||||
# limit is added in 20200619154528_add_text_limit_to_jira_tracker_data_project_key
|
||||
def change
|
||||
add_column :jira_tracker_data, :project_key, :text
|
||||
end
|
||||
# rubocop:enable Migration/AddLimitToTextColumns
|
||||
end
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddTextLimitToJiraTrackerDataProjectKey < ActiveRecord::Migration[6.0]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_text_limit :jira_tracker_data, :project_key, 255
|
||||
end
|
||||
|
||||
def down
|
||||
remove_text_limit :jira_tracker_data, :project_key
|
||||
end
|
||||
end
|
||||
|
|
@ -3655,7 +3655,9 @@ CREATE TABLE public.jira_tracker_data (
|
|||
encrypted_username_iv character varying,
|
||||
encrypted_password character varying,
|
||||
encrypted_password_iv character varying,
|
||||
jira_issue_transition_id character varying
|
||||
jira_issue_transition_id character varying,
|
||||
project_key text,
|
||||
CONSTRAINT check_214cf6a48b CHECK ((char_length(project_key) <= 255))
|
||||
);
|
||||
|
||||
CREATE SEQUENCE public.jira_tracker_data_id_seq
|
||||
|
|
@ -14113,6 +14115,8 @@ COPY "schema_migrations" (version) FROM STDIN;
|
|||
20200618105638
|
||||
20200618134223
|
||||
20200618134723
|
||||
20200619154527
|
||||
20200619154528
|
||||
20200622103836
|
||||
20200622235737
|
||||
20200623000148
|
||||
|
|
|
|||
|
|
@ -526,14 +526,14 @@ To enable it:
|
|||
|
||||
```ruby
|
||||
# Instance-wide
|
||||
Feature.enable(:batched_suggestions)
|
||||
Feature.enable(:batch_suggestions)
|
||||
```
|
||||
|
||||
To disable it:
|
||||
|
||||
```ruby
|
||||
# Instance-wide
|
||||
Feature.disable(:batched_suggestions)
|
||||
Feature.disable(:batch_suggestions)
|
||||
```
|
||||
|
||||
## Start a thread by replying to a standard comment
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ code_quality:
|
|||
variables:
|
||||
DOCKER_DRIVER: overlay2
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
CODE_QUALITY_IMAGE: "registry.gitlab.com/gitlab-org/ci-cd/codequality:0.85.9"
|
||||
CODE_QUALITY_IMAGE: "registry.gitlab.com/gitlab-org/ci-cd/codequality:0.85.10"
|
||||
script:
|
||||
- |
|
||||
if ! docker info &>/dev/null; then
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
module Gitlab
|
||||
module StaticSiteEditor
|
||||
class Config
|
||||
SUPPORTED_EXTENSIONS = %w[.md].freeze
|
||||
SUPPORTED_EXTENSIONS = %w[.md .md.erb].freeze
|
||||
|
||||
def initialize(repository, ref, file_path, return_url)
|
||||
@repository = repository
|
||||
|
|
@ -42,11 +42,11 @@ module Gitlab
|
|||
end
|
||||
|
||||
def extension_supported?
|
||||
File.extname(file_path).in?(SUPPORTED_EXTENSIONS)
|
||||
SUPPORTED_EXTENSIONS.any? { |ext| file_path.end_with?(ext) }
|
||||
end
|
||||
|
||||
def file_exists?
|
||||
commit_id.present? && repository.blob_at(commit_id, file_path).present?
|
||||
commit_id.present? && !repository.blob_at(commit_id, file_path).nil?
|
||||
end
|
||||
|
||||
def full_path
|
||||
|
|
|
|||
|
|
@ -25,13 +25,27 @@ module QA
|
|||
base.view 'app/views/shared/issuable/_sidebar.html.haml' do
|
||||
element :assignee_block
|
||||
element :dropdown_menu_labels
|
||||
element :edit_link_labels
|
||||
element :edit_labels_link
|
||||
element :edit_milestone_link
|
||||
element :labels_block
|
||||
element :milestone_block
|
||||
element :milestone_link
|
||||
end
|
||||
end
|
||||
|
||||
def assign_milestone(milestone)
|
||||
click_element(:edit_milestone_link)
|
||||
within_element(:milestone_block) do
|
||||
click_link("#{milestone.title}")
|
||||
end
|
||||
|
||||
wait_until(reload: false) do
|
||||
has_element?(:milestone_block, text: milestone.title, wait: 0)
|
||||
end
|
||||
|
||||
refresh
|
||||
end
|
||||
|
||||
def click_milestone_link
|
||||
click_element(:milestone_link)
|
||||
end
|
||||
|
|
@ -55,7 +69,7 @@ module QA
|
|||
end
|
||||
|
||||
def has_milestone?(milestone_title)
|
||||
within_element(:milestone_block) do
|
||||
wait_milestone_block_finish_loading do
|
||||
has_element?(:milestone_link, title: milestone_title)
|
||||
end
|
||||
end
|
||||
|
|
@ -66,7 +80,7 @@ module QA
|
|||
|
||||
def select_labels_and_refresh(labels)
|
||||
Support::Retrier.retry_until do
|
||||
click_element(:edit_link_labels)
|
||||
click_element(:edit_labels_link)
|
||||
has_element?(:dropdown_menu_labels, text: labels.first)
|
||||
end
|
||||
|
||||
|
|
@ -76,7 +90,7 @@ module QA
|
|||
end
|
||||
end
|
||||
|
||||
click_element(:edit_link_labels)
|
||||
click_element(:edit_labels_link)
|
||||
|
||||
labels.each do |label|
|
||||
has_element?(:labels_block, text: label, wait: 0)
|
||||
|
|
@ -103,6 +117,15 @@ module QA
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
def wait_milestone_block_finish_loading
|
||||
within_element(:milestone_block) do
|
||||
wait_until(reload: false, max_duration: 10, sleep_interval: 1) do
|
||||
finished_loading_block?
|
||||
yield
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ module QA
|
|||
Page::Project::Issue::New.perform do |new_page|
|
||||
new_page.fill_title(@title)
|
||||
new_page.fill_description(@description)
|
||||
new_page.choose_milestone(@milestone) if @milestone
|
||||
new_page.create_new_issue
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module QA
|
||||
context 'Plan' do
|
||||
describe 'Project milestone' do
|
||||
include Support::Dates
|
||||
|
||||
let(:title) { 'Project milestone' }
|
||||
let(:start_date) { current_date_yyyy_mm_dd }
|
||||
let(:due_date) { next_month_yyyy_mm_dd }
|
||||
|
||||
let(:project) do
|
||||
Resource::Project.fabricate_via_api! do |project|
|
||||
project.name = 'project-to-test-milestones'
|
||||
end
|
||||
end
|
||||
|
||||
let(:issue) do
|
||||
Resource::Issue.fabricate_via_api! do |issue|
|
||||
issue.project = project
|
||||
end
|
||||
end
|
||||
|
||||
let(:project_milestone) do
|
||||
Resource::ProjectMilestone.fabricate_via_api! do |milestone|
|
||||
milestone.project = project
|
||||
milestone.start_date = start_date
|
||||
milestone.due_date = due_date
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
Flow::Login.sign_in
|
||||
end
|
||||
|
||||
it 'assigns a project milestone to an existing issue' do
|
||||
issue.visit!
|
||||
|
||||
Page::Project::Issue::Show.perform do |existing_issue|
|
||||
existing_issue.assign_milestone(project_milestone)
|
||||
|
||||
expect(existing_issue).to have_milestone(project_milestone.title)
|
||||
end
|
||||
end
|
||||
|
||||
it 'assigns a project milestone to a new issue' do
|
||||
Resource::Issue.fabricate_via_browser_ui! do |issue|
|
||||
issue.project = project
|
||||
issue.milestone = project_milestone
|
||||
end
|
||||
|
||||
Page::Project::Issue::Show.perform do |issue|
|
||||
expect(issue).to have_milestone(project_milestone.title)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -27,6 +27,10 @@ RSpec.describe 'viewing issues with design references' do
|
|||
MD
|
||||
end
|
||||
|
||||
before do
|
||||
stub_feature_flags(design_management_moved: false)
|
||||
end
|
||||
|
||||
def visit_page_with_design_references
|
||||
public_issue = create(:issue, project: public_project, description: description)
|
||||
visit project_issue_path(public_issue.project, public_issue)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ RSpec.describe 'User paginates issue designs', :js do
|
|||
|
||||
before do
|
||||
enable_design_management
|
||||
stub_feature_flags(design_management_moved: false)
|
||||
|
||||
create_list(:design, 2, :with_file, issue: issue)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ RSpec.describe 'User design permissions', :js do
|
|||
|
||||
before do
|
||||
enable_design_management
|
||||
stub_feature_flags(design_management_moved: false)
|
||||
|
||||
visit project_issue_path(project, issue)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ RSpec.describe 'User uploads new design', :js do
|
|||
context "when the feature is available" do
|
||||
before do
|
||||
enable_design_management
|
||||
stub_feature_flags(design_management_moved: false)
|
||||
|
||||
visit project_issue_path(project, issue)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ RSpec.describe 'Users views raw design image files' do
|
|||
|
||||
before do
|
||||
enable_design_management
|
||||
stub_feature_flags(design_management_moved: false)
|
||||
end
|
||||
|
||||
it 'serves the latest design version when no ref is given' do
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ RSpec.describe 'User views issue designs', :js do
|
|||
|
||||
before do
|
||||
enable_design_management
|
||||
stub_feature_flags(design_management_moved: false)
|
||||
|
||||
visit project_issue_path(project, issue)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ RSpec.describe 'User views issue designs', :js do
|
|||
|
||||
before do
|
||||
enable_design_management
|
||||
stub_feature_flags(design_management_moved: false)
|
||||
end
|
||||
|
||||
context 'navigates from the issue view' do
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ RSpec.describe 'User views an SVG design that contains XSS', :js do
|
|||
|
||||
before do
|
||||
enable_design_management
|
||||
stub_feature_flags(design_management_moved: false)
|
||||
|
||||
visit designs_project_issue_path(
|
||||
project,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Design note pin component should match the snapshot of note when repositioning 1`] = `
|
||||
<button
|
||||
aria-label="Comment form position"
|
||||
class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center btn-transparent comment-indicator"
|
||||
style="left: 10px; top: 10px; cursor: move;"
|
||||
type="button"
|
||||
>
|
||||
<icon-stub
|
||||
name="image-comment-dark"
|
||||
size="16"
|
||||
/>
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`Design note pin component should match the snapshot of note with index 1`] = `
|
||||
<button
|
||||
aria-label="Comment '1' position"
|
||||
class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center js-image-badge badge badge-pill"
|
||||
style="left: 10px; top: 10px;"
|
||||
type="button"
|
||||
>
|
||||
|
||||
1
|
||||
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`Design note pin component should match the snapshot of note without index 1`] = `
|
||||
<button
|
||||
aria-label="Comment form position"
|
||||
class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center btn-transparent comment-indicator"
|
||||
style="left: 10px; top: 10px;"
|
||||
type="button"
|
||||
>
|
||||
<icon-stub
|
||||
name="image-comment-dark"
|
||||
size="16"
|
||||
/>
|
||||
</button>
|
||||
`;
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
// 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>
|
||||
`;
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
// 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>
|
||||
`;
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
// 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>
|
||||
`;
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui';
|
||||
import BatchDeleteButton from '~/design_management_new/components/delete_button.vue';
|
||||
|
||||
describe('Batch delete button component', () => {
|
||||
let wrapper;
|
||||
|
||||
const findButton = () => wrapper.find(GlDeprecatedButton);
|
||||
const findModal = () => wrapper.find(GlModal);
|
||||
|
||||
function createComponent(isDeleting = false) {
|
||||
wrapper = shallowMount(BatchDeleteButton, {
|
||||
propsData: {
|
||||
isDeleting,
|
||||
},
|
||||
directives: {
|
||||
GlModalDirective,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders non-disabled button by default', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findButton().exists()).toBe(true);
|
||||
expect(findButton().attributes('disabled')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('renders disabled button when design is deleting', () => {
|
||||
createComponent(true);
|
||||
expect(findButton().attributes('disabled')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits `deleteSelectedDesigns` event on modal ok click', () => {
|
||||
createComponent();
|
||||
findButton().vm.$emit('click');
|
||||
return wrapper.vm
|
||||
.$nextTick()
|
||||
.then(() => {
|
||||
findModal().vm.$emit('ok');
|
||||
return wrapper.vm.$nextTick();
|
||||
})
|
||||
.then(() => {
|
||||
expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import DesignNotePin from '~/design_management_new/components/design_note_pin.vue';
|
||||
|
||||
describe('Design note pin component', () => {
|
||||
let wrapper;
|
||||
|
||||
function createComponent(propsData = {}) {
|
||||
wrapper = shallowMount(DesignNotePin, {
|
||||
propsData: {
|
||||
position: {
|
||||
left: '10px',
|
||||
top: '10px',
|
||||
},
|
||||
...propsData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('should match the snapshot of note without index', () => {
|
||||
createComponent();
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match the snapshot of note with index', () => {
|
||||
createComponent({ label: 1 });
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match the snapshot of note when repositioning', () => {
|
||||
createComponent({ repositioning: true });
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('pinStyle', () => {
|
||||
it('sets cursor to `move` when repositioning = true', () => {
|
||||
createComponent({ repositioning: true });
|
||||
expect(wrapper.vm.pinStyle.cursor).toBe('move');
|
||||
});
|
||||
|
||||
it('does not set cursor when repositioning = false', () => {
|
||||
createComponent();
|
||||
expect(wrapper.vm.pinStyle.cursor).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
// 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>
|
||||
`;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// 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>"
|
||||
`;
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import { GlLoadingIcon } from '@gitlab/ui';
|
||||
import notes from '../../mock_data/notes';
|
||||
import DesignDiscussion from '~/design_management_new/components/design_notes/design_discussion.vue';
|
||||
import DesignNote from '~/design_management_new/components/design_notes/design_note.vue';
|
||||
import DesignReplyForm from '~/design_management_new/components/design_notes/design_reply_form.vue';
|
||||
import createNoteMutation from '~/design_management_new/graphql/mutations/create_note.mutation.graphql';
|
||||
import toggleResolveDiscussionMutation from '~/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql';
|
||||
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
|
||||
import ToggleRepliesWidget from '~/design_management_new/components/design_notes/toggle_replies_widget.vue';
|
||||
|
||||
const discussion = {
|
||||
id: '0',
|
||||
resolved: false,
|
||||
resolvable: true,
|
||||
notes,
|
||||
};
|
||||
|
||||
describe('Design discussions component', () => {
|
||||
let wrapper;
|
||||
|
||||
const findDesignNotes = () => wrapper.findAll(DesignNote);
|
||||
const findReplyPlaceholder = () => wrapper.find(ReplyPlaceholder);
|
||||
const findReplyForm = () => wrapper.find(DesignReplyForm);
|
||||
const findRepliesWidget = () => wrapper.find(ToggleRepliesWidget);
|
||||
const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]');
|
||||
const findResolveIcon = () => wrapper.find('[data-testid="resolve-icon"]');
|
||||
const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]');
|
||||
const findResolveLoadingIcon = () => wrapper.find(GlLoadingIcon);
|
||||
const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]');
|
||||
|
||||
const mutationVariables = {
|
||||
mutation: createNoteMutation,
|
||||
update: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
noteableId: 'noteable-id',
|
||||
body: 'test',
|
||||
discussionId: '0',
|
||||
},
|
||||
},
|
||||
};
|
||||
const mutate = jest.fn(() => Promise.resolve());
|
||||
const $apollo = {
|
||||
mutate,
|
||||
};
|
||||
|
||||
function createComponent(props = {}, data = {}) {
|
||||
wrapper = mount(DesignDiscussion, {
|
||||
propsData: {
|
||||
resolvedDiscussionsExpanded: true,
|
||||
discussion,
|
||||
noteableId: 'noteable-id',
|
||||
designId: 'design-id',
|
||||
discussionIndex: 1,
|
||||
discussionWithOpenForm: '',
|
||||
...props,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
...data,
|
||||
};
|
||||
},
|
||||
mocks: {
|
||||
$apollo,
|
||||
$route: {
|
||||
hash: '#note_1',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('when discussion is not resolvable', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({
|
||||
discussion: {
|
||||
...discussion,
|
||||
resolvable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render an icon to resolve a thread', () => {
|
||||
expect(findResolveIcon().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render a checkbox in reply form', () => {
|
||||
findReplyPlaceholder().vm.$emit('onMouseDown');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findResolveCheckbox().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when discussion is unresolved', () => {
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('renders correct amount of discussion notes', () => {
|
||||
expect(findDesignNotes()).toHaveLength(2);
|
||||
expect(findDesignNotes().wrappers.every(w => w.isVisible())).toBe(true);
|
||||
});
|
||||
|
||||
it('renders reply placeholder', () => {
|
||||
expect(findReplyPlaceholder().isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render toggle replies widget', () => {
|
||||
expect(findRepliesWidget().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders a correct icon to resolve a thread', () => {
|
||||
expect(findResolveIcon().props('name')).toBe('check-circle');
|
||||
});
|
||||
|
||||
it('renders a checkbox with Resolve thread text in reply form', () => {
|
||||
findReplyPlaceholder().vm.$emit('onClick');
|
||||
wrapper.setProps({ discussionWithOpenForm: discussion.id });
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findResolveCheckbox().text()).toBe('Resolve thread');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render resolved message', () => {
|
||||
expect(findResolvedMessage().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when discussion is resolved', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({
|
||||
discussion: {
|
||||
...discussion,
|
||||
resolved: true,
|
||||
resolvedBy: notes[0].author,
|
||||
resolvedAt: '2020-05-08T07:10:45Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only the first note', () => {
|
||||
expect(
|
||||
findDesignNotes()
|
||||
.at(0)
|
||||
.isVisible(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
findDesignNotes()
|
||||
.at(1)
|
||||
.isVisible(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('renders resolved message', () => {
|
||||
expect(findResolvedMessage().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not show renders reply placeholder', () => {
|
||||
expect(findReplyPlaceholder().isVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders toggle replies widget with correct props', () => {
|
||||
expect(findRepliesWidget().exists()).toBe(true);
|
||||
expect(findRepliesWidget().props()).toEqual({
|
||||
collapsed: true,
|
||||
replies: notes.slice(1),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a correct icon to resolve a thread', () => {
|
||||
expect(findResolveIcon().props('name')).toBe('check-circle-filled');
|
||||
});
|
||||
|
||||
describe('when replies are expanded', () => {
|
||||
beforeEach(() => {
|
||||
findRepliesWidget().vm.$emit('toggle');
|
||||
return wrapper.vm.$nextTick();
|
||||
});
|
||||
|
||||
it('renders replies widget with collapsed prop equal to false', () => {
|
||||
expect(findRepliesWidget().props('collapsed')).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the second note', () => {
|
||||
expect(
|
||||
findDesignNotes()
|
||||
.at(1)
|
||||
.isVisible(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('renders a reply placeholder', () => {
|
||||
expect(findReplyPlaceholder().isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders a checkbox with Unresolve thread text in reply form', () => {
|
||||
findReplyPlaceholder().vm.$emit('onClick');
|
||||
wrapper.setProps({ discussionWithOpenForm: discussion.id });
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findResolveCheckbox().text()).toBe('Unresolve thread');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('hides reply placeholder and opens form on placeholder click', () => {
|
||||
createComponent();
|
||||
findReplyPlaceholder().vm.$emit('onClick');
|
||||
wrapper.setProps({ discussionWithOpenForm: discussion.id });
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findReplyPlaceholder().exists()).toBe(false);
|
||||
expect(findReplyForm().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls mutation on submitting form and closes the form', () => {
|
||||
createComponent(
|
||||
{ discussionWithOpenForm: discussion.id },
|
||||
{ discussionComment: 'test', isFormRendered: true },
|
||||
);
|
||||
|
||||
findReplyForm().vm.$emit('submitForm');
|
||||
expect(mutate).toHaveBeenCalledWith(mutationVariables);
|
||||
|
||||
return mutate()
|
||||
.then(() => {
|
||||
return wrapper.vm.$nextTick();
|
||||
})
|
||||
.then(() => {
|
||||
expect(findReplyForm().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the discussion comment on closing comment form', () => {
|
||||
createComponent(
|
||||
{ discussionWithOpenForm: discussion.id },
|
||||
{ discussionComment: 'test', isFormRendered: true },
|
||||
);
|
||||
|
||||
return wrapper.vm
|
||||
.$nextTick()
|
||||
.then(() => {
|
||||
findReplyForm().vm.$emit('cancelForm');
|
||||
|
||||
expect(wrapper.vm.discussionComment).toBe('');
|
||||
return wrapper.vm.$nextTick();
|
||||
})
|
||||
.then(() => {
|
||||
expect(findReplyForm().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies correct class to design notes when discussion is highlighted', () => {
|
||||
createComponent(
|
||||
{},
|
||||
{
|
||||
activeDiscussion: {
|
||||
id: notes[0].id,
|
||||
source: 'pin',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls toggleResolveDiscussion mutation on resolve thread button click', () => {
|
||||
createComponent();
|
||||
findResolveButton().trigger('click');
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
mutation: toggleResolveDiscussionMutation,
|
||||
variables: {
|
||||
id: discussion.id,
|
||||
resolve: true,
|
||||
},
|
||||
});
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(findResolveLoadingIcon().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls toggleResolveDiscussion mutation after adding a note if checkbox was checked', () => {
|
||||
createComponent(
|
||||
{ discussionWithOpenForm: discussion.id },
|
||||
{ discussionComment: 'test', isFormRendered: true },
|
||||
);
|
||||
findResolveButton().trigger('click');
|
||||
findReplyForm().vm.$emit('submitForm');
|
||||
|
||||
return mutate().then(() => {
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
mutation: toggleResolveDiscussionMutation,
|
||||
variables: {
|
||||
id: discussion.id,
|
||||
resolve: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('emits openForm event on opening the form', () => {
|
||||
createComponent();
|
||||
findReplyPlaceholder().vm.$emit('onClick');
|
||||
|
||||
expect(wrapper.emitted('openForm')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { ApolloMutation } from 'vue-apollo';
|
||||
import DesignNote from '~/design_management_new/components/design_notes/design_note.vue';
|
||||
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import DesignReplyForm from '~/design_management_new/components/design_notes/design_reply_form.vue';
|
||||
|
||||
const scrollIntoViewMock = jest.fn();
|
||||
const note = {
|
||||
id: 'gid://gitlab/DiffNote/123',
|
||||
author: {
|
||||
id: 'author-id',
|
||||
},
|
||||
body: 'test',
|
||||
userPermissions: {
|
||||
adminNote: false,
|
||||
},
|
||||
};
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
|
||||
|
||||
const $route = {
|
||||
hash: '#note_123',
|
||||
};
|
||||
|
||||
const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } });
|
||||
|
||||
describe('Design note component', () => {
|
||||
let wrapper;
|
||||
|
||||
const findUserAvatar = () => wrapper.find(UserAvatarLink);
|
||||
const findUserLink = () => wrapper.find('.js-user-link');
|
||||
const findReplyForm = () => wrapper.find(DesignReplyForm);
|
||||
const findEditButton = () => wrapper.find('.js-note-edit');
|
||||
const findNoteContent = () => wrapper.find('.js-note-text');
|
||||
|
||||
function createComponent(props = {}, data = { isEditing: false }) {
|
||||
wrapper = shallowMount(DesignNote, {
|
||||
propsData: {
|
||||
note: {},
|
||||
...props,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
...data,
|
||||
};
|
||||
},
|
||||
mocks: {
|
||||
$route,
|
||||
$apollo: {
|
||||
mutate,
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
ApolloMutation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('should match the snapshot', () => {
|
||||
createComponent({
|
||||
note,
|
||||
});
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render an author', () => {
|
||||
createComponent({
|
||||
note,
|
||||
});
|
||||
|
||||
expect(findUserAvatar().exists()).toBe(true);
|
||||
expect(findUserLink().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('should render a time ago tooltip if note has createdAt property', () => {
|
||||
createComponent({
|
||||
note: {
|
||||
...note,
|
||||
createdAt: '2019-07-26T15:02:20Z',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger a scrollIntoView method', () => {
|
||||
createComponent({
|
||||
note,
|
||||
});
|
||||
|
||||
expect(scrollIntoViewMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not render edit icon when user does not have a permission', () => {
|
||||
createComponent({
|
||||
note,
|
||||
});
|
||||
|
||||
expect(findEditButton().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('when user has a permission to edit note', () => {
|
||||
it('should open an edit form on edit button click', () => {
|
||||
createComponent({
|
||||
note: {
|
||||
...note,
|
||||
userPermissions: {
|
||||
adminNote: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
findEditButton().trigger('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findReplyForm().exists()).toBe(true);
|
||||
expect(findNoteContent().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when edit form is rendered', () => {
|
||||
beforeEach(() => {
|
||||
createComponent(
|
||||
{
|
||||
note: {
|
||||
...note,
|
||||
userPermissions: {
|
||||
adminNote: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ isEditing: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render note content and should render reply form', () => {
|
||||
expect(findNoteContent().exists()).toBe(false);
|
||||
expect(findReplyForm().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the form on hideForm event', () => {
|
||||
findReplyForm().vm.$emit('cancelForm');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findReplyForm().exists()).toBe(false);
|
||||
expect(findNoteContent().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls a mutation on submitForm event and hides a form', () => {
|
||||
findReplyForm().vm.$emit('submitForm');
|
||||
expect(mutate).toHaveBeenCalled();
|
||||
|
||||
return mutate()
|
||||
.then(() => {
|
||||
return wrapper.vm.$nextTick();
|
||||
})
|
||||
.then(() => {
|
||||
expect(findReplyForm().exists()).toBe(false);
|
||||
expect(findNoteContent().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import DesignReplyForm from '~/design_management_new/components/design_notes/design_reply_form.vue';
|
||||
|
||||
const showModal = jest.fn();
|
||||
|
||||
const GlModal = {
|
||||
template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
|
||||
methods: {
|
||||
show: showModal,
|
||||
},
|
||||
};
|
||||
|
||||
describe('Design reply form component', () => {
|
||||
let wrapper;
|
||||
|
||||
const findTextarea = () => wrapper.find('textarea');
|
||||
const findSubmitButton = () => wrapper.find({ ref: 'submitButton' });
|
||||
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
|
||||
const findModal = () => wrapper.find({ ref: 'cancelCommentModal' });
|
||||
|
||||
function createComponent(props = {}, mountOptions = {}) {
|
||||
wrapper = mount(DesignReplyForm, {
|
||||
propsData: {
|
||||
value: '',
|
||||
isSaving: false,
|
||||
...props,
|
||||
},
|
||||
stubs: { GlModal },
|
||||
...mountOptions,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('textarea has focus after component mount', () => {
|
||||
// We need to attach to document, so that `document.activeElement` is properly set in jsdom
|
||||
createComponent({}, { attachToDocument: true });
|
||||
|
||||
expect(findTextarea().element).toEqual(document.activeElement);
|
||||
});
|
||||
|
||||
it('renders button text as "Comment" when creating a comment', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findSubmitButton().html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders button text as "Save comment" when creating a comment', () => {
|
||||
createComponent({ isNewComment: false });
|
||||
|
||||
expect(findSubmitButton().html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('when form has no text', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({
|
||||
value: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('submit button is disabled', () => {
|
||||
expect(findSubmitButton().attributes().disabled).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not emit submitForm event on textarea ctrl+enter keydown', () => {
|
||||
findTextarea().trigger('keydown.enter', {
|
||||
ctrlKey: true,
|
||||
});
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted('submitForm')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not emit submitForm event on textarea meta+enter keydown', () => {
|
||||
findTextarea().trigger('keydown.enter', {
|
||||
metaKey: true,
|
||||
});
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted('submitForm')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('emits cancelForm event on pressing escape button on textarea', () => {
|
||||
findTextarea().trigger('keyup.esc');
|
||||
|
||||
expect(wrapper.emitted('cancelForm')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits cancelForm event on clicking Cancel button', () => {
|
||||
findCancelButton().vm.$emit('click');
|
||||
|
||||
expect(wrapper.emitted('cancelForm')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when form has text', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({
|
||||
value: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('submit button is enabled', () => {
|
||||
expect(findSubmitButton().attributes().disabled).toBeFalsy();
|
||||
});
|
||||
|
||||
it('emits submitForm event on Comment button click', () => {
|
||||
findSubmitButton().vm.$emit('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted('submitForm')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('emits submitForm event on textarea ctrl+enter keydown', () => {
|
||||
findTextarea().trigger('keydown.enter', {
|
||||
ctrlKey: true,
|
||||
});
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted('submitForm')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('emits submitForm event on textarea meta+enter keydown', () => {
|
||||
findTextarea().trigger('keydown.enter', {
|
||||
metaKey: true,
|
||||
});
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted('submitForm')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('emits input event on changing textarea content', () => {
|
||||
findTextarea().setValue('test2');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted('input')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('emits cancelForm event on Escape key if text was not changed', () => {
|
||||
findTextarea().trigger('keyup.esc');
|
||||
|
||||
expect(wrapper.emitted('cancelForm')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens confirmation modal on Escape key when text has changed', () => {
|
||||
wrapper.setProps({ value: 'test2' });
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
findTextarea().trigger('keyup.esc');
|
||||
expect(showModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('emits cancelForm event on Cancel button click if text was not changed', () => {
|
||||
findCancelButton().trigger('click');
|
||||
|
||||
expect(wrapper.emitted('cancelForm')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens confirmation modal on Cancel button click when text has changed', () => {
|
||||
wrapper.setProps({ value: 'test2' });
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
findCancelButton().trigger('click');
|
||||
expect(showModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('emits cancelForm event on modal Ok button click', () => {
|
||||
findTextarea().trigger('keyup.esc');
|
||||
findModal().vm.$emit('ok');
|
||||
|
||||
expect(wrapper.emitted('cancelForm')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { GlIcon, GlButton, GlLink } from '@gitlab/ui';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import ToggleRepliesWidget from '~/design_management_new/components/design_notes/toggle_replies_widget.vue';
|
||||
import notes from '../../mock_data/notes';
|
||||
|
||||
describe('Toggle replies widget component', () => {
|
||||
let wrapper;
|
||||
|
||||
const findToggleWrapper = () => wrapper.find('[data-testid="toggle-comments-wrapper"]');
|
||||
const findIcon = () => wrapper.find(GlIcon);
|
||||
const findButton = () => wrapper.find(GlButton);
|
||||
const findAuthorLink = () => wrapper.find(GlLink);
|
||||
const findTimeAgo = () => wrapper.find(TimeAgoTooltip);
|
||||
|
||||
function createComponent(props = {}) {
|
||||
wrapper = shallowMount(ToggleRepliesWidget, {
|
||||
propsData: {
|
||||
collapsed: true,
|
||||
replies: notes,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('when replies are collapsed', () => {
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('should not have expanded class', () => {
|
||||
expect(findToggleWrapper().classes()).not.toContain('expanded');
|
||||
});
|
||||
|
||||
it('should render chevron-right icon', () => {
|
||||
expect(findIcon().props('name')).toBe('chevron-right');
|
||||
});
|
||||
|
||||
it('should have replies length on button', () => {
|
||||
expect(findButton().text()).toBe('2 replies');
|
||||
});
|
||||
|
||||
it('should render a link to the last reply author', () => {
|
||||
expect(findAuthorLink().exists()).toBe(true);
|
||||
expect(findAuthorLink().text()).toBe(notes[1].author.name);
|
||||
expect(findAuthorLink().attributes('href')).toBe(notes[1].author.webUrl);
|
||||
});
|
||||
|
||||
it('should render correct time ago tooltip', () => {
|
||||
expect(findTimeAgo().exists()).toBe(true);
|
||||
expect(findTimeAgo().props('time')).toBe(notes[1].createdAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when replies are expanded', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ collapsed: false });
|
||||
});
|
||||
|
||||
it('should have expanded class', () => {
|
||||
expect(findToggleWrapper().classes()).toContain('expanded');
|
||||
});
|
||||
|
||||
it('should render chevron-down icon', () => {
|
||||
expect(findIcon().props('name')).toBe('chevron-down');
|
||||
});
|
||||
|
||||
it('should have Collapse replies text on button', () => {
|
||||
expect(findButton().text()).toBe('Collapse replies');
|
||||
});
|
||||
|
||||
it('should not have a link to the last reply author', () => {
|
||||
expect(findAuthorLink().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not render time ago tooltip', () => {
|
||||
expect(findTimeAgo().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit toggle event on icon click', () => {
|
||||
createComponent();
|
||||
findIcon().vm.$emit('click', new MouseEvent('click'));
|
||||
|
||||
expect(wrapper.emitted('toggle')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should emit toggle event on button click', () => {
|
||||
createComponent();
|
||||
findButton().vm.$emit('click', new MouseEvent('click'));
|
||||
|
||||
expect(wrapper.emitted('toggle')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import DesignOverlay from '~/design_management_new/components/design_overlay.vue';
|
||||
import updateActiveDiscussion from '~/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql';
|
||||
import notes from '../mock_data/notes';
|
||||
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '~/design_management_new/constants';
|
||||
|
||||
const mutate = jest.fn(() => Promise.resolve());
|
||||
|
||||
describe('Design overlay component', () => {
|
||||
let wrapper;
|
||||
|
||||
const mockDimensions = { width: 100, height: 100 };
|
||||
|
||||
const findOverlay = () => wrapper.find('.image-diff-overlay');
|
||||
const findAllNotes = () => wrapper.findAll('.js-image-badge');
|
||||
const findCommentBadge = () => wrapper.find('.comment-indicator');
|
||||
const findFirstBadge = () => findAllNotes().at(0);
|
||||
const findSecondBadge = () => findAllNotes().at(1);
|
||||
|
||||
const clickAndDragBadge = (elem, fromPoint, toPoint) => {
|
||||
elem.trigger('mousedown', { clientX: fromPoint.x, clientY: fromPoint.y });
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
elem.trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y });
|
||||
return wrapper.vm.$nextTick();
|
||||
});
|
||||
};
|
||||
|
||||
function createComponent(props = {}, data = {}) {
|
||||
wrapper = mount(DesignOverlay, {
|
||||
propsData: {
|
||||
dimensions: mockDimensions,
|
||||
position: {
|
||||
top: '0',
|
||||
left: '0',
|
||||
},
|
||||
resolvedDiscussionsExpanded: false,
|
||||
...props,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeDiscussion: {
|
||||
id: null,
|
||||
source: null,
|
||||
},
|
||||
...data,
|
||||
};
|
||||
},
|
||||
mocks: {
|
||||
$apollo: {
|
||||
mutate,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('should have correct inline style', () => {
|
||||
createComponent();
|
||||
|
||||
expect(wrapper.find('.image-diff-overlay').attributes().style).toBe(
|
||||
'width: 100px; height: 100px; top: 0px; left: 0px;',
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit `openCommentForm` when clicking on overlay', () => {
|
||||
createComponent();
|
||||
const newCoordinates = {
|
||||
x: 10,
|
||||
y: 10,
|
||||
};
|
||||
|
||||
wrapper
|
||||
.find('.image-diff-overlay-add-comment')
|
||||
.trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y });
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted('openCommentForm')).toEqual([
|
||||
[{ x: newCoordinates.x, y: newCoordinates.y }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with notes', () => {
|
||||
it('should render only the first note', () => {
|
||||
createComponent({
|
||||
notes,
|
||||
});
|
||||
expect(findAllNotes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('with resolved discussions toggle expanded', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({
|
||||
notes,
|
||||
resolvedDiscussionsExpanded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render all notes', () => {
|
||||
expect(findAllNotes()).toHaveLength(notes.length);
|
||||
});
|
||||
|
||||
it('should have set the correct position for each note badge', () => {
|
||||
expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;');
|
||||
expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;');
|
||||
});
|
||||
|
||||
it('should apply resolved class to the resolved note pin', () => {
|
||||
expect(findSecondBadge().classes()).toContain('resolved');
|
||||
});
|
||||
|
||||
it('when there is an active discussion, should apply inactive class to all pins besides the active one', () => {
|
||||
wrapper.setData({
|
||||
activeDiscussion: {
|
||||
id: notes[0].id,
|
||||
source: 'discussion',
|
||||
},
|
||||
});
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findSecondBadge().classes()).toContain('inactive');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should recalculate badges positions on window resize', () => {
|
||||
createComponent({
|
||||
notes,
|
||||
dimensions: {
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
});
|
||||
|
||||
expect(findFirstBadge().attributes().style).toBe('left: 40px; top: 60px;');
|
||||
|
||||
wrapper.setProps({
|
||||
dimensions: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
});
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 30px;');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call an update active discussion mutation when clicking a note without moving it', () => {
|
||||
const note = notes[0];
|
||||
const { position } = note;
|
||||
const mutationVariables = {
|
||||
mutation: updateActiveDiscussion,
|
||||
variables: {
|
||||
id: note.id,
|
||||
source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin,
|
||||
},
|
||||
};
|
||||
|
||||
findFirstBadge().trigger('mousedown', { clientX: position.x, clientY: position.y });
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
findFirstBadge().trigger('mouseup', { clientX: position.x, clientY: position.y });
|
||||
expect(mutate).toHaveBeenCalledWith(mutationVariables);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when moving notes', () => {
|
||||
it('should update badge style when note is being moved', () => {
|
||||
createComponent({
|
||||
notes,
|
||||
});
|
||||
|
||||
const { position } = notes[0];
|
||||
|
||||
return clickAndDragBadge(
|
||||
findFirstBadge(),
|
||||
{ x: position.x, y: position.y },
|
||||
{ x: 20, y: 20 },
|
||||
).then(() => {
|
||||
expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 20px; cursor: move;');
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit `moveNote` event when note-moving action ends', () => {
|
||||
createComponent({ notes });
|
||||
const note = notes[0];
|
||||
const { position } = note;
|
||||
const newCoordinates = { x: 20, y: 20 };
|
||||
|
||||
wrapper.setData({
|
||||
movingNoteNewPosition: {
|
||||
...position,
|
||||
...newCoordinates,
|
||||
},
|
||||
movingNoteStartPosition: {
|
||||
noteId: notes[0].id,
|
||||
discussionId: notes[0].discussion.id,
|
||||
...position,
|
||||
},
|
||||
});
|
||||
|
||||
const badge = findFirstBadge();
|
||||
return clickAndDragBadge(badge, { x: position.x, y: position.y }, newCoordinates)
|
||||
.then(() => {
|
||||
badge.trigger('mouseup');
|
||||
return wrapper.vm.$nextTick();
|
||||
})
|
||||
.then(() => {
|
||||
expect(wrapper.emitted('moveNote')).toEqual([
|
||||
[
|
||||
{
|
||||
noteId: notes[0].id,
|
||||
discussionId: notes[0].discussion.id,
|
||||
coordinates: newCoordinates,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without [adminNote] permission', () => {
|
||||
const mockNoteNotAuthorised = {
|
||||
...notes[0],
|
||||
userPermissions: {
|
||||
adminNote: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mockNoteCoordinates = {
|
||||
x: mockNoteNotAuthorised.position.x,
|
||||
y: mockNoteNotAuthorised.position.y,
|
||||
};
|
||||
|
||||
it('should be unable to move a note', () => {
|
||||
createComponent({
|
||||
dimensions: mockDimensions,
|
||||
notes: [mockNoteNotAuthorised],
|
||||
});
|
||||
|
||||
const badge = findAllNotes().at(0);
|
||||
return clickAndDragBadge(badge, { ...mockNoteCoordinates }, { x: 20, y: 20 }).then(() => {
|
||||
// note position should not change after a click-and-drag attempt
|
||||
expect(findFirstBadge().attributes().style).toContain(
|
||||
`left: ${mockNoteCoordinates.x}px; top: ${mockNoteCoordinates.y}px;`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a new form', () => {
|
||||
it('should render a new comment badge', () => {
|
||||
createComponent({
|
||||
currentCommentForm: {
|
||||
...notes[0].position,
|
||||
},
|
||||
});
|
||||
|
||||
expect(findCommentBadge().exists()).toBe(true);
|
||||
expect(findCommentBadge().attributes().style).toBe('left: 10px; top: 15px;');
|
||||
});
|
||||
|
||||
describe('when moving the comment badge', () => {
|
||||
it('should update badge style to reflect new position', () => {
|
||||
const { position } = notes[0];
|
||||
|
||||
createComponent({
|
||||
currentCommentForm: {
|
||||
...position,
|
||||
},
|
||||
});
|
||||
|
||||
return clickAndDragBadge(
|
||||
findCommentBadge(),
|
||||
{ x: position.x, y: position.y },
|
||||
{ x: 20, y: 20 },
|
||||
).then(() => {
|
||||
expect(findCommentBadge().attributes().style).toBe(
|
||||
'left: 20px; top: 20px; cursor: move;',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should update badge style when note-moving action ends', () => {
|
||||
const { position } = notes[0];
|
||||
createComponent({
|
||||
currentCommentForm: {
|
||||
...position,
|
||||
},
|
||||
});
|
||||
|
||||
const commentBadge = findCommentBadge();
|
||||
const toPoint = { x: 20, y: 20 };
|
||||
|
||||
return clickAndDragBadge(commentBadge, { x: position.x, y: position.y }, toPoint)
|
||||
.then(() => {
|
||||
commentBadge.trigger('mouseup');
|
||||
// simulates the currentCommentForm being updated in index.vue component, and
|
||||
// propagated back down to this prop
|
||||
wrapper.setProps({
|
||||
currentCommentForm: { height: position.height, width: position.width, ...toPoint },
|
||||
});
|
||||
return wrapper.vm.$nextTick();
|
||||
})
|
||||
.then(() => {
|
||||
expect(commentBadge.attributes().style).toBe('left: 20px; top: 20px;');
|
||||
});
|
||||
});
|
||||
|
||||
it.each`
|
||||
element | getElementFunc | event
|
||||
${'overlay'} | ${findOverlay} | ${'mouseleave'}
|
||||
${'comment badge'} | ${findCommentBadge} | ${'mouseup'}
|
||||
`(
|
||||
'should emit `openCommentForm` event when $event fired on $element element',
|
||||
({ getElementFunc, event }) => {
|
||||
createComponent({
|
||||
notes,
|
||||
currentCommentForm: {
|
||||
...notes[0].position,
|
||||
},
|
||||
});
|
||||
|
||||
const newCoordinates = { x: 20, y: 20 };
|
||||
wrapper.setData({
|
||||
movingNoteStartPosition: {
|
||||
...notes[0].position,
|
||||
},
|
||||
movingNoteNewPosition: {
|
||||
...notes[0].position,
|
||||
...newCoordinates,
|
||||
},
|
||||
});
|
||||
|
||||
getElementFunc().trigger(event);
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMovingNotePositionDelta', () => {
|
||||
it('should calculate delta correctly from state', () => {
|
||||
createComponent();
|
||||
|
||||
wrapper.setData({
|
||||
movingNoteStartPosition: {
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const mockMouseEvent = {
|
||||
clientX: 30,
|
||||
clientY: 10,
|
||||
};
|
||||
|
||||
expect(wrapper.vm.getMovingNotePositionDelta(mockMouseEvent)).toEqual({
|
||||
deltaX: 20,
|
||||
deltaY: -10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPositionInOverlay', () => {
|
||||
createComponent({ dimensions: mockDimensions });
|
||||
|
||||
it.each`
|
||||
test | coordinates | expectedResult
|
||||
${'within overlay bounds'} | ${{ x: 50, y: 50 }} | ${true}
|
||||
${'outside overlay bounds'} | ${{ x: 101, y: 101 }} | ${false}
|
||||
`('returns [$expectedResult] when position is $test', ({ coordinates, expectedResult }) => {
|
||||
const position = { ...mockDimensions, ...coordinates };
|
||||
|
||||
expect(wrapper.vm.isPositionInOverlay(position)).toBe(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNoteRelativePosition', () => {
|
||||
it('calculates position correctly', () => {
|
||||
createComponent({ dimensions: mockDimensions });
|
||||
const position = { x: 50, y: 50, width: 200, height: 200 };
|
||||
|
||||
expect(wrapper.vm.getNoteRelativePosition(position)).toEqual({ left: 25, top: 25 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('canMoveNote', () => {
|
||||
it.each`
|
||||
adminNotePermission | canMoveNoteResult
|
||||
${true} | ${true}
|
||||
${false} | ${false}
|
||||
${undefined} | ${false}
|
||||
`(
|
||||
'returns [$canMoveNoteResult] when [adminNote permission] is [$adminNotePermission]',
|
||||
({ adminNotePermission, canMoveNoteResult }) => {
|
||||
createComponent();
|
||||
|
||||
const note = {
|
||||
userPermissions: {
|
||||
adminNote: adminNotePermission,
|
||||
},
|
||||
};
|
||||
expect(wrapper.vm.canMoveNote(note)).toBe(canMoveNoteResult);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,553 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import DesignPresentation from '~/design_management_new/components/design_presentation.vue';
|
||||
import DesignOverlay from '~/design_management_new/components/design_overlay.vue';
|
||||
|
||||
const mockOverlayData = {
|
||||
overlayDimensions: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
overlayPosition: {
|
||||
top: '0',
|
||||
left: '0',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Design management design presentation component', () => {
|
||||
let wrapper;
|
||||
|
||||
function createComponent(
|
||||
{
|
||||
image,
|
||||
imageName,
|
||||
discussions = [],
|
||||
isAnnotating = false,
|
||||
resolvedDiscussionsExpanded = false,
|
||||
} = {},
|
||||
data = {},
|
||||
stubs = {},
|
||||
) {
|
||||
wrapper = shallowMount(DesignPresentation, {
|
||||
propsData: {
|
||||
image,
|
||||
imageName,
|
||||
discussions,
|
||||
isAnnotating,
|
||||
resolvedDiscussionsExpanded,
|
||||
},
|
||||
stubs,
|
||||
});
|
||||
|
||||
wrapper.setData(data);
|
||||
wrapper.element.scrollTo = jest.fn();
|
||||
}
|
||||
|
||||
const findOverlayCommentButton = () => wrapper.find('.image-diff-overlay-add-comment');
|
||||
|
||||
/**
|
||||
* Spy on $refs and mock given values
|
||||
* @param {Object} viewportDimensions {width, height}
|
||||
* @param {Object} childDimensions {width, height}
|
||||
* @param {Float} scrollTopPerc 0 < x < 1
|
||||
* @param {Float} scrollLeftPerc 0 < x < 1
|
||||
*/
|
||||
function mockRefDimensions(
|
||||
ref,
|
||||
viewportDimensions,
|
||||
childDimensions,
|
||||
scrollTopPerc,
|
||||
scrollLeftPerc,
|
||||
) {
|
||||
jest.spyOn(ref, 'scrollWidth', 'get').mockReturnValue(childDimensions.width);
|
||||
jest.spyOn(ref, 'scrollHeight', 'get').mockReturnValue(childDimensions.height);
|
||||
jest.spyOn(ref, 'offsetWidth', 'get').mockReturnValue(viewportDimensions.width);
|
||||
jest.spyOn(ref, 'offsetHeight', 'get').mockReturnValue(viewportDimensions.height);
|
||||
jest
|
||||
.spyOn(ref, 'scrollLeft', 'get')
|
||||
.mockReturnValue((childDimensions.width - viewportDimensions.width) * scrollLeftPerc);
|
||||
jest
|
||||
.spyOn(ref, 'scrollTop', 'get')
|
||||
.mockReturnValue((childDimensions.height - viewportDimensions.height) * scrollTopPerc);
|
||||
}
|
||||
|
||||
function clickDragExplore(startCoords, endCoords, { useTouchEvents, mouseup } = {}) {
|
||||
const event = useTouchEvents
|
||||
? {
|
||||
mousedown: 'touchstart',
|
||||
mousemove: 'touchmove',
|
||||
mouseup: 'touchend',
|
||||
}
|
||||
: {
|
||||
mousedown: 'mousedown',
|
||||
mousemove: 'mousemove',
|
||||
mouseup: 'mouseup',
|
||||
};
|
||||
|
||||
const addCommentOverlay = findOverlayCommentButton();
|
||||
|
||||
// triggering mouse events on this element best simulates
|
||||
// reality, as it is the lowest-level node that needs to
|
||||
// respond to mouse events
|
||||
addCommentOverlay.trigger(event.mousedown, {
|
||||
clientX: startCoords.clientX,
|
||||
clientY: startCoords.clientY,
|
||||
});
|
||||
return wrapper.vm
|
||||
.$nextTick()
|
||||
.then(() => {
|
||||
addCommentOverlay.trigger(event.mousemove, {
|
||||
clientX: endCoords.clientX,
|
||||
clientY: endCoords.clientY,
|
||||
});
|
||||
|
||||
return wrapper.vm.$nextTick();
|
||||
})
|
||||
.then(() => {
|
||||
if (mouseup) {
|
||||
addCommentOverlay.trigger(event.mouseup);
|
||||
return wrapper.vm.$nextTick();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders image and overlay when image provided', () => {
|
||||
createComponent(
|
||||
{
|
||||
image: 'test.jpg',
|
||||
imageName: 'test',
|
||||
},
|
||||
mockOverlayData,
|
||||
);
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders empty state when no image provided', () => {
|
||||
createComponent();
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('openCommentForm event emits correct data', () => {
|
||||
createComponent(
|
||||
{
|
||||
image: 'test.jpg',
|
||||
imageName: 'test',
|
||||
},
|
||||
mockOverlayData,
|
||||
);
|
||||
|
||||
wrapper.vm.openCommentForm({ x: 1, y: 1 });
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted('openCommentForm')).toEqual([
|
||||
[{ ...mockOverlayData.overlayDimensions, x: 1, y: 1 }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentCommentForm', () => {
|
||||
it('is null when isAnnotating is false', () => {
|
||||
createComponent(
|
||||
{
|
||||
image: 'test.jpg',
|
||||
imageName: 'test',
|
||||
},
|
||||
mockOverlayData,
|
||||
);
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.vm.currentCommentForm).toBeNull();
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('is null when isAnnotating is true but annotation position is falsey', () => {
|
||||
createComponent(
|
||||
{
|
||||
image: 'test.jpg',
|
||||
imageName: 'test',
|
||||
isAnnotating: true,
|
||||
},
|
||||
mockOverlayData,
|
||||
);
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.vm.currentCommentForm).toBeNull();
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('is equal to current annotation position when isAnnotating is true', () => {
|
||||
createComponent(
|
||||
{
|
||||
image: 'test.jpg',
|
||||
imageName: 'test',
|
||||
isAnnotating: true,
|
||||
},
|
||||
{
|
||||
...mockOverlayData,
|
||||
currentAnnotationPosition: {
|
||||
x: 1,
|
||||
y: 1,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.vm.currentCommentForm).toEqual({
|
||||
x: 1,
|
||||
y: 1,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setOverlayPosition', () => {
|
||||
beforeEach(() => {
|
||||
createComponent(
|
||||
{
|
||||
image: 'test.jpg',
|
||||
imageName: 'test',
|
||||
},
|
||||
mockOverlayData,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sets overlay position correctly when overlay is smaller than viewport', () => {
|
||||
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200);
|
||||
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200);
|
||||
|
||||
wrapper.vm.setOverlayPosition();
|
||||
expect(wrapper.vm.overlayPosition).toEqual({
|
||||
left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`,
|
||||
top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets overlay position correctly when overlay width is larger than viewports', () => {
|
||||
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(50);
|
||||
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200);
|
||||
|
||||
wrapper.vm.setOverlayPosition();
|
||||
expect(wrapper.vm.overlayPosition).toEqual({
|
||||
left: '0',
|
||||
top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets overlay position correctly when overlay height is larger than viewports', () => {
|
||||
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200);
|
||||
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(50);
|
||||
|
||||
wrapper.vm.setOverlayPosition();
|
||||
expect(wrapper.vm.overlayPosition).toEqual({
|
||||
left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`,
|
||||
top: '0',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getViewportCenter', () => {
|
||||
beforeEach(() => {
|
||||
createComponent(
|
||||
{
|
||||
image: 'test.jpg',
|
||||
imageName: 'test',
|
||||
},
|
||||
mockOverlayData,
|
||||
);
|
||||
});
|
||||
|
||||
it('calculate center correctly with no scroll', () => {
|
||||
mockRefDimensions(
|
||||
wrapper.vm.$refs.presentationViewport,
|
||||
{ width: 10, height: 10 },
|
||||
{ width: 20, height: 20 },
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(wrapper.vm.getViewportCenter()).toEqual({
|
||||
x: 5,
|
||||
y: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('calculate center correctly with some scroll', () => {
|
||||
mockRefDimensions(
|
||||
wrapper.vm.$refs.presentationViewport,
|
||||
{ width: 10, height: 10 },
|
||||
{ width: 20, height: 20 },
|
||||
0.5,
|
||||
0.5,
|
||||
);
|
||||
|
||||
expect(wrapper.vm.getViewportCenter()).toEqual({
|
||||
x: 10,
|
||||
y: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('Returns default case if no overflow (scrollWidth==offsetWidth, etc.)', () => {
|
||||
mockRefDimensions(
|
||||
wrapper.vm.$refs.presentationViewport,
|
||||
{ width: 20, height: 20 },
|
||||
{ width: 20, height: 20 },
|
||||
0.5,
|
||||
0.5,
|
||||
);
|
||||
|
||||
expect(wrapper.vm.getViewportCenter()).toEqual({
|
||||
x: 10,
|
||||
y: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('scaleZoomFocalPoint', () => {
|
||||
it('scales focal point correctly when zooming in', () => {
|
||||
createComponent(
|
||||
{
|
||||
image: 'test.jpg',
|
||||
imageName: 'test',
|
||||
},
|
||||
{
|
||||
...mockOverlayData,
|
||||
zoomFocalPoint: {
|
||||
x: 5,
|
||||
y: 5,
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
wrapper.vm.scaleZoomFocalPoint();
|
||||
expect(wrapper.vm.zoomFocalPoint).toEqual({
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('scales focal point correctly when zooming out', () => {
|
||||
createComponent(
|
||||
{
|
||||
image: 'test.jpg',
|
||||
imageName: 'test',
|
||||
},
|
||||
{
|
||||
...mockOverlayData,
|
||||
zoomFocalPoint: {
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
wrapper.vm.scaleZoomFocalPoint();
|
||||
expect(wrapper.vm.zoomFocalPoint).toEqual({
|
||||
x: 5,
|
||||
y: 5,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onImageResize', () => {
|
||||
it('sets zoom focal point on initial load', () => {
|
||||
createComponent(
|
||||
{
|
||||
image: 'test.jpg',
|
||||
imageName: 'test',
|
||||
},
|
||||
mockOverlayData,
|
||||
);
|
||||
|
||||
wrapper.setMethods({
|
||||
shiftZoomFocalPoint: jest.fn(),
|
||||
scaleZoomFocalPoint: jest.fn(),
|
||||
scrollToFocalPoint: jest.fn(),
|
||||
});
|
||||
|
||||
wrapper.vm.onImageResize({ width: 10, height: 10 });
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.vm.shiftZoomFocalPoint).toHaveBeenCalled();
|
||||
expect(wrapper.vm.initialLoad).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls scaleZoomFocalPoint and scrollToFocalPoint after initial load', () => {
|
||||
wrapper.vm.onImageResize({ width: 10, height: 10 });
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.vm.scaleZoomFocalPoint).toHaveBeenCalled();
|
||||
expect(wrapper.vm.scrollToFocalPoint).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onPresentationMousedown', () => {
|
||||
it.each`
|
||||
scenario | width | height
|
||||
${'width overflows'} | ${101} | ${100}
|
||||
${'height overflows'} | ${100} | ${101}
|
||||
${'width and height overflows'} | ${200} | ${200}
|
||||
`('sets lastDragPosition when design $scenario', ({ width, height }) => {
|
||||
createComponent();
|
||||
mockRefDimensions(
|
||||
wrapper.vm.$refs.presentationViewport,
|
||||
{ width: 100, height: 100 },
|
||||
{ width, height },
|
||||
);
|
||||
|
||||
const newLastDragPosition = { x: 2, y: 2 };
|
||||
wrapper.vm.onPresentationMousedown({
|
||||
clientX: newLastDragPosition.x,
|
||||
clientY: newLastDragPosition.y,
|
||||
});
|
||||
|
||||
expect(wrapper.vm.lastDragPosition).toStrictEqual(newLastDragPosition);
|
||||
});
|
||||
|
||||
it('does not set lastDragPosition if design does not overflow', () => {
|
||||
const lastDragPosition = { x: 1, y: 1 };
|
||||
|
||||
createComponent({}, { lastDragPosition });
|
||||
mockRefDimensions(
|
||||
wrapper.vm.$refs.presentationViewport,
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 50, height: 50 },
|
||||
);
|
||||
|
||||
wrapper.vm.onPresentationMousedown({ clientX: 2, clientY: 2 });
|
||||
|
||||
// check lastDragPosition is unchanged
|
||||
expect(wrapper.vm.lastDragPosition).toStrictEqual(lastDragPosition);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnnotationPositon', () => {
|
||||
it.each`
|
||||
coordinates | overlayDimensions | position
|
||||
${{ x: 100, y: 100 }} | ${{ width: 50, height: 50 }} | ${{ x: 100, y: 100, width: 50, height: 50 }}
|
||||
${{ x: 100.2, y: 100.5 }} | ${{ width: 50.6, height: 50.0 }} | ${{ x: 100, y: 101, width: 51, height: 50 }}
|
||||
`('returns correct annotation position', ({ coordinates, overlayDimensions, position }) => {
|
||||
createComponent(undefined, {
|
||||
overlayDimensions: {
|
||||
width: overlayDimensions.width,
|
||||
height: overlayDimensions.height,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.vm.getAnnotationPositon(coordinates)).toStrictEqual(position);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when design is overflowing', () => {
|
||||
beforeEach(() => {
|
||||
createComponent(
|
||||
{
|
||||
image: 'test.jpg',
|
||||
imageName: 'test',
|
||||
},
|
||||
mockOverlayData,
|
||||
{
|
||||
'design-overlay': DesignOverlay,
|
||||
},
|
||||
);
|
||||
|
||||
// mock a design that overflows
|
||||
mockRefDimensions(
|
||||
wrapper.vm.$refs.presentationViewport,
|
||||
{ width: 10, height: 10 },
|
||||
{ width: 20, height: 20 },
|
||||
0,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a comment form if design was not dragged', () => {
|
||||
const addCommentOverlay = findOverlayCommentButton();
|
||||
const startCoords = {
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
};
|
||||
|
||||
addCommentOverlay.trigger('mousedown', {
|
||||
clientX: startCoords.clientX,
|
||||
clientY: startCoords.clientY,
|
||||
});
|
||||
|
||||
return wrapper.vm
|
||||
.$nextTick()
|
||||
.then(() => {
|
||||
addCommentOverlay.trigger('mouseup');
|
||||
return wrapper.vm.$nextTick();
|
||||
})
|
||||
.then(() => {
|
||||
expect(wrapper.emitted('openCommentForm')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when clicking and dragging', () => {
|
||||
it.each`
|
||||
description | useTouchEvents
|
||||
${'with touch events'} | ${true}
|
||||
${'without touch events'} | ${false}
|
||||
`('calls scrollTo with correct arguments $description', ({ useTouchEvents }) => {
|
||||
return clickDragExplore(
|
||||
{ clientX: 0, clientY: 0 },
|
||||
{ clientX: 10, clientY: 10 },
|
||||
{ useTouchEvents },
|
||||
).then(() => {
|
||||
expect(wrapper.element.scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.element.scrollTo).toHaveBeenCalledWith(-10, -10);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not open a comment form when drag position exceeds buffer', () => {
|
||||
return clickDragExplore(
|
||||
{ clientX: 0, clientY: 0 },
|
||||
{ clientX: 10, clientY: 10 },
|
||||
{ mouseup: true },
|
||||
).then(() => {
|
||||
expect(wrapper.emitted('openCommentForm')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a comment form when drag position is within buffer', () => {
|
||||
return clickDragExplore(
|
||||
{ clientX: 0, clientY: 0 },
|
||||
{ clientX: 1, clientY: 0 },
|
||||
{ mouseup: true },
|
||||
).then(() => {
|
||||
expect(wrapper.emitted('openCommentForm')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import DesignScaler from '~/design_management_new/components/design_scaler.vue';
|
||||
|
||||
describe('Design management design scaler component', () => {
|
||||
let wrapper;
|
||||
|
||||
function createComponent(propsData, data = {}) {
|
||||
wrapper = shallowMount(DesignScaler, {
|
||||
propsData,
|
||||
});
|
||||
wrapper.setData(data);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
const getButton = type => {
|
||||
const buttonTypeOrder = ['minus', 'reset', 'plus'];
|
||||
const buttons = wrapper.findAll('button');
|
||||
return buttons.at(buttonTypeOrder.indexOf(type));
|
||||
};
|
||||
|
||||
it('emits @scale event when "plus" button clicked', () => {
|
||||
createComponent();
|
||||
|
||||
getButton('plus').trigger('click');
|
||||
expect(wrapper.emitted('scale')).toEqual([[1.2]]);
|
||||
});
|
||||
|
||||
it('emits @scale event when "reset" button clicked (scale > 1)', () => {
|
||||
createComponent({}, { scale: 1.6 });
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
getButton('reset').trigger('click');
|
||||
expect(wrapper.emitted('scale')).toEqual([[1]]);
|
||||
});
|
||||
});
|
||||
|
||||
it('emits @scale event when "minus" button clicked (scale > 1)', () => {
|
||||
createComponent({}, { scale: 1.6 });
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
getButton('minus').trigger('click');
|
||||
expect(wrapper.emitted('scale')).toEqual([[1.4]]);
|
||||
});
|
||||
});
|
||||
|
||||
it('minus and reset buttons are disabled when scale === 1', () => {
|
||||
createComponent();
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('minus and reset buttons are enabled when scale > 1', () => {
|
||||
createComponent({}, { scale: 1.2 });
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('plus button is disabled when scale === 2', () => {
|
||||
createComponent({}, { scale: 2 });
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { GlCollapse, GlPopover } from '@gitlab/ui';
|
||||
import Cookies from 'js-cookie';
|
||||
import DesignSidebar from '~/design_management_new/components/design_sidebar.vue';
|
||||
import Participants from '~/sidebar/components/participants/participants.vue';
|
||||
import DesignDiscussion from '~/design_management_new/components/design_notes/design_discussion.vue';
|
||||
import design from '../mock_data/design';
|
||||
import updateActiveDiscussionMutation from '~/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql';
|
||||
|
||||
const updateActiveDiscussionMutationVariables = {
|
||||
mutation: updateActiveDiscussionMutation,
|
||||
variables: {
|
||||
id: design.discussions.nodes[0].notes.nodes[0].id,
|
||||
source: 'discussion',
|
||||
},
|
||||
};
|
||||
|
||||
const $route = {
|
||||
params: {
|
||||
id: '1',
|
||||
},
|
||||
};
|
||||
|
||||
const cookieKey = 'hide_design_resolved_comments_popover';
|
||||
|
||||
const mutate = jest.fn().mockResolvedValue();
|
||||
|
||||
describe('Design management design sidebar component', () => {
|
||||
let wrapper;
|
||||
|
||||
const findDiscussions = () => wrapper.findAll(DesignDiscussion);
|
||||
const findFirstDiscussion = () => findDiscussions().at(0);
|
||||
const findUnresolvedDiscussions = () => wrapper.findAll('[data-testid="unresolved-discussion"]');
|
||||
const findResolvedDiscussions = () => wrapper.findAll('[data-testid="resolved-discussion"]');
|
||||
const findParticipants = () => wrapper.find(Participants);
|
||||
const findCollapsible = () => wrapper.find(GlCollapse);
|
||||
const findToggleResolvedCommentsButton = () => wrapper.find('[data-testid="resolved-comments"]');
|
||||
const findPopover = () => wrapper.find(GlPopover);
|
||||
const findNewDiscussionDisclaimer = () =>
|
||||
wrapper.find('[data-testid="new-discussion-disclaimer"]');
|
||||
|
||||
function createComponent(props = {}) {
|
||||
wrapper = shallowMount(DesignSidebar, {
|
||||
propsData: {
|
||||
design,
|
||||
resolvedDiscussionsExpanded: false,
|
||||
markdownPreviewPath: '',
|
||||
...props,
|
||||
},
|
||||
mocks: {
|
||||
$route,
|
||||
$apollo: {
|
||||
mutate,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders participants', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findParticipants().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('passes the correct amount of participants to the Participants component', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findParticipants().props('participants')).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('when has no discussions', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({
|
||||
design: {
|
||||
...design,
|
||||
discussions: {
|
||||
nodes: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render discussions', () => {
|
||||
expect(findDiscussions().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders a message about possibility to create a new discussion', () => {
|
||||
expect(findNewDiscussionDisclaimer().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when has discussions', () => {
|
||||
beforeEach(() => {
|
||||
Cookies.set(cookieKey, true);
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('renders correct amount of unresolved discussions', () => {
|
||||
expect(findUnresolvedDiscussions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders correct amount of resolved discussions', () => {
|
||||
expect(findResolvedDiscussions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('has resolved comments collapsible collapsed', () => {
|
||||
expect(findCollapsible().attributes('visible')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits toggleResolveComments event on resolve comments button click', () => {
|
||||
findToggleResolvedCommentsButton().vm.$emit('click');
|
||||
expect(wrapper.emitted('toggleResolvedComments')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens a collapsible when resolvedDiscussionsExpanded prop changes to true', () => {
|
||||
expect(findCollapsible().attributes('visible')).toBeUndefined();
|
||||
wrapper.setProps({
|
||||
resolvedDiscussionsExpanded: true,
|
||||
});
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findCollapsible().attributes('visible')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not popover about resolved comments', () => {
|
||||
expect(findPopover().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('sends a mutation to set an active discussion when clicking on a discussion', () => {
|
||||
findFirstDiscussion().trigger('click');
|
||||
|
||||
expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables);
|
||||
});
|
||||
|
||||
it('sends a mutation to reset an active discussion when clicking outside of discussion', () => {
|
||||
wrapper.trigger('click');
|
||||
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
...updateActiveDiscussionMutationVariables,
|
||||
variables: { id: undefined, source: 'discussion' },
|
||||
});
|
||||
});
|
||||
|
||||
it('emits correct event on discussion create note error', () => {
|
||||
findFirstDiscussion().vm.$emit('createNoteError', 'payload');
|
||||
expect(wrapper.emitted('onDesignDiscussionError')).toEqual([['payload']]);
|
||||
});
|
||||
|
||||
it('emits correct event on discussion update note error', () => {
|
||||
findFirstDiscussion().vm.$emit('updateNoteError', 'payload');
|
||||
expect(wrapper.emitted('updateNoteError')).toEqual([['payload']]);
|
||||
});
|
||||
|
||||
it('emits correct event on discussion resolve error', () => {
|
||||
findFirstDiscussion().vm.$emit('resolveDiscussionError', 'payload');
|
||||
expect(wrapper.emitted('resolveDiscussionError')).toEqual([['payload']]);
|
||||
});
|
||||
|
||||
it('changes prop correctly on opening discussion form', () => {
|
||||
findFirstDiscussion().vm.$emit('openForm', 'some-id');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findFirstDiscussion().props('discussionWithOpenForm')).toBe('some-id');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when all discussions are resolved', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({
|
||||
design: {
|
||||
...design,
|
||||
discussions: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'discussion-id',
|
||||
replyId: 'discussion-reply-id',
|
||||
resolved: true,
|
||||
notes: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'note-id',
|
||||
body: '123',
|
||||
author: {
|
||||
name: 'Administrator',
|
||||
username: 'root',
|
||||
webUrl: 'link-to-author',
|
||||
avatarUrl: 'link-to-avatar',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a message about possibility to create a new discussion', () => {
|
||||
expect(findNewDiscussionDisclaimer().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render unresolved discussions', () => {
|
||||
expect(findUnresolvedDiscussions()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when showing resolved discussions for the first time', () => {
|
||||
beforeEach(() => {
|
||||
Cookies.set(cookieKey, false);
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('renders a popover if we show resolved comments collapsible for the first time', () => {
|
||||
expect(findPopover().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('dismisses a popover on the outside click', () => {
|
||||
wrapper.trigger('click');
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(findPopover().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it(`sets a ${cookieKey} cookie on clicking outside the popover`, () => {
|
||||
jest.spyOn(Cookies, 'set');
|
||||
wrapper.trigger('click');
|
||||
expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { expires: 365 * 10 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { GlIcon } from '@gitlab/ui';
|
||||
import DesignImage from '~/design_management_new/components/image.vue';
|
||||
|
||||
describe('Design management large image component', () => {
|
||||
let wrapper;
|
||||
|
||||
function createComponent(propsData, data = {}) {
|
||||
wrapper = shallowMount(DesignImage, {
|
||||
propsData,
|
||||
});
|
||||
wrapper.setData(data);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
createComponent({
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders image', () => {
|
||||
createComponent({
|
||||
isLoading: false,
|
||||
image: 'test.jpg',
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('sets correct classes and styles if imageStyle is set', () => {
|
||||
createComponent(
|
||||
{
|
||||
isLoading: false,
|
||||
image: 'test.jpg',
|
||||
name: 'test',
|
||||
},
|
||||
{
|
||||
imageStyle: {
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
},
|
||||
},
|
||||
);
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders media broken icon on error', () => {
|
||||
createComponent({
|
||||
isLoading: false,
|
||||
image: 'test.jpg',
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const image = wrapper.find('img');
|
||||
image.trigger('error');
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(image.isVisible()).toBe(false);
|
||||
expect(wrapper.find(GlIcon).element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoom', () => {
|
||||
const baseImageWidth = 100;
|
||||
const baseImageHeight = 100;
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent(
|
||||
{
|
||||
isLoading: false,
|
||||
image: 'test.jpg',
|
||||
name: 'test',
|
||||
},
|
||||
{
|
||||
imageStyle: {
|
||||
width: `${baseImageWidth}px`,
|
||||
height: `${baseImageHeight}px`,
|
||||
},
|
||||
baseImageSize: {
|
||||
width: baseImageWidth,
|
||||
height: baseImageHeight,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
jest.spyOn(wrapper.vm.$refs.contentImg, 'offsetWidth', 'get').mockReturnValue(baseImageWidth);
|
||||
jest
|
||||
.spyOn(wrapper.vm.$refs.contentImg, 'offsetHeight', 'get')
|
||||
.mockReturnValue(baseImageHeight);
|
||||
});
|
||||
|
||||
it('emits @resize event on zoom', () => {
|
||||
const zoomAmount = 2;
|
||||
wrapper.vm.zoom(zoomAmount);
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted('resize')).toEqual([
|
||||
[{ width: baseImageWidth * zoomAmount, height: baseImageHeight * zoomAmount }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('emits @resize event with base image size when scale=1', () => {
|
||||
wrapper.vm.zoom(1);
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted('resize')).toEqual([
|
||||
[{ width: baseImageWidth, height: baseImageHeight }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets image style when zoomed', () => {
|
||||
const zoomAmount = 2;
|
||||
wrapper.vm.zoom(zoomAmount);
|
||||
expect(wrapper.vm.imageStyle).toEqual({
|
||||
width: `${baseImageWidth * zoomAmount}px`,
|
||||
height: `${baseImageHeight * zoomAmount}px`,
|
||||
});
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,472 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Design management list item component when item appears in view after image is loaded renders media broken icon when image onerror triggered 1`] = `
|
||||
<gl-icon-stub
|
||||
class="text-secondary"
|
||||
name="media-broken"
|
||||
size="32"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`Design management list item component with no notes renders item with correct status icon for creation event 1`] = `
|
||||
<router-link-stub
|
||||
class="card cursor-pointer text-plain js-design-list-item design-list-item"
|
||||
to="[object Object]"
|
||||
>
|
||||
<div
|
||||
class="card-body p-0 d-flex-center overflow-hidden position-relative"
|
||||
>
|
||||
<div
|
||||
class="design-event position-absolute"
|
||||
>
|
||||
<span
|
||||
aria-label="Added in this version"
|
||||
title="Added in this version"
|
||||
>
|
||||
<icon-stub
|
||||
class="text-success-500"
|
||||
name="file-addition-solid"
|
||||
size="18"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<gl-intersection-observer-stub
|
||||
options="[object Object]"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<img
|
||||
alt="test"
|
||||
class="block mx-auto mw-100 mh-100 design-img"
|
||||
data-qa-selector="design_image"
|
||||
src=""
|
||||
/>
|
||||
</gl-intersection-observer-stub>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="card-footer d-flex w-100"
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column str-truncated-100"
|
||||
>
|
||||
<span
|
||||
class="bold str-truncated-100"
|
||||
data-qa-selector="design_file_name"
|
||||
>
|
||||
test
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="str-truncated-100"
|
||||
>
|
||||
|
||||
Updated
|
||||
<timeago-stub
|
||||
cssclass=""
|
||||
time="01-01-2019"
|
||||
tooltipplacement="bottom"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
</router-link-stub>
|
||||
`;
|
||||
|
||||
exports[`Design management list item component with no notes renders item with correct status icon for deletion event 1`] = `
|
||||
<router-link-stub
|
||||
class="card cursor-pointer text-plain js-design-list-item design-list-item"
|
||||
to="[object Object]"
|
||||
>
|
||||
<div
|
||||
class="card-body p-0 d-flex-center overflow-hidden position-relative"
|
||||
>
|
||||
<div
|
||||
class="design-event position-absolute"
|
||||
>
|
||||
<span
|
||||
aria-label="Deleted in this version"
|
||||
title="Deleted in this version"
|
||||
>
|
||||
<icon-stub
|
||||
class="text-danger-500"
|
||||
name="file-deletion-solid"
|
||||
size="18"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<gl-intersection-observer-stub
|
||||
options="[object Object]"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<img
|
||||
alt="test"
|
||||
class="block mx-auto mw-100 mh-100 design-img"
|
||||
data-qa-selector="design_image"
|
||||
src=""
|
||||
/>
|
||||
</gl-intersection-observer-stub>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="card-footer d-flex w-100"
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column str-truncated-100"
|
||||
>
|
||||
<span
|
||||
class="bold str-truncated-100"
|
||||
data-qa-selector="design_file_name"
|
||||
>
|
||||
test
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="str-truncated-100"
|
||||
>
|
||||
|
||||
Updated
|
||||
<timeago-stub
|
||||
cssclass=""
|
||||
time="01-01-2019"
|
||||
tooltipplacement="bottom"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
</router-link-stub>
|
||||
`;
|
||||
|
||||
exports[`Design management list item component with no notes renders item with correct status icon for modification event 1`] = `
|
||||
<router-link-stub
|
||||
class="card cursor-pointer text-plain js-design-list-item design-list-item"
|
||||
to="[object Object]"
|
||||
>
|
||||
<div
|
||||
class="card-body p-0 d-flex-center overflow-hidden position-relative"
|
||||
>
|
||||
<div
|
||||
class="design-event position-absolute"
|
||||
>
|
||||
<span
|
||||
aria-label="Modified in this version"
|
||||
title="Modified in this version"
|
||||
>
|
||||
<icon-stub
|
||||
class="text-primary-500"
|
||||
name="file-modified-solid"
|
||||
size="18"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<gl-intersection-observer-stub
|
||||
options="[object Object]"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<img
|
||||
alt="test"
|
||||
class="block mx-auto mw-100 mh-100 design-img"
|
||||
data-qa-selector="design_image"
|
||||
src=""
|
||||
/>
|
||||
</gl-intersection-observer-stub>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="card-footer d-flex w-100"
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column str-truncated-100"
|
||||
>
|
||||
<span
|
||||
class="bold str-truncated-100"
|
||||
data-qa-selector="design_file_name"
|
||||
>
|
||||
test
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="str-truncated-100"
|
||||
>
|
||||
|
||||
Updated
|
||||
<timeago-stub
|
||||
cssclass=""
|
||||
time="01-01-2019"
|
||||
tooltipplacement="bottom"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
</router-link-stub>
|
||||
`;
|
||||
|
||||
exports[`Design management list item component with no notes renders item with no status icon for none event 1`] = `
|
||||
<router-link-stub
|
||||
class="card cursor-pointer text-plain js-design-list-item design-list-item"
|
||||
to="[object Object]"
|
||||
>
|
||||
<div
|
||||
class="card-body p-0 d-flex-center overflow-hidden position-relative"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<gl-intersection-observer-stub
|
||||
options="[object Object]"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<img
|
||||
alt="test"
|
||||
class="block mx-auto mw-100 mh-100 design-img"
|
||||
data-qa-selector="design_image"
|
||||
src=""
|
||||
/>
|
||||
</gl-intersection-observer-stub>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="card-footer d-flex w-100"
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column str-truncated-100"
|
||||
>
|
||||
<span
|
||||
class="bold str-truncated-100"
|
||||
data-qa-selector="design_file_name"
|
||||
>
|
||||
test
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="str-truncated-100"
|
||||
>
|
||||
|
||||
Updated
|
||||
<timeago-stub
|
||||
cssclass=""
|
||||
time="01-01-2019"
|
||||
tooltipplacement="bottom"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
</router-link-stub>
|
||||
`;
|
||||
|
||||
exports[`Design management list item component with no notes renders loading spinner when isUploading is true 1`] = `
|
||||
<router-link-stub
|
||||
class="card cursor-pointer text-plain js-design-list-item design-list-item"
|
||||
to="[object Object]"
|
||||
>
|
||||
<div
|
||||
class="card-body p-0 d-flex-center overflow-hidden position-relative"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<gl-intersection-observer-stub
|
||||
options="[object Object]"
|
||||
>
|
||||
<gl-loading-icon-stub
|
||||
color="orange"
|
||||
label="Loading"
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<img
|
||||
alt="test"
|
||||
class="block mx-auto mw-100 mh-100 design-img"
|
||||
data-qa-selector="design_image"
|
||||
src=""
|
||||
style="display: none;"
|
||||
/>
|
||||
</gl-intersection-observer-stub>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="card-footer d-flex w-100"
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column str-truncated-100"
|
||||
>
|
||||
<span
|
||||
class="bold str-truncated-100"
|
||||
data-qa-selector="design_file_name"
|
||||
>
|
||||
test
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="str-truncated-100"
|
||||
>
|
||||
|
||||
Updated
|
||||
<timeago-stub
|
||||
cssclass=""
|
||||
time="01-01-2019"
|
||||
tooltipplacement="bottom"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
</router-link-stub>
|
||||
`;
|
||||
|
||||
exports[`Design management list item component with notes renders item with multiple comments 1`] = `
|
||||
<router-link-stub
|
||||
class="card cursor-pointer text-plain js-design-list-item design-list-item"
|
||||
to="[object Object]"
|
||||
>
|
||||
<div
|
||||
class="card-body p-0 d-flex-center overflow-hidden position-relative"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<gl-intersection-observer-stub
|
||||
options="[object Object]"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<img
|
||||
alt="test"
|
||||
class="block mx-auto mw-100 mh-100 design-img"
|
||||
data-qa-selector="design_image"
|
||||
src=""
|
||||
/>
|
||||
</gl-intersection-observer-stub>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="card-footer d-flex w-100"
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column str-truncated-100"
|
||||
>
|
||||
<span
|
||||
class="bold str-truncated-100"
|
||||
data-qa-selector="design_file_name"
|
||||
>
|
||||
test
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="str-truncated-100"
|
||||
>
|
||||
|
||||
Updated
|
||||
<timeago-stub
|
||||
cssclass=""
|
||||
time="01-01-2019"
|
||||
tooltipplacement="bottom"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="ml-auto d-flex align-items-center text-secondary"
|
||||
>
|
||||
<icon-stub
|
||||
class="ml-1"
|
||||
name="comments"
|
||||
size="16"
|
||||
/>
|
||||
|
||||
<span
|
||||
aria-label="2 comments"
|
||||
class="ml-1"
|
||||
>
|
||||
|
||||
2
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link-stub>
|
||||
`;
|
||||
|
||||
exports[`Design management list item component with notes renders item with single comment 1`] = `
|
||||
<router-link-stub
|
||||
class="card cursor-pointer text-plain js-design-list-item design-list-item"
|
||||
to="[object Object]"
|
||||
>
|
||||
<div
|
||||
class="card-body p-0 d-flex-center overflow-hidden position-relative"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<gl-intersection-observer-stub
|
||||
options="[object Object]"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<img
|
||||
alt="test"
|
||||
class="block mx-auto mw-100 mh-100 design-img"
|
||||
data-qa-selector="design_image"
|
||||
src=""
|
||||
/>
|
||||
</gl-intersection-observer-stub>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="card-footer d-flex w-100"
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column str-truncated-100"
|
||||
>
|
||||
<span
|
||||
class="bold str-truncated-100"
|
||||
data-qa-selector="design_file_name"
|
||||
>
|
||||
test
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="str-truncated-100"
|
||||
>
|
||||
|
||||
Updated
|
||||
<timeago-stub
|
||||
cssclass=""
|
||||
time="01-01-2019"
|
||||
tooltipplacement="bottom"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="ml-auto d-flex align-items-center text-secondary"
|
||||
>
|
||||
<icon-stub
|
||||
class="ml-1"
|
||||
name="comments"
|
||||
size="16"
|
||||
/>
|
||||
|
||||
<span
|
||||
aria-label="1 comment"
|
||||
class="ml-1"
|
||||
>
|
||||
|
||||
1
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link-stub>
|
||||
`;
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
|
||||
import VueRouter from 'vue-router';
|
||||
import Item from '~/design_management_new/components/list/item.vue';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueRouter);
|
||||
const router = new VueRouter();
|
||||
|
||||
// Referenced from: doc/api/graphql/reference/gitlab_schema.graphql:DesignVersionEvent
|
||||
const DESIGN_VERSION_EVENT = {
|
||||
CREATION: 'CREATION',
|
||||
DELETION: 'DELETION',
|
||||
MODIFICATION: 'MODIFICATION',
|
||||
NO_CHANGE: 'NONE',
|
||||
};
|
||||
|
||||
describe('Design management list item component', () => {
|
||||
let wrapper;
|
||||
|
||||
function createComponent({
|
||||
notesCount = 0,
|
||||
event = DESIGN_VERSION_EVENT.NO_CHANGE,
|
||||
isUploading = false,
|
||||
imageLoading = false,
|
||||
} = {}) {
|
||||
wrapper = shallowMount(Item, {
|
||||
localVue,
|
||||
router,
|
||||
propsData: {
|
||||
id: 1,
|
||||
filename: 'test',
|
||||
image: 'http://via.placeholder.com/300',
|
||||
isUploading,
|
||||
event,
|
||||
notesCount,
|
||||
updatedAt: '01-01-2019',
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageLoading,
|
||||
};
|
||||
},
|
||||
stubs: ['router-link'],
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('when item is not in view', () => {
|
||||
it('image is not rendered', () => {
|
||||
createComponent();
|
||||
|
||||
const image = wrapper.find('img');
|
||||
expect(image.attributes('src')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when item appears in view', () => {
|
||||
let image;
|
||||
let glIntersectionObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
image = wrapper.find('img');
|
||||
glIntersectionObserver = wrapper.find(GlIntersectionObserver);
|
||||
|
||||
glIntersectionObserver.vm.$emit('appear');
|
||||
return wrapper.vm.$nextTick();
|
||||
});
|
||||
|
||||
describe('before image is loaded', () => {
|
||||
it('renders loading spinner', () => {
|
||||
expect(wrapper.find(GlLoadingIcon)).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe('after image is loaded', () => {
|
||||
beforeEach(() => {
|
||||
image.trigger('load');
|
||||
return wrapper.vm.$nextTick();
|
||||
});
|
||||
|
||||
it('renders an image', () => {
|
||||
expect(image.attributes('src')).toBe('http://via.placeholder.com/300');
|
||||
expect(image.isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders media broken icon when image onerror triggered', () => {
|
||||
image.trigger('error');
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(image.isVisible()).toBe(false);
|
||||
expect(wrapper.find(GlIcon).element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when imageV432x230 and image provided', () => {
|
||||
it('renders imageV432x230 image', () => {
|
||||
const mockSrc = 'mock-imageV432x230-url';
|
||||
wrapper.setProps({ imageV432x230: mockSrc });
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(image.attributes('src')).toBe(mockSrc);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when image disappears from view and then reappears', () => {
|
||||
beforeEach(() => {
|
||||
glIntersectionObserver.vm.$emit('appear');
|
||||
return wrapper.vm.$nextTick();
|
||||
});
|
||||
|
||||
it('renders an image', () => {
|
||||
expect(image.isVisible()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with notes', () => {
|
||||
it('renders item with single comment', () => {
|
||||
createComponent({ notesCount: 1 });
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders item with multiple comments', () => {
|
||||
createComponent({ notesCount: 2 });
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with no notes', () => {
|
||||
it('renders item with no status icon for none event', () => {
|
||||
createComponent();
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders item with correct status icon for modification event', () => {
|
||||
createComponent({ event: DESIGN_VERSION_EVENT.MODIFICATION });
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders item with correct status icon for deletion event', () => {
|
||||
createComponent({ event: DESIGN_VERSION_EVENT.DELETION });
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders item with correct status icon for creation event', () => {
|
||||
createComponent({ event: DESIGN_VERSION_EVENT.CREATION });
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders loading spinner when isUploading is true', () => {
|
||||
createComponent({ isUploading: true });
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Design management toolbar component renders design and updated data 1`] = `
|
||||
<header
|
||||
class="d-flex p-2 bg-white align-items-center js-design-header"
|
||||
>
|
||||
<a
|
||||
aria-label="Go back to designs"
|
||||
class="mr-3 text-plain d-flex justify-content-center align-items-center"
|
||||
>
|
||||
<icon-stub
|
||||
name="close"
|
||||
size="18"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="overflow-hidden d-flex align-items-center"
|
||||
>
|
||||
<h2
|
||||
class="m-0 str-truncated-100 gl-font-base"
|
||||
>
|
||||
test.jpg
|
||||
</h2>
|
||||
|
||||
<small
|
||||
class="text-secondary"
|
||||
>
|
||||
Updated 1 hour ago by Test Name
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<pagination-stub
|
||||
class="ml-auto flex-shrink-0"
|
||||
id="1"
|
||||
/>
|
||||
|
||||
<gl-deprecated-button-stub
|
||||
class="mr-2"
|
||||
href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d"
|
||||
size="md"
|
||||
variant="secondary"
|
||||
>
|
||||
<icon-stub
|
||||
name="download"
|
||||
size="18"
|
||||
/>
|
||||
</gl-deprecated-button-stub>
|
||||
|
||||
<delete-button-stub
|
||||
buttonclass=""
|
||||
buttonvariant="danger"
|
||||
hasselecteddesigns="true"
|
||||
>
|
||||
<icon-stub
|
||||
name="remove"
|
||||
size="18"
|
||||
/>
|
||||
</delete-button-stub>
|
||||
</header>
|
||||
`;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Design management pagination button component disables button when no design is passed 1`] = `
|
||||
<router-link-stub
|
||||
aria-label="Test title"
|
||||
class="btn btn-default disabled"
|
||||
disabled="true"
|
||||
to="[object Object]"
|
||||
>
|
||||
<icon-stub
|
||||
name="angle-right"
|
||||
size="16"
|
||||
/>
|
||||
</router-link-stub>
|
||||
`;
|
||||
|
||||
exports[`Design management pagination button component renders router-link 1`] = `
|
||||
<router-link-stub
|
||||
aria-label="Test title"
|
||||
class="btn btn-default"
|
||||
to="[object Object]"
|
||||
>
|
||||
<icon-stub
|
||||
name="angle-right"
|
||||
size="16"
|
||||
/>
|
||||
</router-link-stub>
|
||||
`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue