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

Make a cleanup after Design Management feature flag is removed

parent 9c62516d
...@@ -4,7 +4,7 @@ import App from './components/app.vue'; ...@@ -4,7 +4,7 @@ import App from './components/app.vue';
import apolloProvider from './graphql'; import apolloProvider from './graphql';
export default () => { export default () => {
const el = document.querySelector('.js-design-management-new'); const el = document.querySelector('.js-design-management');
const { issueIid, projectPath, issuePath } = el.dataset; const { issueIid, projectPath, issuePath } = el.dataset;
const router = createRouter(issuePath); const router = createRouter(issuePath);
......
<script>
import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash';
export default {
name: 'DeleteButton',
components: {
GlDeprecatedButton,
GlModal,
},
directives: {
GlModalDirective,
},
props: {
isDeleting: {
type: Boolean,
required: false,
default: false,
},
buttonClass: {
type: String,
required: false,
default: '',
},
buttonVariant: {
type: String,
required: false,
default: '',
},
hasSelectedDesigns: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
modalId: uniqueId('design-deletion-confirmation-'),
};
},
};
</script>
<template>
<div>
<gl-modal
:modal-id="modalId"
:title="s__('DesignManagement|Delete designs confirmation')"
:ok-title="s__('DesignManagement|Delete')"
ok-variant="danger"
@ok="$emit('deleteSelectedDesigns')"
>
<p>{{ s__('DesignManagement|Are you sure you want to delete the selected designs?') }}</p>
</gl-modal>
<gl-deprecated-button
v-gl-modal-directive="modalId"
:variant="buttonVariant"
:disabled="isDeleting || !hasSelectedDesigns"
:class="buttonClass"
>
<slot></slot>
</gl-deprecated-button>
</div>
</template>
<script>
import { ApolloMutation } from 'vue-apollo';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql';
import { updateStoreAfterDesignsDelete } from '../utils/cache_update';
export default {
components: {
ApolloMutation,
},
props: {
filenames: {
type: Array,
required: true,
},
projectPath: {
type: String,
required: true,
},
iid: {
type: String,
required: true,
},
},
computed: {
projectQueryBody() {
return {
query: getDesignListQuery,
variables: { fullPath: this.projectPath, iid: this.iid, atVersion: null },
};
},
},
methods: {
updateStoreAfterDelete(
store,
{
data: { designManagementDelete },
},
) {
updateStoreAfterDesignsDelete(
store,
designManagementDelete,
this.projectQueryBody,
this.filenames,
);
},
},
destroyDesignMutation,
};
</script>
<template>
<apollo-mutation
#default="{ mutate, loading, error }"
:mutation="$options.destroyDesignMutation"
:variables="{
filenames,
projectPath,
iid,
}"
:update="updateStoreAfterDelete"
v-on="$listeners"
>
<slot v-bind="{ mutate, loading, error }"></slot>
</apollo-mutation>
</template>
<script>
import { GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
export default {
name: 'DesignNotePin',
components: {
GlIcon,
},
props: {
position: {
type: Object,
required: true,
},
label: {
type: Number,
required: false,
default: null,
},
repositioning: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isNewNote() {
return this.label === null;
},
pinStyle() {
return this.repositioning ? { ...this.position, cursor: 'move' } : this.position;
},
pinLabel() {
return this.isNewNote
? __('Comment form position')
: sprintf(__("Comment '%{label}' position"), { label: this.label });
},
},
};
</script>
<template>
<button
:style="pinStyle"
:aria-label="pinLabel"
:class="{
'btn-transparent comment-indicator': isNewNote,
'js-image-badge badge badge-pill': !isNewNote,
}"
class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0"
type="button"
@mousedown="$emit('mousedown', $event)"
@mouseup="$emit('mouseup', $event)"
@click="$emit('click', $event)"
>
<gl-icon v-if="isNewNote" name="image-comment-dark" :size="24" />
<template v-else>
{{ label }}
</template>
</button>
</template>
<script>
import { ApolloMutation } from 'vue-apollo';
import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import allVersionsMixin from '../../mixins/all_versions';
import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql';
import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql';
import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql';
import DesignNote from './design_note.vue';
import DesignReplyForm from './design_reply_form.vue';
import { updateStoreAfterAddDiscussionComment } from '../../utils/cache_update';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
import ToggleRepliesWidget from './toggle_replies_widget.vue';
export default {
components: {
ApolloMutation,
DesignNote,
ReplyPlaceholder,
DesignReplyForm,
GlIcon,
GlLoadingIcon,
GlLink,
ToggleRepliesWidget,
TimeAgoTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [allVersionsMixin],
props: {
discussion: {
type: Object,
required: true,
},
noteableId: {
type: String,
required: true,
},
designId: {
type: String,
required: true,
},
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
resolvedDiscussionsExpanded: {
type: Boolean,
required: true,
},
discussionWithOpenForm: {
type: String,
required: true,
},
},
apollo: {
activeDiscussion: {
query: activeDiscussionQuery,
result({ data }) {
const discussionId = data.activeDiscussion.id;
if (this.discussion.resolved && !this.resolvedDiscussionsExpanded) {
return;
}
// We watch any changes to the active discussion from the design pins and scroll to this discussion if it exists
// We don't want scrollIntoView to be triggered from the discussion click itself
if (
discussionId &&
data.activeDiscussion.source === ACTIVE_DISCUSSION_SOURCE_TYPES.pin &&
discussionId === this.discussion.notes[0].id
) {
this.$el.scrollIntoView({
behavior: 'smooth',
inline: 'start',
});
}
},
},
},
data() {
return {
discussionComment: '',
isFormRendered: false,
activeDiscussion: {},
isResolving: false,
shouldChangeResolvedStatus: false,
areRepliesCollapsed: this.discussion.resolved,
};
},
computed: {
mutationPayload() {
return {
noteableId: this.noteableId,
body: this.discussionComment,
discussionId: this.discussion.id,
};
},
designVariables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
filenames: [this.$route.params.id],
atVersion: this.designsVersion,
};
},
isDiscussionHighlighted() {
return this.discussion.notes[0].id === this.activeDiscussion.id;
},
resolveCheckboxText() {
return this.discussion.resolved
? s__('DesignManagement|Unresolve thread')
: s__('DesignManagement|Resolve thread');
},
firstNote() {
return this.discussion.notes[0];
},
discussionReplies() {
return this.discussion.notes.slice(1);
},
areRepliesShown() {
return !this.discussion.resolved || !this.areRepliesCollapsed;
},
resolveIconName() {
return this.discussion.resolved ? 'check-circle-filled' : 'check-circle';
},
isRepliesWidgetVisible() {
return this.discussion.resolved && this.discussionReplies.length > 0;
},
isReplyPlaceholderVisible() {
return this.areRepliesShown || !this.discussionReplies.length;
},
isFormVisible() {
return this.isFormRendered && this.discussionWithOpenForm === this.discussion.id;
},
},
methods: {
addDiscussionComment(
store,
{
data: { createNote },
},
) {
updateStoreAfterAddDiscussionComment(
store,
createNote,
getDesignQuery,
this.designVariables,
this.discussion.id,
);
},
onDone() {
this.discussionComment = '';
this.hideForm();
if (this.shouldChangeResolvedStatus) {
this.toggleResolvedStatus();
}
},
onCreateNoteError(err) {
this.$emit('createNoteError', err);
},
hideForm() {
this.isFormRendered = false;
this.discussionComment = '';
},
showForm() {
this.$emit('openForm', this.discussion.id);
this.isFormRendered = true;
},
toggleResolvedStatus() {
this.isResolving = true;
this.$apollo
.mutate({
mutation: toggleResolveDiscussionMutation,
variables: { id: this.discussion.id, resolve: !this.discussion.resolved },
})
.then(({ data }) => {
if (data.errors?.length > 0) {
this.$emit('resolveDiscussionError', data.errors[0]);
}
})
.catch(err => {
this.$emit('resolveDiscussionError', err);
})
.finally(() => {
this.isResolving = false;
});
},
},
createNoteMutation,
};
</script>
<template>
<div class="design-discussion-wrapper">
<div
class="badge badge-pill gl-display-flex gl-align-items-center gl-justify-content-center"
:class="{ resolved: discussion.resolved }"
type="button"
>
{{ discussion.index }}
</div>
<ul
class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none"
data-qa-selector="design_discussion_content"
>
<design-note
:note="firstNote"
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
:class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
@error="$emit('updateNoteError', $event)"
>
<template v-if="discussion.resolvable" #resolveDiscussion>
<button
v-gl-tooltip
:class="{ 'is-active': discussion.resolved }"
:title="resolveCheckboxText"
:aria-label="resolveCheckboxText"
type="button"
class="line-resolve-btn note-action-button gl-mr-3"
data-testid="resolve-button"
@click.stop="toggleResolvedStatus"
>
<gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" />
<gl-loading-icon v-else inline />
</button>
</template>
<template v-if="discussion.resolved" #resolvedStatus>
<p class="gl-text-gray-500 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message">
{{ __('Resolved by') }}
<gl-link
class="gl-text-gray-500 gl-text-decoration-none gl-font-sm link-inherit-color"
:href="discussion.resolvedBy.webUrl"
target="_blank"
>{{ discussion.resolvedBy.name }}</gl-link
>
<time-ago-tooltip :time="discussion.resolvedAt" tooltip-placement="bottom" />
</p>
</template>
</design-note>
<toggle-replies-widget
v-if="isRepliesWidgetVisible"
:collapsed="areRepliesCollapsed"
:replies="discussionReplies"
@toggle="areRepliesCollapsed = !areRepliesCollapsed"
/>
<design-note
v-for="note in discussionReplies"
v-show="areRepliesShown"
:key="note.id"
:note="note"
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
:class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
@error="$emit('updateNoteError', $event)"
/>
<li v-show="isReplyPlaceholderVisible" class="reply-wrapper">
<reply-placeholder
v-if="!isFormVisible"
class="qa-discussion-reply"
:button-text="__('Reply...')"
@onClick="showForm"
/>
<apollo-mutation
v-else
#default="{ mutate, loading }"
:mutation="$options.createNoteMutation"
:variables="{
input: mutationPayload,
}"
:update="addDiscussionComment"
@done="onDone"
@error="onCreateNoteError"
>
<design-reply-form
v-model="discussionComment"
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
@submitForm="mutate"
@cancelForm="hideForm"
>
<template v-if="discussion.resolvable" #resolveCheckbox>
<label data-testid="resolve-checkbox">
<input v-model="shouldChangeResolvedStatus" type="checkbox" />
{{ resolveCheckboxText }}
</label>
</template>
</design-reply-form>
</apollo-mutation>
</li>
</ul>
</div>
</template>
<script>
/* eslint-disable vue/no-v-html */
import { ApolloMutation } from 'vue-apollo';
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import DesignReplyForm from './design_reply_form.vue';
import { findNoteId } from '../../utils/design_management_utils';
import { hasErrors } from '../../utils/cache_update';
export default {
components: {
UserAvatarLink,
TimelineEntryItem,
TimeAgoTooltip,
DesignReplyForm,
ApolloMutation,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
note: {
type: Object,
required: true,
},
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
noteText: this.note.body,
isEditing: false,
};
},
computed: {
author() {
return this.note.author;
},
noteAnchorId() {
return findNoteId(this.note.id);
},
isNoteLinked() {
return this.$route.hash === `#note_${this.noteAnchorId}`;
},
mutationPayload() {
return {
id: this.note.id,
body: this.noteText,
};
},
isEditButtonVisible() {
return !this.isEditing && this.note.userPermissions.adminNote;
},
},
mounted() {
if (this.isNoteLinked) {
this.$refs.anchor.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' });
}
},
methods: {
hideForm() {
this.isEditing = false;
this.noteText = this.note.body;
},
onDone({ data }) {
this.hideForm();
if (hasErrors(data.updateNote)) {
this.$emit('error', data.errors[0]);
}
},
},
updateNoteMutation,
};
</script>
<template>
<timeline-entry-item :id="`note_${noteAnchorId}`" ref="anchor" class="design-note note-form">
<user-avatar-link
:link-href="author.webUrl"
:img-src="author.avatarUrl"
:img-alt="author.username"
:img-size="40"
/>
<div class="d-flex justify-content-between">
<div>
<a
v-once
:href="author.webUrl"
class="js-user-link"
:data-user-id="author.id"
:data-username="author.username"
>
<span class="note-header-author-name bold">{{ author.name }}</span>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
<span class="note-headline-light">@{{ author.username }}</span>
</a>
<span class="note-headline-light note-headline-meta">
<span class="system-note-message"> <slot></slot> </span>
<template v-if="note.createdAt">
<span class="system-note-separator"></span>
<a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`">
<time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
</a>
</template>
</span>
</div>
<div class="gl-display-flex">
<slot name="resolveDiscussion"></slot>
<button
v-if="isEditButtonVisible"
v-gl-tooltip
type="button"
:title="__('Edit comment')"
class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
@click="isEditing = true"
>
<gl-icon name="pencil" class="link-highlight" />
</button>
</div>
</div>
<template v-if="!isEditing">
<div
class="note-text js-note-text md"
data-qa-selector="note_content"
v-html="note.bodyHtml"
></div>
<slot name="resolvedStatus"></slot>
</template>
<apollo-mutation
v-else
#default="{ mutate, loading }"
:mutation="$options.updateNoteMutation"
:variables="{
input: mutationPayload,
}"
@error="$emit('error', $event)"
@done="onDone"
>
<design-reply-form
v-model="noteText"
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
:is-new-comment="false"
class="mt-5"
@submitForm="mutate"
@cancelForm="hideForm"
/>
</apollo-mutation>
</timeline-entry-item>
</template>
<script>
import { GlDeprecatedButton, GlModal } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { s__ } from '~/locale';
export default {
name: 'DesignReplyForm',
components: {
MarkdownField,
GlDeprecatedButton,
GlModal,
},
props: {
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
value: {
type: String,
required: true,
},
isSaving: {
type: Boolean,
required: true,
},
isNewComment: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
formText: this.value,
};
},
computed: {
hasValue() {
return this.value.trim().length > 0;
},
modalSettings() {
if (this.isNewComment) {
return {
title: s__('DesignManagement|Cancel comment confirmation'),
okTitle: s__('DesignManagement|Discard comment'),
cancelTitle: s__('DesignManagement|Keep comment'),
content: s__('DesignManagement|Are you sure you want to cancel creating this comment?'),
};
}
return {
title: s__('DesignManagement|Cancel comment update confirmation'),
okTitle: s__('DesignManagement|Cancel changes'),
cancelTitle: s__('DesignManagement|Keep changes'),
content: s__('DesignManagement|Are you sure you want to cancel changes to this comment?'),
};
},
buttonText() {
return this.isNewComment
? s__('DesignManagement|Comment')
: s__('DesignManagement|Save comment');
},
},
mounted() {
this.focusInput();
},
methods: {
submitForm() {
if (this.hasValue) this.$emit('submitForm');
},
cancelComment() {
if (this.hasValue && this.formText !== this.value) {
this.$refs.cancelCommentModal.show();
} else {
this.$emit('cancelForm');
}
},
focusInput() {
this.$refs.textarea.focus();
},
},
};
</script>
<template>
<form class="new-note common-note-form" @submit.prevent>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:can-attach-file="false"
:enable-autocomplete="true"
:textarea-value="value"
markdown-docs-path="/help/user/markdown"
class="bordered-box"
>
<template #textarea>
<textarea
ref="textarea"
:value="value"
class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
data-supports-quick-actions="false"
data-qa-selector="note_textarea"
:aria-label="__('Description')"
:placeholder="__('Write a comment…')"
@input="$emit('input', $event.target.value)"
@keydown.meta.enter="submitForm"
@keydown.ctrl.enter="submitForm"
@keyup.esc.stop="cancelComment"
>
</textarea>
</template>
</markdown-field>
<slot name="resolveCheckbox"></slot>
<div class="note-form-actions gl-display-flex gl-justify-content-space-between">
<gl-deprecated-button
ref="submitButton"
:disabled="!hasValue || isSaving"
variant="success"
type="submit"
data-track-event="click_button"
data-qa-selector="save_comment_button"
@click="$emit('submitForm')"
>
{{ buttonText }}
</gl-deprecated-button>
<gl-deprecated-button ref="cancelButton" @click="cancelComment">{{
__('Cancel')
}}</gl-deprecated-button>
</div>
<gl-modal
ref="cancelCommentModal"
ok-variant="danger"
:title="modalSettings.title"
:ok-title="modalSettings.okTitle"
:cancel-title="modalSettings.cancelTitle"
modal-id="cancel-comment-modal"
@ok="$emit('cancelForm')"
>{{ modalSettings.content }}
</gl-modal>
</form>
</template>
<script>
import { GlIcon, GlButton, GlLink } from '@gitlab/ui';
import { __, n__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'ToggleNotesWidget',
components: {
GlIcon,
GlButton,
GlLink,
TimeAgoTooltip,
},
props: {
collapsed: {
type: Boolean,
required: true,
},
replies: {
type: Array,
required: true,
},
},
computed: {
lastReply() {
return this.replies[this.replies.length - 1];
},
iconName() {
return this.collapsed ? 'chevron-right' : 'chevron-down';
},
toggleText() {
return this.collapsed
? `${this.replies.length} ${n__('reply', 'replies', this.replies.length)}`
: __('Collapse replies');
},
},
};
</script>
<template>
<li
class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3"
:class="{ expanded: !collapsed }"
data-testid="toggle-comments-wrapper"
>
<gl-icon :name="iconName" class="gl-ml-3" @click.stop="$emit('toggle')" />
<gl-button
variant="link"
class="toggle-comments-button gl-ml-2 gl-mr-2"
@click.stop="$emit('toggle')"
>
{{ toggleText }}
</gl-button>
<template v-if="collapsed">
<span class="gl-text-gray-500">{{ __('Last reply by') }}</span>
<gl-link
:href="lastReply.author.webUrl"
target="_blank"
class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2"
>
{{ lastReply.author.name }}
</gl-link>
<time-ago-tooltip
:time="lastReply.createdAt"
tooltip-placement="bottom"
class="gl-text-gray-500"
/>
</template>
</li>
</template>
<script>
import activeDiscussionQuery from '../graphql/queries/active_discussion.query.graphql';
import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
import DesignNotePin from './design_note_pin.vue';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
export default {
name: 'DesignOverlay',
components: {
DesignNotePin,
},
props: {
dimensions: {
type: Object,
required: true,
},
position: {
type: Object,
required: true,
},
notes: {
type: Array,
required: false,
default: () => [],
},
currentCommentForm: {
type: Object,
required: false,
default: null,
},
disableCommenting: {
type: Boolean,
required: false,
default: false,
},
resolvedDiscussionsExpanded: {
type: Boolean,
required: true,
},
},
apollo: {
activeDiscussion: {
query: activeDiscussionQuery,
},
},
data() {
return {
movingNoteNewPosition: null,
movingNoteStartPosition: null,
activeDiscussion: {},
};
},
computed: {
overlayStyle() {
const cursor = this.disableCommenting ? 'unset' : undefined;
return {
cursor,
width: `${this.dimensions.width}px`,
height: `${this.dimensions.height}px`,
...this.position,
};
},
isMovingCurrentComment() {
return Boolean(this.movingNoteStartPosition && !this.movingNoteStartPosition.noteId);
},
currentCommentPositionStyle() {
return this.isMovingCurrentComment && this.movingNoteNewPosition
? this.getNotePositionStyle(this.movingNoteNewPosition)
: this.getNotePositionStyle(this.currentCommentForm);
},
},
methods: {
setNewNoteCoordinates({ x, y }) {
this.$emit('openCommentForm', { x, y });
},
getNoteRelativePosition(position) {
const { x, y, width, height } = position;
const widthRatio = this.dimensions.width / width;
const heightRatio = this.dimensions.height / height;
return {
left: Math.round(x * widthRatio),
top: Math.round(y * heightRatio),
};
},
getNotePositionStyle(position) {
const { left, top } = this.getNoteRelativePosition(position);
return {
left: `${left}px`,
top: `${top}px`,
};
},
getMovingNotePositionDelta(e) {
let deltaX = 0;
let deltaY = 0;
if (this.movingNoteStartPosition) {
const { clientX, clientY } = this.movingNoteStartPosition;
deltaX = e.clientX - clientX;
deltaY = e.clientY - clientY;
}
return {
deltaX,
deltaY,
};
},
isMovingNote(noteId) {
const movingNoteId = this.movingNoteStartPosition?.noteId;
return Boolean(movingNoteId && movingNoteId === noteId);
},
canMoveNote(note) {
const { userPermissions } = note;
const { adminNote } = userPermissions || {};
return Boolean(adminNote);
},
isPositionInOverlay(position) {
const { top, left } = this.getNoteRelativePosition(position);
const { height, width } = this.dimensions;
return top >= 0 && top <= height && left >= 0 && left <= width;
},
onNewNoteMove(e) {
if (!this.isMovingCurrentComment) return;
const { deltaX, deltaY } = this.getMovingNotePositionDelta(e);
const x = this.currentCommentForm.x + deltaX;
const y = this.currentCommentForm.y + deltaY;
const movingNoteNewPosition = {
x,
y,
width: this.dimensions.width,
height: this.dimensions.height,
};
if (!this.isPositionInOverlay(movingNoteNewPosition)) {
this.onNewNoteMouseup();
return;
}
this.movingNoteNewPosition = movingNoteNewPosition;
},
onExistingNoteMove(e) {
const note = this.notes.find(({ id }) => id === this.movingNoteStartPosition.noteId);
if (!note || !this.canMoveNote(note)) return;
const { position } = note;
const { width, height } = position;
const widthRatio = this.dimensions.width / width;
const heightRatio = this.dimensions.height / height;
const { deltaX, deltaY } = this.getMovingNotePositionDelta(e);
const x = position.x * widthRatio + deltaX;
const y = position.y * heightRatio + deltaY;
const movingNoteNewPosition = {
x,
y,
width: this.dimensions.width,
height: this.dimensions.height,
};
if (!this.isPositionInOverlay(movingNoteNewPosition)) {
this.onExistingNoteMouseup();
return;
}
this.movingNoteNewPosition = movingNoteNewPosition;
},
onNewNoteMouseup() {
if (!this.movingNoteNewPosition) return;
const { x, y } = this.movingNoteNewPosition;
this.setNewNoteCoordinates({ x, y });
},
onExistingNoteMouseup(note) {
if (!this.movingNoteStartPosition || !this.movingNoteNewPosition) {
this.updateActiveDiscussion(note.id);
this.$emit('closeCommentForm');
return;
}
const { x, y } = this.movingNoteNewPosition;
this.$emit('moveNote', {
noteId: this.movingNoteStartPosition.noteId,
discussionId: this.movingNoteStartPosition.discussionId,
coordinates: { x, y },
});
},
onNoteMousedown({ clientX, clientY }, note) {
this.movingNoteStartPosition = {
noteId: note?.id,
discussionId: note?.discussion.id,
clientX,
clientY,
};
},
onOverlayMousemove(e) {
if (!this.movingNoteStartPosition) return;
if (this.isMovingCurrentComment) {
this.onNewNoteMove(e);
} else {
this.onExistingNoteMove(e);
}
},
onNoteMouseup(note) {
if (!this.movingNoteStartPosition) return;
if (this.isMovingCurrentComment) {
this.onNewNoteMouseup();
} else {
this.onExistingNoteMouseup(note);
}
this.movingNoteStartPosition = null;
this.movingNoteNewPosition = null;
},
onAddCommentMouseup({ offsetX, offsetY }) {
if (this.disableCommenting) return;
if (this.activeDiscussion.id) {
this.updateActiveDiscussion();
}
this.setNewNoteCoordinates({ x: offsetX, y: offsetY });
},
updateActiveDiscussion(id) {
this.$apollo.mutate({
mutation: updateActiveDiscussionMutation,
variables: {
id,
source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin,
},
});
},
isNoteInactive(note) {
return this.activeDiscussion.id && this.activeDiscussion.id !== note.id;
},
designPinClass(note) {
return { inactive: this.isNoteInactive(note), resolved: note.resolved };
},
},
};
</script>
<template>
<div
class="position-absolute image-diff-overlay frame"
:style="overlayStyle"
@mousemove="onOverlayMousemove"
@mouseleave="onNoteMouseup"
>
<button
v-show="!disableCommenting"
type="button"
class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button"
data-qa-selector="design_image_button"
@mouseup="onAddCommentMouseup"
></button>
<template v-for="note in notes">
<design-note-pin
v-if="resolvedDiscussionsExpanded || !note.resolved"
:key="note.id"
:label="note.index"
:repositioning="isMovingNote(note.id)"
:position="
isMovingNote(note.id) && movingNoteNewPosition
? getNotePositionStyle(movingNoteNewPosition)
: getNotePositionStyle(note.position)
"
:class="designPinClass(note)"
@mousedown.stop="onNoteMousedown($event, note)"
@mouseup.stop="onNoteMouseup(note)"
/>
</template>
<design-note-pin
v-if="currentCommentForm"
:position="currentCommentPositionStyle"
:repositioning="isMovingCurrentComment"
@mousedown.stop="onNoteMousedown"
@mouseup.stop="onNoteMouseup"
/>
</div>
</template>
<script>
import { GlIcon } from '@gitlab/ui';
const SCALE_STEP_SIZE = 0.2;
const DEFAULT_SCALE = 1;
const MIN_SCALE = 1;
const MAX_SCALE = 2;
export default {
components: {
GlIcon,
},
data() {
return {
scale: DEFAULT_SCALE,
};
},
computed: {
disableReset() {
return this.scale <= MIN_SCALE;
},
disableDecrease() {
return this.scale === DEFAULT_SCALE;
},
disableIncrease() {
return this.scale >= MAX_SCALE;
},
},
methods: {
setScale(scale) {
if (scale < MIN_SCALE) {
return;
}
this.scale = Math.round(scale * 100) / 100;
this.$emit('scale', this.scale);
},
incrementScale() {
this.setScale(this.scale + SCALE_STEP_SIZE);
},
decrementScale() {
this.setScale(this.scale - SCALE_STEP_SIZE);
},
resetScale() {
this.setScale(DEFAULT_SCALE);
},
},
};
</script>
<template>
<div class="design-scaler btn-group" role="group">
<button class="btn" :disabled="disableDecrease" @click="decrementScale">
<span class="d-flex-center gl-icon s16">
</span>
</button>
<button class="btn" :disabled="disableReset" @click="resetScale">
<gl-icon name="redo" />
</button>
<button class="btn" :disabled="disableIncrease" @click="incrementScale">
<gl-icon name="plus" />
</button>
</div>
</template>
<script>
import Cookies from 'js-cookie';
import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui';
import { s__ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
import { extractDiscussions, extractParticipants } from '../utils/design_management_utils';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
import DesignDiscussion from './design_notes/design_discussion.vue';
import Participants from '~/sidebar/components/participants/participants.vue';
export default {
components: {
DesignDiscussion,
Participants,
GlCollapse,
GlButton,
GlPopover,
},
props: {
design: {
type: Object,
required: true,
},
resolvedDiscussionsExpanded: {
type: Boolean,
required: true,
},
markdownPreviewPath: {
type: String,
required: true,
},
},
data() {
return {
isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)),
discussionWithOpenForm: '',
};
},
computed: {
discussions() {
return extractDiscussions(this.design.discussions);
},
issue() {
return {
...this.design.issue,
webPath: this.design.issue.webPath.substr(1),
};
},
discussionParticipants() {
return extractParticipants(this.issue.participants);
},
resolvedDiscussions() {
return this.discussions.filter(discussion => discussion.resolved);
},
unresolvedDiscussions() {
return this.discussions.filter(discussion => !discussion.resolved);
},
resolvedCommentsToggleIcon() {
return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right';
},
},
methods: {
handleSidebarClick() {
this.isResolvedCommentsPopoverHidden = true;
Cookies.set(this.$options.cookieKey, 'true', { expires: 365 * 10 });
this.updateActiveDiscussion();
},
updateActiveDiscussion(id) {
this.$apollo.mutate({
mutation: updateActiveDiscussionMutation,
variables: {
id,
source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion,
},
});
},
closeCommentForm() {
this.comment = '';
this.$emit('closeCommentForm');
},
updateDiscussionWithOpenForm(id) {
this.discussionWithOpenForm = id;
},
},
resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
cookieKey: 'hide_design_resolved_comments_popover',
};
</script>
<template>
<div class="image-notes" @click="handleSidebarClick">
<h2 class="gl-font-weight-bold gl-mt-0">
{{ issue.title }}
</h2>
<a
class="gl-text-gray-400 gl-text-decoration-none gl-mb-6 gl-display-block"
:href="issue.webUrl"
>{{ issue.webPath }}</a
>
<participants
:participants="discussionParticipants"
:show-participant-label="false"
class="gl-mb-4"
/>
<h2
v-if="unresolvedDiscussions.length === 0"
class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4"
data-testid="new-discussion-disclaimer"
>
{{ s__("DesignManagement|Click the image where you'd like to start a new discussion") }}
</h2>
<design-discussion
v-for="discussion in unresolvedDiscussions"
:key="discussion.id"
:discussion="discussion"
:design-id="$route.params.id"
:noteable-id="design.id"
:markdown-preview-path="markdownPreviewPath"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
:discussion-with-open-form="discussionWithOpenForm"
data-testid="unresolved-discussion"
@createNoteError="$emit('onDesignDiscussionError', $event)"
@updateNoteError="$emit('updateNoteError', $event)"
@resolveDiscussionError="$emit('resolveDiscussionError', $event)"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
@openForm="updateDiscussionWithOpenForm"
/>
<template v-if="resolvedDiscussions.length > 0">
<gl-button
id="resolved-comments"
data-testid="resolved-comments"
:icon="resolvedCommentsToggleIcon"
variant="link"
class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
@click="$emit('toggleResolvedComments')"
>{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }})
</gl-button>
<gl-popover
v-if="!isResolvedCommentsPopoverHidden"
:show="!isResolvedCommentsPopoverHidden"
target="resolved-comments"
container="popovercontainer"
placement="top"
:title="s__('DesignManagement|Resolved Comments')"
>
<p>
{{
s__(
'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below',
)
}}
</p>
<a href="#" rel="noopener noreferrer" target="_blank">{{
s__('DesignManagement|Learn more about resolving comments')
}}</a>
</gl-popover>
<gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3">
<design-discussion
v-for="discussion in resolvedDiscussions"
:key="discussion.id"
:discussion="discussion"
:design-id="$route.params.id"
:noteable-id="design.id"
:markdown-preview-path="markdownPreviewPath"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
:discussion-with-open-form="discussionWithOpenForm"
data-testid="resolved-discussion"
@error="$emit('onDesignDiscussionError', $event)"
@updateNoteError="$emit('updateNoteError', $event)"
@openForm="updateDiscussionWithOpenForm"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
/>
</gl-collapse>
</template>
<slot name="replyForm"></slot>
</div>
</template>
<script>
import { throttle } from 'lodash';
import { GlIcon } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
props: {
image: {
type: String,
required: false,
default: '',
},
name: {
type: String,
required: false,
default: '',
},
scale: {
type: Number,
required: false,
default: 1,
},
},
data() {
return {
baseImageSize: null,
imageStyle: null,
imageError: false,
};
},
watch: {
scale(val) {
this.zoom(val);
},
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeThrottled, false);
},
mounted() {
this.onImgLoad();
this.resizeThrottled = throttle(() => {
// NOTE: if imageStyle is set, then baseImageSize
// won't change due to resize. We must still emit a
// `resize` event so that the parent can handle
// resizes appropriately (e.g. for design_overlay)
this.setBaseImageSize();
}, 400);
window.addEventListener('resize', this.resizeThrottled, false);
},
methods: {
onImgLoad() {
requestIdleCallback(this.setBaseImageSize, { timeout: 1000 });
},
onImgError() {
this.imageError = true;
},
setBaseImageSize() {
const { contentImg } = this.$refs;
if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return;
this.baseImageSize = {
height: contentImg.offsetHeight,
width: contentImg.offsetWidth,
};
this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height });
},
onResize({ width, height }) {
this.$emit('resize', { width, height });
},
zoom(amount) {
if (amount === 1) {
this.imageStyle = null;
this.$nextTick(() => {
this.setBaseImageSize();
});
return;
}
const width = this.baseImageSize.width * amount;
const height = this.baseImageSize.height * amount;
this.imageStyle = {
width: `${width}px`,
height: `${height}px`,
};
this.onResize({ width, height });
},
},
};
</script>
<template>
<div class="m-auto js-design-image">
<gl-icon v-if="imageError" class="text-secondary-100" name="media-broken" :size="48" />
<img
v-show="!imageError"
ref="contentImg"
class="mh-100"
:src="image"
:alt="name"
:style="imageStyle"
:class="{ 'img-fluid': !imageStyle }"
@error="onImgError"
@load="onImgLoad"
/>
</div>
</template>
<script>
import { GlLoadingIcon, GlIcon, GlIntersectionObserver } from '@gitlab/ui';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import { n__, __ } from '~/locale';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
components: {
GlLoadingIcon,
GlIntersectionObserver,
GlIcon,
Timeago,
},
props: {
id: {
type: [Number, String],
required: true,
},
event: {
type: String,
required: true,
},
notesCount: {
type: Number,
required: true,
},
image: {
type: String,
required: true,
},
filename: {
type: String,
required: true,
},
updatedAt: {
type: String,
required: false,
default: null,
},
isUploading: {
type: Boolean,
required: false,
default: true,
},
imageV432x230: {
type: String,
required: false,
default: null,
},
},
data() {
return {
imageLoading: true,
imageError: false,
wasInView: false,
};
},
computed: {
icon() {
const normalizedEvent = this.event.toLowerCase();
const icons = {
creation: {
name: 'file-addition-solid',
classes: 'text-success-500',
tooltip: __('Added in this version'),
},
modification: {
name: 'file-modified-solid',
classes: 'text-primary-500',
tooltip: __('Modified in this version'),
},
deletion: {
name: 'file-deletion-solid',
classes: 'text-danger-500',
tooltip: __('Deleted in this version'),
},
};
return icons[normalizedEvent] ? icons[normalizedEvent] : {};
},
notesLabel() {
return n__('%d comment', '%d comments', this.notesCount);
},
imageLink() {
return this.wasInView ? this.imageV432x230 || this.image : '';
},
showLoadingSpinner() {
return this.imageLoading || this.isUploading;
},
showImageErrorIcon() {
return this.wasInView && this.imageError;
},
showImage() {
return !this.showLoadingSpinner && !this.showImageErrorIcon;
},
},
methods: {
onImageLoad() {
this.imageLoading = false;
this.imageError = false;
},
onImageError() {
this.imageLoading = false;
this.imageError = true;
},
onAppear() {
// do nothing if image has previously
// been in view
if (this.wasInView) {
return;
}
this.wasInView = true;
this.imageLoading = true;
},
},
DESIGN_ROUTE_NAME,
};
</script>
<template>
<router-link
:to="{
name: $options.DESIGN_ROUTE_NAME,
params: { id: filename },
query: $route.query,
}"
class="card cursor-pointer text-plain js-design-list-item design-list-item"
>
<div class="card-body p-0 d-flex-center overflow-hidden position-relative">
<div v-if="icon.name" data-testid="designEvent" class="design-event position-absolute">
<span :title="icon.tooltip" :aria-label="icon.tooltip">
<gl-icon :name="icon.name" :size="18" :class="icon.classes" />
</span>
</div>
<gl-intersection-observer @appear="onAppear">
<gl-loading-icon v-if="showLoadingSpinner" size="md" />
<gl-icon
v-else-if="showImageErrorIcon"
name="media-broken"
class="text-secondary"
:size="32"
/>
<img
v-show="showImage"
:src="imageLink"
:alt="filename"
class="block mx-auto mw-100 mh-100 design-img"
data-qa-selector="design_image"
@load="onImageLoad"
@error="onImageError"
/>
</gl-intersection-observer>
</div>
<div class="card-footer d-flex w-100">
<div class="d-flex flex-column str-truncated-100">
<span class="bold str-truncated-100" data-qa-selector="design_file_name">{{
filename
}}</span>
<span v-if="updatedAt" class="str-truncated-100">
{{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" />
</span>
</div>
<div v-if="notesCount" class="ml-auto d-flex align-items-center text-secondary">
<gl-icon name="comments" class="ml-1" />
<span :aria-label="notesLabel" class="ml-1">
{{ notesCount }}
</span>
</div>
</div>
</router-link>
</template>
<script>
import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import Pagination from './pagination.vue';
import DeleteButton from '../delete_button.vue';
import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql';
import appDataQuery from '../../graphql/queries/app_data.query.graphql';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
export default {
components: {
GlIcon,
Pagination,
DeleteButton,
GlDeprecatedButton,
},
mixins: [timeagoMixin],
props: {
id: {
type: String,
required: true,
},
isDeleting: {
type: Boolean,
required: true,
},
filename: {
type: String,
required: false,
default: '',
},
updatedAt: {
type: String,
required: false,
default: null,
},
updatedBy: {
type: Object,
required: false,
default: () => ({}),
},
isLatestVersion: {
type: Boolean,
required: true,
},
image: {
type: String,
required: true,
},
},
data() {
return {
permissions: {
createDesign: false,
},
projectPath: '',
issueIid: null,
};
},
apollo: {
appData: {
query: appDataQuery,
manual: true,
result({ data: { projectPath, issueIid } }) {
this.projectPath = projectPath;
this.issueIid = issueIid;
},
},
permissions: {
query: permissionsQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
};
},
update: data => data.project.issue.userPermissions,
},
},
computed: {
updatedText() {
return sprintf(__('Updated %{updated_at} by %{updated_by}'), {
updated_at: this.timeFormatted(this.updatedAt),
updated_by: this.updatedBy.name,
});
},
canDeleteDesign() {
return this.permissions.createDesign;
},
},
DESIGNS_ROUTE_NAME,
};
</script>
<template>
<header class="d-flex p-2 bg-white align-items-center js-design-header">
<router-link
:to="{
name: $options.DESIGNS_ROUTE_NAME,
query: $route.query,
}"
:aria-label="s__('DesignManagement|Go back to designs')"
class="mr-3 text-plain d-flex justify-content-center align-items-center"
>
<gl-icon :size="18" name="close" />
</router-link>
<div class="overflow-hidden d-flex align-items-center">
<h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
<small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
</div>
<pagination :id="id" class="ml-auto flex-shrink-0" />
<gl-deprecated-button :href="image" class="mr-2">
<gl-icon :size="18" name="download" />
</gl-deprecated-button>
<delete-button
v-if="isLatestVersion && canDeleteDesign"
:is-deleting="isDeleting"
button-variant="danger"
@deleteSelectedDesigns="$emit('delete')"
>
<gl-icon :size="18" name="remove" />
</delete-button>
</header>
</template>
<script>
/* global Mousetrap */
import 'mousetrap';
import { s__, sprintf } from '~/locale';
import PaginationButton from './pagination_button.vue';
import allDesignsMixin from '../../mixins/all_designs';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
components: {
PaginationButton,
},
mixins: [allDesignsMixin],
props: {
id: {
type: String,
required: true,
},
},
computed: {
designsCount() {
return this.designs.length;
},
currentIndex() {
return this.designs.findIndex(design => design.filename === this.id);
},
paginationText() {
return sprintf(s__('DesignManagement|%{current_design} of %{designs_count}'), {
current_design: this.currentIndex + 1,
designs_count: this.designsCount,
});
},
previousDesign() {
if (!this.designsCount) return null;
return this.designs[this.currentIndex - 1];
},
nextDesign() {
if (!this.designsCount) return null;
return this.designs[this.currentIndex + 1];
},
},
mounted() {
Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign));
Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign));
},
beforeDestroy() {
Mousetrap.unbind(['left', 'right'], this.navigateToDesign);
},
methods: {
navigateToDesign(design) {
if (design) {
this.$router.push({
name: DESIGN_ROUTE_NAME,
params: { id: design.filename },
query: this.$route.query,
});
}
},
},
};
</script>
<template>
<div v-if="designsCount" class="d-flex align-items-center">
{{ paginationText }}
<div class="btn-group ml-3 mr-3">
<pagination-button
:design="previousDesign"
:title="s__('DesignManagement|Go to previous design')"
icon-name="angle-left"
class="js-previous-design"
/>
<pagination-button
:design="nextDesign"
:title="s__('DesignManagement|Go to next design')"
icon-name="angle-right"
class="js-next-design"
/>
</div>
</div>
</template>
<script>
import { GlIcon } from '@gitlab/ui';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
components: {
GlIcon,
},
props: {
design: {
type: Object,
required: false,
default: null,
},
title: {
type: String,
required: true,
},
iconName: {
type: String,
required: true,
},
},
computed: {
designLink() {
if (!this.design) return {};
return {
name: DESIGN_ROUTE_NAME,
params: { id: this.design.filename },
query: this.$route.query,
};
},
},
};
</script>
<template>
<router-link
:to="designLink"
:disabled="!design"
:class="{ disabled: !design }"
:aria-label="title"
class="btn btn-default"
>
<gl-icon :name="iconName" />
</router-link>
</template>
<script>
import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
export default {
components: {
GlDeprecatedButton,
GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
isSaving: {
type: Boolean,
required: true,
},
},
methods: {
openFileUpload() {
this.$refs.fileUpload.click();
},
onFileUploadChange(e) {
this.$emit('upload', e.target.files);
},
},
VALID_DESIGN_FILE_MIMETYPE,
};
</script>
<template>
<div>
<gl-deprecated-button
v-gl-tooltip.hover
:title="
s__(
'DesignManagement|Adding a design with the same filename replaces the file in a new version.',
)
"
:disabled="isSaving"
variant="success"
@click="openFileUpload"
>
{{ s__('DesignManagement|Upload designs') }}
<gl-loading-icon v-if="isSaving" inline class="ml-1" />
</gl-deprecated-button>
<input
ref="fileUpload"
type="file"
name="design_file"
:accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
class="hide"
multiple
@change="onFileUploadChange"
/>
</div>
</template>
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql';
import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages';
import { isValidDesignFile } from '../../utils/design_management_utils';
import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
export default {
components: {
GlIcon,
GlLink,
GlSprintf,
},
data() {
return {
dragCounter: 0,
isDragDataValid: false,
};
},
computed: {
dragging() {
return this.dragCounter !== 0;
},
},
methods: {
isValidUpload(files) {
return files.every(isValidDesignFile);
},
isValidDragDataType({ dataTransfer }) {
return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE));
},
ondrop({ dataTransfer = {} }) {
this.dragCounter = 0;
// User already had feedback when dropzone was active, so bail here
if (!this.isDragDataValid) {
return;
}
const { files } = dataTransfer;
if (!this.isValidUpload(Array.from(files))) {
createFlash(UPLOAD_DESIGN_INVALID_FILETYPE_ERROR);
return;
}
this.$emit('change', files);
},
ondragenter(e) {
this.dragCounter += 1;
this.isDragDataValid = this.isValidDragDataType(e);
},
ondragleave() {
this.dragCounter -= 1;
},
openFileUpload() {
this.$refs.fileUpload.click();
},
onDesignInputChange(e) {
this.$emit('change', e.target.files);
},
},
uploadDesignMutation,
VALID_DESIGN_FILE_MIMETYPE,
};
</script>
<template>
<div
class="w-100 position-relative"
@dragstart.prevent.stop
@dragend.prevent.stop
@dragover.prevent.stop
@dragenter.prevent.stop="ondragenter"
@dragleave.prevent.stop="ondragleave"
@drop.prevent.stop="ondrop"
>
<slot>
<button
class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
@click="openFileUpload"
>
<div class="d-flex-center flex-column text-center">
<gl-icon name="doc-new" :size="48" class="mb-4" />
<p>
<gl-sprintf
:message="
__(
'%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}.',
)
"
>
<template #lineOne="{ content }"
><span class="d-block">{{ content }}</span>
</template>
<template #link="{ content }">
<gl-link class="h-100 w-100" @click.stop="openFileUpload">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
</div>
</button>
<input
ref="fileUpload"
type="file"
name="design_file"
:accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
class="hide"
multiple
@change="onDesignInputChange"
/>
</slot>
<transition name="design-dropzone-fade">
<div
v-show="dragging"
class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
>
<div v-show="!isDragDataValid" class="mw-50 text-center">
<h3>{{ __('Oh no!') }}</h3>
<span>{{
__(
'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.',
)
}}</span>
</div>
<div v-show="isDragDataValid" class="mw-50 text-center">
<h3>{{ __('Incoming!') }}</h3>
<span>{{ __('Drop your designs to start your upload.') }}</span>
</div>
</div>
</transition>
</div>
</template>
<script>
import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import allVersionsMixin from '../../mixins/all_versions';
import { findVersionId } from '../../utils/design_management_utils';
export default {
components: {
GlDeprecatedDropdown,
GlDeprecatedDropdownItem,
},
mixins: [allVersionsMixin],
computed: {
queryVersion() {
return this.$route.query.version;
},
currentVersionIdx() {
if (!this.queryVersion) return 0;
const idx = this.allVersions.findIndex(
version => this.findVersionId(version.node.id) === this.queryVersion,
);
// if the currentVersionId isn't a valid version (i.e. not in allVersions)
// then return the latest version (index 0)
return idx !== -1 ? idx : 0;
},
currentVersionId() {
if (this.queryVersion) return this.queryVersion;
const currentVersion = this.allVersions[this.currentVersionIdx];
return this.findVersionId(currentVersion.node.id);
},
dropdownText() {
if (this.isLatestVersion) {
return __('Showing Latest Version');
}
// allVersions is sorted in reverse chronological order (latest first)
const currentVersionNumber = this.allVersions.length - this.currentVersionIdx;
return sprintf(__('Showing Version #%{versionNumber}'), {
versionNumber: currentVersionNumber,
});
},
},
methods: {
findVersionId,
},
};
</script>
<template>
<gl-deprecated-dropdown :text="dropdownText" variant="link" class="design-version-dropdown">
<gl-deprecated-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id">
<router-link
class="d-flex js-version-link"
:to="{ path: $route.path, query: { version: findVersionId(version.node.id) } }"
>
<div class="flex-grow-1 ml-2">
<div>
<strong
>{{ __('Version') }} {{ allVersions.length - index }}
<span v-if="findVersionId(version.node.id) === latestVersionId"
>({{ __('latest') }})</span
>
</strong>
</div>
</div>
<i
v-if="findVersionId(version.node.id) === currentVersionId"
class="fa fa-check float-right gl-mr-2"
></i>
</router-link>
</gl-deprecated-dropdown-item>
</gl-deprecated-dropdown>
</template>
// WARNING: replace this with something
// more sensical as per https://gitlab.com/gitlab-org/gitlab/issues/118611
export const VALID_DESIGN_FILE_MIMETYPE = {
mimetype: 'image/*',
regex: /image\/.+/,
};
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
export const VALID_DATA_TRANSFER_TYPE = 'Files';
export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
pin: 'pin',
discussion: 'discussion',
};
export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0'];
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { uniqueId } from 'lodash';
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql';
import typeDefs from './graphql/typedefs.graphql';
Vue.use(VueApollo);
const resolvers = {
Mutation: {
updateActiveDiscussion: (_, { id = null, source }, { cache }) => {
const data = cache.readQuery({ query: activeDiscussionQuery });
data.activeDiscussion = {
__typename: 'ActiveDiscussion',
id,
source,
};
cache.writeQuery({ query: activeDiscussionQuery, data });
},
},
};
const defaultClient = createDefaultClient(
resolvers,
// This config is added temporarily to resolve an issue with duplicate design IDs.
// Should be removed as soon as https://gitlab.com/gitlab-org/gitlab/issues/13495 is resolved
{
cacheConfig: {
dataIdFromObject: object => {
// eslint-disable-next-line no-underscore-dangle, @gitlab/require-i18n-strings
if (object.__typename === 'Design') {
return object.id && object.image ? `${object.id}-${object.image}` : uniqueId();
}
return defaultDataIdFromObject(object);
},
},
typeDefs,
},
);
export default new VueApollo({
defaultClient,
});
#import "./design_note.fragment.graphql"
#import "./design_list.fragment.graphql"
#import "./diff_refs.fragment.graphql"
#import "./discussion_resolved_status.fragment.graphql"
fragment DesignItem on Design {
...DesignListItem
fullPath
diffRefs {
...DesignDiffRefs
}
discussions {
nodes {
id
replyId
...ResolvedStatus
notes {
nodes {
...DesignNote
}
}
}
}
}
fragment DesignListItem on Design {
id
event
filename
notesCount
image
imageV432x230
}
#import "./diff_refs.fragment.graphql"
#import "~/graphql_shared/fragments/author.fragment.graphql"
#import "./note_permissions.fragment.graphql"
fragment DesignNote on Note {
id
author {
...Author
}
body
bodyHtml
createdAt
resolved
position {
diffRefs {
...DesignDiffRefs
}
x
y
height
width
}
userPermissions {
...DesignNotePermissions
}
discussion {
id
}
}
fragment ResolvedStatus on Discussion {
resolvable
resolved
resolvedAt
resolvedBy {
name
webUrl
}
}
#import "../fragments/design_note.fragment.graphql"
mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
createImageDiffNote(input: $input) {
note {
...DesignNote
discussion {
id
replyId
notes {
edges {
node {
...DesignNote
}
}
}
}
}
errors
}
}
#import "../fragments/design_note.fragment.graphql"
mutation createNote($input: CreateNoteInput!) {
createNote(input: $input) {
note {
...DesignNote
}
errors
}
}
#import "../fragments/version.fragment.graphql"
mutation destroyDesign($filenames: [String!]!, $projectPath: ID!, $iid: ID!) {
designManagementDelete(input: { projectPath: $projectPath, iid: $iid, filenames: $filenames }) {
version {
...VersionListItem
}
errors
}
}
#import "../fragments/design_note.fragment.graphql"
#import "../fragments/discussion_resolved_status.fragment.graphql"
mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) {
discussionToggleResolve(input: { id: $id, resolve: $resolve }) {
discussion {
id
...ResolvedStatus
notes {
nodes {
...DesignNote
}
}
}
errors
}
}
mutation updateActiveDiscussion($id: String, $source: String) {
updateActiveDiscussion(id: $id, source: $source) @client
}
#import "../fragments/design_note.fragment.graphql"
mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) {
updateImageDiffNote(input: $input) {
errors
note {
...DesignNote
}
}
}
#import "../fragments/design_note.fragment.graphql"
mutation updateNote($input: UpdateNoteInput!) {
updateNote(input: $input) {
note {
...DesignNote
}
errors
}
}
#import "../fragments/design.fragment.graphql"
mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) {
designs {
...DesignItem
versions {
edges {
node {
id
sha
}
}
}
}
skippedDesigns {
filename
}
errors
}
}
query permissions($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
id
issue(iid: $iid) {
userPermissions {
createDesign
}
}
}
}
#import "../fragments/design.fragment.graphql"
#import "~/graphql_shared/fragments/author.fragment.graphql"
query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [String!]) {
project(fullPath: $fullPath) {
id
issue(iid: $iid) {
designCollection {
designs(atVersion: $atVersion, filenames: $filenames) {
edges {
node {
...DesignItem
issue {
title
webPath
webUrl
participants {
edges {
node {
...Author
}
}
}
}
}
}
}
}
}
}
}
#import "../fragments/design_list.fragment.graphql"
#import "../fragments/version.fragment.graphql"
query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
project(fullPath: $fullPath) {
id
issue(iid: $iid) {
designCollection {
designs(atVersion: $atVersion) {
edges {
node {
...DesignListItem
}
}
}
versions {
edges {
node {
...VersionListItem
}
}
}
}
}
}
}
type ActiveDiscussion {
id: ID
source: String
}
extend type Query {
activeDiscussion: ActiveDiscussion
}
extend type Mutation {
updateActiveDiscussion(id: ID!, source: String!): Boolean
}
// This application is being moved, please do not touch this files
// Please see https://gitlab.com/gitlab-org/gitlab/-/issues/14744#note_364468096 for details
import $ from 'jquery';
import Vue from 'vue';
import createRouter from './router';
import App from './components/app.vue';
import apolloProvider from './graphql';
import getDesignListQuery from './graphql/queries/get_design_list.query.graphql';
import { DESIGNS_ROUTE_NAME, ROOT_ROUTE_NAME } from './router/constants';
export default () => {
const el = document.querySelector('.js-design-management');
const badge = document.querySelector('.js-designs-count');
const { issueIid, projectPath, issuePath } = el.dataset;
const router = createRouter(issuePath);
$('.js-issue-tabs').on('shown.bs.tab', ({ target: { id } }) => {
if (id === 'designs' && router.currentRoute.name === ROOT_ROUTE_NAME) {
router.push({ name: DESIGNS_ROUTE_NAME });
} else if (id === 'discussion') {
router.push({ name: ROOT_ROUTE_NAME });
}
});
apolloProvider.clients.defaultClient.cache.writeData({
data: {
projectPath,
issueIid,
activeDiscussion: {
__typename: 'ActiveDiscussion',
id: null,
source: null,
},
},
});
apolloProvider.clients.defaultClient
.watchQuery({
query: getDesignListQuery,
variables: {
fullPath: projectPath,
iid: issueIid,
atVersion: null,
},
})
.subscribe(({ data }) => {
if (badge) {
badge.textContent = data.project.issue.designCollection.designs.edges.length;
}
});
return new Vue({
el,
router,
apolloProvider,
render(createElement) {
return createElement(App);
},
});
};
import { propertyOf } from 'lodash';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__ } from '~/locale';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import { extractNodes } from '../utils/design_management_utils';
import allVersionsMixin from './all_versions';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
export default {
mixins: [allVersionsMixin],
apollo: {
designs: {
query: getDesignListQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
atVersion: this.designsVersion,
};
},
update: data => {
const designEdges = propertyOf(data)(['project', 'issue', 'designCollection', 'designs']);
if (designEdges) {
return extractNodes(designEdges);
}
return [];
},
error() {
this.error = true;
},
result() {
if (this.$route.query.version && !this.hasValidVersion) {
createFlash(
s__(
'DesignManagement|Requested design version does not exist. Showing latest version instead',
),
);
this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } });
}
},
},
},
data() {
return {
designs: [],
error: false,
};
},
};
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import appDataQuery from '../graphql/queries/app_data.query.graphql';
import { findVersionId } from '../utils/design_management_utils';
export default {
apollo: {
appData: {
query: appDataQuery,
manual: true,
result({ data: { projectPath, issueIid } }) {
this.projectPath = projectPath;
this.issueIid = issueIid;
},
},
allVersions: {
query: getDesignListQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
atVersion: null,
};
},
update: data => data.project.issue.designCollection.versions.edges,
},
},
computed: {
hasValidVersion() {
return (
this.$route.query.version &&
this.allVersions &&
this.allVersions.some(version => version.node.id.endsWith(this.$route.query.version))
);
},
designsVersion() {
return this.hasValidVersion
? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}`
: null;
},
latestVersionId() {
const latestVersion = this.allVersions[0];
return latestVersion && findVersionId(latestVersion.node.id);
},
isLatestVersion() {
if (this.allVersions.length > 0) {
return (
!this.$route.query.version ||
!this.latestVersionId ||
this.$route.query.version === this.latestVersionId
);
}
return true;
},
},
data() {
return {
allVersions: [],
projectPath: '',
issueIid: null,
};
},
};
export const ROOT_ROUTE_NAME = 'root';
export const DESIGNS_ROUTE_NAME = 'designs';
export const DESIGN_ROUTE_NAME = 'design';
import $ from 'jquery';
import Vue from 'vue';
import VueRouter from 'vue-router';
import routes from './routes';
import { DESIGN_ROUTE_NAME } from './constants';
import { getPageLayoutElement } from '~/design_management_legacy/utils/design_management_utils';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants';
Vue.use(VueRouter);
export default function createRouter(base) {
const router = new VueRouter({
base,
mode: 'history',
routes,
});
const pageEl = getPageLayoutElement();
router.beforeEach(({ meta: { el }, name }, _, next) => {
$(`#${el}`).tab('show');
// apply a fullscreen layout style in Design View (a.k.a design detail)
if (pageEl) {
if (name === DESIGN_ROUTE_NAME) {
pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
} else {
pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
}
}
next();
});
return router;
}
import Home from '../pages/index.vue';
import DesignDetail from '../pages/design/index.vue';
import { ROOT_ROUTE_NAME, DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants';
export default [
{
name: ROOT_ROUTE_NAME,
path: '/',
component: Home,
meta: {
el: 'discussion',
},
},
{
name: DESIGNS_ROUTE_NAME,
path: '/designs',
component: Home,
meta: {
el: 'designs',
},
children: [
{
name: DESIGN_ROUTE_NAME,
path: ':id',
component: DesignDetail,
meta: {
el: 'designs',
},
beforeEnter(
{
params: { id },
},
from,
next,
) {
if (typeof id === 'string') {
next();
}
},
props: ({ params: { id } }) => ({ id }),
},
],
},
];
/* eslint-disable @gitlab/require-i18n-strings */
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { extractCurrentDiscussion, extractDesign } from './design_management_utils';
import {
ADD_IMAGE_DIFF_NOTE_ERROR,
UPDATE_IMAGE_DIFF_NOTE_ERROR,
ADD_DISCUSSION_COMMENT_ERROR,
designDeletionError,
} from './error_messages';
const deleteDesignsFromStore = (store, query, selectedDesigns) => {
const data = store.readQuery(query);
const changedDesigns = data.project.issue.designCollection.designs.edges.filter(
({ node }) => !selectedDesigns.includes(node.filename),
);
data.project.issue.designCollection.designs.edges = [...changedDesigns];
store.writeQuery({
...query,
data,
});
};
/**
* Adds a new version of designs to store
*
* @param {Object} store
* @param {Object} query
* @param {Object} version
*/
const addNewVersionToStore = (store, query, version) => {
if (!version) return;
const data = store.readQuery(query);
const newEdge = { node: version, __typename: 'DesignVersionEdge' };
data.project.issue.designCollection.versions.edges = [
newEdge,
...data.project.issue.designCollection.versions.edges,
];
store.writeQuery({
...query,
data,
});
};
const addDiscussionCommentToStore = (store, createNote, query, queryVariables, discussionId) => {
const data = store.readQuery({
query,
variables: queryVariables,
});
const design = extractDesign(data);
const currentDiscussion = extractCurrentDiscussion(design.discussions, discussionId);
currentDiscussion.notes.nodes = [...currentDiscussion.notes.nodes, createNote.note];
design.notesCount += 1;
if (
!design.issue.participants.edges.some(
participant => participant.node.username === createNote.note.author.username,
)
) {
design.issue.participants.edges = [
...design.issue.participants.edges,
{
__typename: 'UserEdge',
node: {
__typename: 'User',
...createNote.note.author,
},
},
];
}
store.writeQuery({
query,
variables: queryVariables,
data: {
...data,
design: {
...design,
},
},
});
};
const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) => {
const data = store.readQuery({
query,
variables,
});
const newDiscussion = {
__typename: 'Discussion',
id: createImageDiffNote.note.discussion.id,
replyId: createImageDiffNote.note.discussion.replyId,
resolvable: true,
resolved: false,
resolvedAt: null,
resolvedBy: null,
notes: {
__typename: 'NoteConnection',
nodes: [createImageDiffNote.note],
},
};
const design = extractDesign(data);
const notesCount = design.notesCount + 1;
design.discussions.nodes = [...design.discussions.nodes, newDiscussion];
if (
!design.issue.participants.edges.some(
participant => participant.node.username === createImageDiffNote.note.author.username,
)
) {
design.issue.participants.edges = [
...design.issue.participants.edges,
{
__typename: 'UserEdge',
node: {
__typename: 'User',
...createImageDiffNote.note.author,
},
},
];
}
store.writeQuery({
query,
variables,
data: {
...data,
design: {
...design,
notesCount,
},
},
});
};
const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => {
const data = store.readQuery({
query,
variables,
});
const design = extractDesign(data);
const discussion = extractCurrentDiscussion(
design.discussions,
updateImageDiffNote.note.discussion.id,
);
discussion.notes = {
...discussion.notes,
nodes: [updateImageDiffNote.note, ...discussion.notes.nodes.slice(1)],
};
store.writeQuery({
query,
variables,
data: {
...data,
design,
},
});
};
const addNewDesignToStore = (store, designManagementUpload, query) => {
const data = store.readQuery(query);
const newDesigns = data.project.issue.designCollection.designs.edges.reduce((acc, design) => {
if (!acc.find(d => d.filename === design.node.filename)) {
acc.push(design.node);
}
return acc;
}, designManagementUpload.designs);
let newVersionNode;
const findNewVersions = designManagementUpload.designs.find(design => design.versions);
if (findNewVersions) {
const findNewVersionsEdges = findNewVersions.versions.edges;
if (findNewVersionsEdges && findNewVersionsEdges.length) {
newVersionNode = [findNewVersionsEdges[0]];
}
}
const newVersions = [
...(newVersionNode || []),
...data.project.issue.designCollection.versions.edges,
];
const updatedDesigns = {
__typename: 'DesignCollection',
designs: {
__typename: 'DesignConnection',
edges: newDesigns.map(design => ({
__typename: 'DesignEdge',
node: design,
})),
},
versions: {
__typename: 'DesignVersionConnection',
edges: newVersions,
},
};
data.project.issue.designCollection = updatedDesigns;
store.writeQuery({
...query,
data,
});
};
const onError = (data, message) => {
createFlash(message);
throw new Error(data.errors);
};
export const hasErrors = ({ errors = [] }) => errors?.length;
/**
* Updates a store after design deletion
*
* @param {Object} store
* @param {Object} data
* @param {Object} query
* @param {Array} designs
*/
export const updateStoreAfterDesignsDelete = (store, data, query, designs) => {
if (hasErrors(data)) {
onError(data, designDeletionError({ singular: designs.length === 1 }));
} else {
deleteDesignsFromStore(store, query, designs);
addNewVersionToStore(store, query, data.version);
}
};
export const updateStoreAfterAddDiscussionComment = (
store,
data,
query,
queryVariables,
discussionId,
) => {
if (hasErrors(data)) {
onError(data, ADD_DISCUSSION_COMMENT_ERROR);
} else {
addDiscussionCommentToStore(store, data, query, queryVariables, discussionId);
}
};
export const updateStoreAfterAddImageDiffNote = (store, data, query, queryVariables) => {
if (hasErrors(data)) {
onError(data, ADD_IMAGE_DIFF_NOTE_ERROR);
} else {
addImageDiffNoteToStore(store, data, query, queryVariables);
}
};
export const updateStoreAfterUpdateImageDiffNote = (store, data, query, queryVariables) => {
if (hasErrors(data)) {
onError(data, UPDATE_IMAGE_DIFF_NOTE_ERROR);
} else {
updateImageDiffNoteInStore(store, data, query, queryVariables);
}
};
export const updateStoreAfterUploadDesign = (store, data, query) => {
if (hasErrors(data)) {
onError(data, data.errors[0]);
} else {
addNewDesignToStore(store, data, query);
}
};
import { uniqueId } from 'lodash';
import { VALID_DESIGN_FILE_MIMETYPE } from '../constants';
export const isValidDesignFile = ({ type }) =>
(type.match(VALID_DESIGN_FILE_MIMETYPE.regex) || []).length > 0;
/**
* Returns formatted array that doesn't contain
* `edges`->`node` nesting
*
* @param {Array} elements
*/
export const extractNodes = elements => elements.edges.map(({ node }) => node);
/**
* Returns formatted array of discussions that doesn't contain
* `edges`->`node` nesting for child notes
*
* @param {Array} discussions
*/
export const extractDiscussions = discussions =>
discussions.nodes.map((discussion, index) => ({
...discussion,
index: index + 1,
notes: discussion.notes.nodes,
}));
/**
* Returns a discussion with the given id from discussions array
*
* @param {Array} discussions
*/
export const extractCurrentDiscussion = (discussions, id) =>
discussions.nodes.find(discussion => discussion.id === id);
export const findVersionId = id => (id.match('::Version/(.+$)') || [])[1];
export const findNoteId = id => (id.match('DiffNote/(.+$)') || [])[1];
export const extractDesigns = data => data.project.issue.designCollection.designs.edges;
export const extractDesign = data => (extractDesigns(data) || [])[0]?.node;
/**
* Generates optimistic response for a design upload mutation
* @param {Array<File>} files
*/
export const designUploadOptimisticResponse = files => {
const designs = files.map(file => ({
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Design',
id: -uniqueId(),
image: '',
imageV432x230: '',
filename: file.name,
fullPath: '',
notesCount: 0,
event: 'NONE',
diffRefs: {
__typename: 'DiffRefs',
baseSha: '',
startSha: '',
headSha: '',
},
discussions: {
__typename: 'DesignDiscussion',
nodes: [],
},
versions: {
__typename: 'DesignVersionConnection',
edges: {
__typename: 'DesignVersionEdge',
node: {
__typename: 'DesignVersion',
id: -uniqueId(),
sha: -uniqueId(),
},
},
},
}));
return {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Mutation',
designManagementUpload: {
__typename: 'DesignManagementUploadPayload',
designs,
skippedDesigns: [],
errors: [],
},
};
};
/**
* Generates optimistic response for a design upload mutation
* @param {Array<File>} files
*/
export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Mutation',
updateImageDiffNote: {
__typename: 'UpdateImageDiffNotePayload',
note: {
...note,
position: {
...note.position,
...position,
},
},
errors: [],
},
});
const normalizeAuthor = author => ({
...author,
web_url: author.webUrl,
avatar_url: author.avatarUrl,
});
export const extractParticipants = users => users.edges.map(({ node }) => normalizeAuthor(node));
export const getPageLayoutElement = () => document.querySelector('.layout-page');
import { __, s__, n__, sprintf } from '~/locale';
export const ADD_DISCUSSION_COMMENT_ERROR = s__(
'DesignManagement|Could not add a new comment. Please try again.',
);
export const ADD_IMAGE_DIFF_NOTE_ERROR = s__(
'DesignManagement|Could not create new discussion. Please try again.',
);
export const UPDATE_IMAGE_DIFF_NOTE_ERROR = s__(
'DesignManagement|Could not update discussion. Please try again.',
);
export const UPDATE_NOTE_ERROR = s__('DesignManagement|Could not update note. Please try again.');
export const UPLOAD_DESIGN_ERROR = s__(
'DesignManagement|Error uploading a new design. Please try again.',
);
export const UPLOAD_DESIGN_INVALID_FILETYPE_ERROR = __(
'Could not upload your designs as one or more files uploaded are not supported.',
);
export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.');
export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.');
const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped.');
const ALL_DESIGNS_SKIPPED_MESSAGE = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
'The designs you tried uploading did not change.',
)}`;
export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __(
'You can only upload one design when dropping onto an existing design.',
);
export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __(
'You must upload a file with the same file name when dropping onto an existing design.',
);
const MAX_SKIPPED_FILES_LISTINGS = 5;
const oneDesignSkippedMessage = filename =>
`${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${sprintf(s__('DesignManagement|%{filename} did not change.'), {
filename,
})}`;
/**
* Return warning message indicating that some (but not all) uploaded
* files were skipped.
* @param {Array<{ filename }>} skippedFiles
*/
const someDesignsSkippedMessage = skippedFiles => {
const designsSkippedMessage = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
'Some of the designs you tried uploading did not change:',
)}`;
const moreText = sprintf(s__(`DesignManagement|and %{moreCount} more.`), {
moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS,
});
return `${designsSkippedMessage} ${skippedFiles
.slice(0, MAX_SKIPPED_FILES_LISTINGS)
.map(({ filename }) => filename)
.join(', ')}${skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS ? `, ${moreText}` : '.'}`;
};
export const designDeletionError = ({ singular = true } = {}) => {
const design = singular ? __('a design') : __('designs');
return sprintf(s__('Could not delete %{design}. Please try again.'), {
design,
});
};
/**
* Return warning message, if applicable, that one, some or all uploaded
* files were skipped.
* @param {Array<{ filename }>} uploadedDesigns
* @param {Array<{ filename }>} skippedFiles
*/
export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => {
if (skippedFiles.length === 0) {
return null;
}
if (skippedFiles.length === uploadedDesigns.length) {
const { filename } = skippedFiles[0];
return n__(oneDesignSkippedMessage(filename), ALL_DESIGNS_SKIPPED_MESSAGE, skippedFiles.length);
}
return someDesignsSkippedMessage(skippedFiles);
};
import Tracking from '~/tracking';
// Tracking Constants
const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0';
const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
const DESIGN_TRACKING_EVENT_NAME = 'view_design';
export function trackDesignDetailView(
referer = '',
owner = '',
designVersion = 1,
latestVersion = false,
) {
Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, {
label: DESIGN_TRACKING_EVENT_NAME,
context: {
schema: DESIGN_TRACKING_CONTEXT_SCHEMA,
data: {
'design-version-number': designVersion,
'design-is-current-version': latestVersion,
'internal-object-referrer': referer,
'design-collection-owner': owner,
},
},
});
}
...@@ -25,12 +25,6 @@ export default function() { ...@@ -25,12 +25,6 @@ export default function() {
initSentryErrorStackTraceApp(); initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp(); initRelatedMergeRequestsApp();
// This will be removed when we remove the `design_management_moved` feature flag
// See https://gitlab.com/gitlab-org/gitlab/-/issues/223197
import(/* webpackChunkName: 'design_management' */ '~/design_management_legacy')
.then(module => module.default())
.catch(() => {});
import(/* webpackChunkName: 'design_management' */ '~/design_management') import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then(module => module.default()) .then(module => module.default())
.catch(() => {}); .catch(() => {});
......
...@@ -4,20 +4,7 @@ ...@@ -4,20 +4,7 @@
- enable_lfs_message = s_("DesignManagement|To upload designs, you'll need to enable LFS. %{requirements_link_start}More information%{requirements_link_end}").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end } - enable_lfs_message = s_("DesignManagement|To upload designs, you'll need to enable LFS. %{requirements_link_start}More information%{requirements_link_end}").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end }
- if @project.design_management_enabled? - if @project.design_management_enabled?
- if Feature.enabled?(:design_management_moved, @project, default_enabled: true) .js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
.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 - else
- if Feature.enabled?(:design_management_moved, @project, default_enabled: true) .gl-border-solid.gl-border-1.gl-border-gray-100.gl-rounded-base.gl-mt-5.gl-p-3.gl-text-center
.gl-border-solid.gl-border-1.gl-border-gray-100.gl-rounded-base.gl-mt-5.gl-p-3.gl-text-center = enable_lfs_message
= enable_lfs_message
- else
.mt-4
.row.empty-state
.col-12
.text-content
%h4.center
= _('The one place for your designs')
%p.center
= enable_lfs_message
%ul.nav-tabs.nav.nav-links{ role: 'tablist' }
%li
= link_to '#discussion-tab', class: 'active js-issue-tabs', id: 'discussion', role: 'tab', 'aria-controls': 'js-discussion', 'aria-selected': 'true', data: { toggle: 'tab', target: '#discussion-tab', qa_selector: 'discussion_tab_link' } do
= _('Discussion')
%span.badge.badge-pill.js-discussions-count
%li
= link_to '#designs-tab', class: 'js-issue-tabs', id: 'designs', role: 'tab', 'aria-controls': 'js-designs', 'aria-selected': 'false', data: { toggle: 'tab', target: '#designs-tab', qa_selector: 'designs_tab_link' } do
= _('Designs')
%span.badge.badge-pill.js-designs-count
.tab-content
#discussion-tab.tab-pane.show.active{ role: 'tabpanel', 'aria-labelledby': 'discussion', data: { qa_selector: 'discussion_tab_content' } }
= render 'projects/issues/discussion'
#designs-tab.tab-pane{ role: 'tabpanel', 'aria-labelledby': 'designs', data: { qa_selector: 'designs_tab_content' } }
= render 'projects/issues/design_management'
...@@ -76,8 +76,7 @@ ...@@ -76,8 +76,7 @@
- if @issue.sentry_issue.present? - if @issue.sentry_issue.present?
#js-sentry-error-stack-trace{ data: error_details_data(@project, @issue.sentry_issue.sentry_issue_identifier) } #js-sentry-error-stack-trace{ data: error_details_data(@project, @issue.sentry_issue.sentry_issue_identifier) }
- if Feature.enabled?(:design_management_moved, @project, default_enabled: true) = render 'projects/issues/design_management'
= render 'projects/issues/design_management'
= render_if_exists 'projects/issues/related_issues' = render_if_exists 'projects/issues/related_issues'
...@@ -97,9 +96,6 @@ ...@@ -97,9 +96,6 @@
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@issue), notes_filters: UserPreference.notes_filters.to_json } } #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@issue), notes_filters: UserPreference.notes_filters.to_json } }
= render 'new_branch' if show_new_branch_button? = render 'new_branch' if show_new_branch_button?
- if Feature.enabled?(:design_management_moved, @project, default_enabled: true) = render 'projects/issues/discussion'
= render 'projects/issues/discussion'
- else
= render 'projects/issues/tabs'
= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees
...@@ -72,39 +72,12 @@ and connect to GitLab through a personal access token. The details are explained ...@@ -72,39 +72,12 @@ and connect to GitLab through a personal access token. The details are explained
## The Design Management section ## The Design Management section
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/223193) in GitLab 13.2, Designs are displayed directly on the issue description rather than on a separate tab. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/223193) in GitLab 13.2, Designs are displayed directly on the issue description rather than on a separate tab.
> - The new display is deployed behind a feature flag, enabled by default. > - New display's feature flag [removed](https://gitlab.com/gitlab-org/gitlab/-/issues/223197) in GitLab 13.4.
> - It's enabled on GitLab.com.
> - It cannot be enabled or disabled per-project.
> - It's recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#enable-or-disable-displaying-designs-on-the-issue-description-core-only). If disabled, it will move Designs back to the **Designs** tab.
You can find to the **Design Management** section in the issue description: You can find to the **Design Management** section in the issue description:
![Designs section](img/design_management_v13_2.png) ![Designs section](img/design_management_v13_2.png)
### Enable or disable displaying Designs on the issue description **(CORE ONLY)**
Displaying Designs on the issue description is under development but ready for
production use. It is deployed behind a feature flag that is **enabled by
default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
can opt to disable it for your instance.
To disable it:
```ruby
Feature.disable(:design_management_moved)
```
To enable it:
```ruby
Feature.enable(:design_management_moved)
```
By disabling this feature, designs will be displayed on the **Designs** tab
instead of directly on the issue description.
## Adding designs ## Adding designs
To upload Design images, drag files from your computer and drop them in the Design Management section, To upload Design images, drag files from your computer and drop them in the Design Management section,
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Issue page tabs', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:issue) { create(:issue, author: user, assignees: [user], project: project) }
describe 'discussions tab counter' do
before do
allow(Ability).to receive(:allowed?) { true }
stub_feature_flags(design_management_moved: false)
end
subject do
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests
find('#discussion')
end
context 'new issue' do
it 'displays count of 0' do
is_expected.to have_content('Discussion 0')
end
end
context 'issue with 2 system notes and 1 discussion' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: project, note: "This is good") }
before do
create(:system_note, noteable: issue, project: project, author: user, note: 'description updated')
create(:system_note, noteable: issue, project: project, author: user, note: 'description updated')
end
it 'displays count of 1' do
is_expected.to have_content('Discussion 1')
end
context 'with 1 reply' do
before do
create(:note, noteable: issue, in_reply_to: discussion, discussion_id: discussion.discussion_id, note: 'I also think this is good')
end
it 'displays count of 2' do
is_expected.to have_content('Discussion 2')
end
end
end
end
end
...@@ -515,9 +515,6 @@ msgstr "" ...@@ -515,9 +515,6 @@ msgstr ""
msgid "%{level_name} is not allowed since the fork source project has lower visibility." msgid "%{level_name} is not allowed since the fork source project has lower visibility."
msgstr "" msgstr ""
msgid "%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
msgstr ""
msgid "%{link_start}Learn more%{link_end} about what information is shared with GitLab Inc." msgid "%{link_start}Learn more%{link_end} about what information is shared with GitLab Inc."
msgstr "" msgstr ""
...@@ -7039,9 +7036,6 @@ msgstr "" ...@@ -7039,9 +7036,6 @@ msgstr ""
msgid "Could not create wiki page" msgid "Could not create wiki page"
msgstr "" msgstr ""
msgid "Could not delete %{design}. Please try again."
msgstr ""
msgid "Could not delete chat nickname %{chat_name}." msgid "Could not delete chat nickname %{chat_name}."
msgstr "" msgstr ""
...@@ -8043,9 +8037,6 @@ msgstr "" ...@@ -8043,9 +8037,6 @@ msgstr ""
msgid "Deleted chat nickname: %{chat_name}!" msgid "Deleted chat nickname: %{chat_name}!"
msgstr "" msgstr ""
msgid "Deleted in this version"
msgstr ""
msgid "Deleted projects" msgid "Deleted projects"
msgstr "" msgstr ""
...@@ -8484,9 +8475,6 @@ msgstr "" ...@@ -8484,9 +8475,6 @@ msgstr ""
msgid "DesignManagement|Are you sure you want to cancel creating this comment?" msgid "DesignManagement|Are you sure you want to cancel creating this comment?"
msgstr "" msgstr ""
msgid "DesignManagement|Are you sure you want to delete the selected designs?"
msgstr ""
msgid "DesignManagement|Cancel changes" msgid "DesignManagement|Cancel changes"
msgstr "" msgstr ""
...@@ -8517,15 +8505,6 @@ msgstr "" ...@@ -8517,15 +8505,6 @@ msgstr ""
msgid "DesignManagement|Could not update note. Please try again." msgid "DesignManagement|Could not update note. Please try again."
msgstr "" msgstr ""
msgid "DesignManagement|Delete"
msgstr ""
msgid "DesignManagement|Delete designs confirmation"
msgstr ""
msgid "DesignManagement|Delete selected"
msgstr ""
msgid "DesignManagement|Deselect all" msgid "DesignManagement|Deselect all"
msgstr "" msgstr ""
...@@ -8742,9 +8721,6 @@ msgstr "" ...@@ -8742,9 +8721,6 @@ msgstr ""
msgid "Discuss a specific suggestion or question." msgid "Discuss a specific suggestion or question."
msgstr "" msgstr ""
msgid "Discussion"
msgstr ""
msgid "Discussion to reply to cannot be found" msgid "Discussion to reply to cannot be found"
msgstr "" msgstr ""
...@@ -22747,12 +22723,6 @@ msgstr "" ...@@ -22747,12 +22723,6 @@ msgstr ""
msgid "Showing %{pageSize} of %{total} issues" msgid "Showing %{pageSize} of %{total} issues"
msgstr "" msgstr ""
msgid "Showing Latest Version"
msgstr ""
msgid "Showing Version #%{versionNumber}"
msgstr ""
msgid "Showing all issues" msgid "Showing all issues"
msgstr "" msgstr ""
...@@ -24656,9 +24626,6 @@ msgstr "" ...@@ -24656,9 +24626,6 @@ msgstr ""
msgid "The number of times an upload record could not find its file" msgid "The number of times an upload record could not find its file"
msgstr "" msgstr ""
msgid "The one place for your designs"
msgstr ""
msgid "The parent epic is confidential and can only contain confidential epics and issues" msgid "The parent epic is confidential and can only contain confidential epics and issues"
msgstr "" msgstr ""
......
...@@ -8,57 +8,26 @@ RSpec.describe 'User paginates issue designs', :js do ...@@ -8,57 +8,26 @@ RSpec.describe 'User paginates issue designs', :js do
let(:project) { create(:project_empty_repo, :public) } let(:project) { create(:project_empty_repo, :public) }
let(:issue) { create(:issue, project: project) } let(:issue) { create(:issue, project: project) }
context 'design_management_moved flag disabled' do before do
before do enable_design_management
stub_feature_flags(design_management_moved: false) create_list(:design, 2, :with_file, issue: issue)
enable_design_management visit project_issue_path(project, issue)
find('.js-design-list-item', match: :first).click
create_list(:design, 2, :with_file, issue: issue)
visit project_issue_path(project, issue)
click_link 'Designs'
wait_for_requests
find('.js-design-list-item', match: :first).click
end
it 'paginates to next design' do
expect(find('.js-previous-design')[:disabled]).to eq('true')
page.within(find('.js-design-header')) do
expect(page).to have_content('1 of 2')
end
find('.js-next-design').click
expect(find('.js-previous-design')[:disabled]).not_to eq('true')
page.within(find('.js-design-header')) do
expect(page).to have_content('2 of 2')
end
end
end end
context 'design_management_moved flag enabled' do it 'paginates to next design' do
before do expect(find('.js-previous-design')[:disabled]).to eq('true')
enable_design_management
create_list(:design, 2, :with_file, issue: issue)
visit project_issue_path(project, issue)
find('.js-design-list-item', match: :first).click
end
it 'paginates to next design' do page.within(find('.js-design-header')) do
expect(find('.js-previous-design')[:disabled]).to eq('true') expect(page).to have_content('1 of 2')
end
page.within(find('.js-design-header')) do
expect(page).to have_content('1 of 2')
end
find('.js-next-design').click find('.js-next-design').click
expect(find('.js-previous-design')[:disabled]).not_to eq('true') expect(find('.js-previous-design')[:disabled]).not_to eq('true')
page.within(find('.js-design-header')) do page.within(find('.js-design-header')) do
expect(page).to have_content('2 of 2') expect(page).to have_content('2 of 2')
end
end end
end end
end end
...@@ -8,32 +8,13 @@ RSpec.describe 'User design permissions', :js do ...@@ -8,32 +8,13 @@ RSpec.describe 'User design permissions', :js do
let(:project) { create(:project_empty_repo, :public) } let(:project) { create(:project_empty_repo, :public) }
let(:issue) { create(:issue, project: project) } let(:issue) { create(:issue, project: project) }
context 'design_management_moved flag disabled' do before do
before do enable_design_management
enable_design_management
stub_feature_flags(design_management_moved: false)
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
click_link 'Designs'
wait_for_requests
end
it 'user does not have permissions to upload design' do
expect(page).not_to have_field('design_file')
end
end end
context 'design_management_moved flag enabled' do it 'user does not have permissions to upload design' do
before do expect(page).not_to have_field('design_file')
enable_design_management
visit project_issue_path(project, issue)
end
it 'user does not have permissions to upload design' do
expect(page).not_to have_field('design_file')
end
end end
end end
...@@ -11,81 +11,34 @@ RSpec.describe 'User uploads new design', :js do ...@@ -11,81 +11,34 @@ RSpec.describe 'User uploads new design', :js do
before do before do
sign_in(user) sign_in(user)
enable_design_management(feature_enabled)
visit project_issue_path(project, issue)
end end
context 'design_management_moved flag disabled' do context "when the feature is available" do
before do let(:feature_enabled) { true }
enable_design_management(feature_enabled)
stub_feature_flags(design_management_moved: false)
visit project_issue_path(project, issue)
click_link 'Designs' it 'uploads designs' do
upload_design(logo_fixture, count: 1)
wait_for_requests expect(page).to have_selector('.js-design-list-item', count: 1)
end
context "when the feature is available" do
let(:feature_enabled) { true }
it 'uploads designs' do
upload_design(logo_fixture, count: 1)
expect(page).to have_selector('.js-design-list-item', count: 1)
within first('#designs-tab .js-design-list-item') do
expect(page).to have_content('dk.png')
end
upload_design(gif_fixture, count: 2) within first('[data-testid="designs-root"] .js-design-list-item') do
expect(page).to have_content('dk.png')
# Known bug in the legacy implementation: new designs are inserted
# in the beginning on the frontend.
expect(page).to have_selector('.js-design-list-item', count: 2)
expect(page.all('.js-design-list-item').map(&:text)).to eq(['banana_sample.gif', 'dk.png'])
end end
end
context 'when the feature is not available' do upload_design(gif_fixture, count: 2)
let(:feature_enabled) { false }
it 'shows the message about requirements' do expect(page).to have_selector('.js-design-list-item', count: 2)
expect(page).to have_content("To upload designs, you'll need to enable LFS.") expect(page.all('.js-design-list-item').map(&:text)).to eq(['dk.png', 'banana_sample.gif'])
end
end end
end end
context 'design_management_moved flag enabled' do context 'when the feature is not available' do
before do let(:feature_enabled) { false }
enable_design_management(feature_enabled)
stub_feature_flags(design_management_moved: true)
visit project_issue_path(project, issue)
end
context "when the feature is available" do
let(:feature_enabled) { true }
it 'uploads designs' do it 'shows the message about requirements' do
upload_design(logo_fixture, count: 1) expect(page).to have_content("To upload designs, you'll need to enable LFS.")
expect(page).to have_selector('.js-design-list-item', count: 1)
within first('[data-testid="designs-root"] .js-design-list-item') do
expect(page).to have_content('dk.png')
end
upload_design(gif_fixture, count: 2)
expect(page).to have_selector('.js-design-list-item', count: 2)
expect(page.all('.js-design-list-item').map(&:text)).to eq(['dk.png', 'banana_sample.gif'])
end
end
context 'when the feature is not available' do
let(:feature_enabled) { false }
it 'shows the message about requirements' do
expect(page).to have_content("To upload designs, you'll need to enable LFS.")
end
end end
end end
......
...@@ -9,42 +9,19 @@ RSpec.describe 'User views issue designs', :js do ...@@ -9,42 +9,19 @@ RSpec.describe 'User views issue designs', :js do
let_it_be(:issue) { create(:issue, project: project) } let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:design) { create(:design, :with_file, issue: issue) } let_it_be(:design) { create(:design, :with_file, issue: issue) }
context 'design_management_moved flag disabled' do before do
before do enable_design_management
enable_design_management
stub_feature_flags(design_management_moved: false)
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
click_link 'Designs'
end
it 'opens design detail' do
click_link design.filename
page.within(find('.js-design-header')) do
expect(page).to have_content(design.filename)
end
expect(page).to have_selector('.js-design-image')
end
end end
context 'design_management_moved flag enabled' do it 'opens design detail' do
before do click_link design.filename
enable_design_management
visit project_issue_path(project, issue) page.within(find('.js-design-header')) do
expect(page).to have_content(design.filename)
end end
it 'opens design detail' do expect(page).to have_selector('.js-design-image')
click_link design.filename
page.within(find('.js-design-header')) do
expect(page).to have_content(design.filename)
end
expect(page).to have_selector('.js-design-image')
end
end end
end end
...@@ -9,78 +9,37 @@ RSpec.describe 'User views issue designs', :js do ...@@ -9,78 +9,37 @@ RSpec.describe 'User views issue designs', :js do
let_it_be(:issue) { create(:issue, project: project) } let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:design) { create(:design, :with_file, issue: issue) } let_it_be(:design) { create(:design, :with_file, issue: issue) }
context 'design_management_moved flag disabled' do before do
before do enable_design_management
enable_design_management end
stub_feature_flags(design_management_moved: false)
end
context 'navigates from the issue view' do
before do
visit project_issue_path(project, issue)
click_link 'Designs'
wait_for_requests
end
it 'fetches list of designs' do
expect(page).to have_selector('.js-design-list-item', count: 1)
end
end
context 'navigates directly to the design collection view' do
before do
visit designs_project_issue_path(project, issue)
end
it 'expands the sidebar' do context 'navigates from the issue view' do
expect(page).to have_selector('.layout-page.right-sidebar-expanded') before do
end visit project_issue_path(project, issue)
end end
context 'navigates directly to the individual design view' do it 'fetches list of designs' do
before do expect(page).to have_selector('.js-design-list-item', count: 1)
visit designs_project_issue_path(project, issue, vueroute: design.filename)
end
it 'sees the design' do
expect(page).to have_selector('.js-design-detail')
end
end end
end end
context 'design_management_moved flag enabled' do context 'navigates directly to the design collection view' do
before do before do
enable_design_management visit designs_project_issue_path(project, issue)
end end
context 'navigates from the issue view' do it 'expands the sidebar' do
before do expect(page).to have_selector('.layout-page.right-sidebar-expanded')
visit project_issue_path(project, issue)
end
it 'fetches list of designs' do
expect(page).to have_selector('.js-design-list-item', count: 1)
end
end end
end
context 'navigates directly to the design collection view' do context 'navigates directly to the individual design view' do
before do before do
visit designs_project_issue_path(project, issue) visit designs_project_issue_path(project, issue, vueroute: design.filename)
end
it 'expands the sidebar' do
expect(page).to have_selector('.layout-page.right-sidebar-expanded')
end
end end
context 'navigates directly to the individual design view' do it 'sees the design' do
before do expect(page).to have_selector('.js-design-detail')
visit designs_project_issue_path(project, issue, vueroute: design.filename)
end
it 'sees the design' do
expect(page).to have_selector('.js-design-detail')
end
end end
end end
end end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design note pin component should match the snapshot of note when repositioning 1`] = `
<button
aria-label="Comment form position"
class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0 btn-transparent comment-indicator"
style="left: 10px; top: 10px; cursor: move;"
type="button"
>
<gl-icon-stub
name="image-comment-dark"
size="24"
/>
</button>
`;
exports[`Design note pin component should match the snapshot of note with index 1`] = `
<button
aria-label="Comment '1' position"
class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0 js-image-badge badge badge-pill"
style="left: 10px; top: 10px;"
type="button"
>
1
</button>
`;
exports[`Design note pin component should match the snapshot of note without index 1`] = `
<button
aria-label="Comment form position"
class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0 btn-transparent comment-indicator"
style="left: 10px; top: 10px;"
type="button"
>
<gl-icon-stub
name="image-comment-dark"
size="24"
/>
</button>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design 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>"
`;
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment