提交 a9619822 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 4e06ca9e
...@@ -5,7 +5,7 @@ import { ...@@ -5,7 +5,7 @@ import {
GlLink, GlLink,
GlLoadingIcon, GlLoadingIcon,
GlPagination, GlPagination,
GlSkeletonLoading, GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlSprintf, GlSprintf,
GlTable, GlTable,
} from '@gitlab/ui'; } from '@gitlab/ui';
......
...@@ -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 { throttle } from 'lodash';
import DesignImage from './image.vue';
import DesignOverlay from './design_overlay.vue';
const CLICK_DRAG_BUFFER_PX = 2;
export default {
components: {
DesignImage,
DesignOverlay,
},
props: {
image: {
type: String,
required: false,
default: '',
},
imageName: {
type: String,
required: false,
default: '',
},
discussions: {
type: Array,
required: true,
},
isAnnotating: {
type: Boolean,
required: false,
default: false,
},
scale: {
type: Number,
required: false,
default: 1,
},
resolvedDiscussionsExpanded: {
type: Boolean,
required: true,
},
},
data() {
return {
overlayDimensions: null,
overlayPosition: null,
currentAnnotationPosition: null,
zoomFocalPoint: {
x: 0,
y: 0,
width: 0,
height: 0,
},
initialLoad: true,
lastDragPosition: null,
isDraggingDesign: false,
};
},
computed: {
discussionStartingNotes() {
return this.discussions.map(discussion => ({
...discussion.notes[0],
index: discussion.index,
}));
},
currentCommentForm() {
return (this.isAnnotating && this.currentAnnotationPosition) || null;
},
presentationStyle() {
return {
cursor: this.isDraggingDesign ? 'grabbing' : undefined,
};
},
},
beforeDestroy() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return;
presentationViewport.removeEventListener('scroll', this.scrollThrottled, false);
},
mounted() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return;
this.scrollThrottled = throttle(() => {
this.shiftZoomFocalPoint();
}, 400);
presentationViewport.addEventListener('scroll', this.scrollThrottled, false);
},
methods: {
syncCurrentAnnotationPosition() {
if (!this.currentAnnotationPosition) return;
const widthRatio = this.overlayDimensions.width / this.currentAnnotationPosition.width;
const heightRatio = this.overlayDimensions.height / this.currentAnnotationPosition.height;
const x = this.currentAnnotationPosition.x * widthRatio;
const y = this.currentAnnotationPosition.y * heightRatio;
this.currentAnnotationPosition = this.getAnnotationPositon({ x, y });
},
setOverlayDimensions(overlayDimensions) {
this.overlayDimensions = overlayDimensions;
// every time we set overlay dimensions, we need to
// update the current annotation as well
this.syncCurrentAnnotationPosition();
},
setOverlayPosition() {
if (!this.overlayDimensions) {
this.overlayPosition = {};
}
const { presentationViewport } = this.$refs;
if (!presentationViewport) return;
// default to center
this.overlayPosition = {
left: `calc(50% - ${this.overlayDimensions.width / 2}px)`,
top: `calc(50% - ${this.overlayDimensions.height / 2}px)`,
};
// if the overlay overflows, then don't center
if (this.overlayDimensions.width > presentationViewport.offsetWidth) {
this.overlayPosition.left = '0';
}
if (this.overlayDimensions.height > presentationViewport.offsetHeight) {
this.overlayPosition.top = '0';
}
},
/**
* Return a point that represents the center of an
* overflowing child element w.r.t it's parent
*/
getViewportCenter() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return {};
// get height of scroll bars (i.e. the max values for scrollTop, scrollLeft)
const scrollBarWidth = presentationViewport.scrollWidth - presentationViewport.offsetWidth;
const scrollBarHeight = presentationViewport.scrollHeight - presentationViewport.offsetHeight;
// determine how many child pixels have been scrolled
const xScrollRatio =
presentationViewport.scrollLeft > 0 ? presentationViewport.scrollLeft / scrollBarWidth : 0;
const yScrollRatio =
presentationViewport.scrollTop > 0 ? presentationViewport.scrollTop / scrollBarHeight : 0;
const xScrollOffset =
(presentationViewport.scrollWidth - presentationViewport.offsetWidth - 0) * xScrollRatio;
const yScrollOffset =
(presentationViewport.scrollHeight - presentationViewport.offsetHeight - 0) * yScrollRatio;
const viewportCenterX = presentationViewport.offsetWidth / 2;
const viewportCenterY = presentationViewport.offsetHeight / 2;
const focalPointX = viewportCenterX + xScrollOffset;
const focalPointY = viewportCenterY + yScrollOffset;
return {
x: focalPointX,
y: focalPointY,
};
},
/**
* Scroll the viewport such that the focal point is positioned centrally
*/
scrollToFocalPoint() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return;
const scrollX = this.zoomFocalPoint.x - presentationViewport.offsetWidth / 2;
const scrollY = this.zoomFocalPoint.y - presentationViewport.offsetHeight / 2;
presentationViewport.scrollTo(scrollX, scrollY);
},
scaleZoomFocalPoint() {
const { x, y, width, height } = this.zoomFocalPoint;
const widthRatio = this.overlayDimensions.width / width;
const heightRatio = this.overlayDimensions.height / height;
this.zoomFocalPoint = {
x: Math.round(x * widthRatio * 100) / 100,
y: Math.round(y * heightRatio * 100) / 100,
...this.overlayDimensions,
};
},
shiftZoomFocalPoint() {
this.zoomFocalPoint = {
...this.getViewportCenter(),
...this.overlayDimensions,
};
},
onImageResize(imageDimensions) {
this.setOverlayDimensions(imageDimensions);
this.setOverlayPosition();
this.$nextTick(() => {
if (this.initialLoad) {
// set focal point on initial load
this.shiftZoomFocalPoint();
this.initialLoad = false;
} else {
this.scaleZoomFocalPoint();
this.scrollToFocalPoint();
}
});
},
getAnnotationPositon(coordinates) {
const { x, y } = coordinates;
const { width, height } = this.overlayDimensions;
return {
x: Math.round(x),
y: Math.round(y),
width: Math.round(width),
height: Math.round(height),
};
},
openCommentForm(coordinates) {
this.currentAnnotationPosition = this.getAnnotationPositon(coordinates);
this.$emit('openCommentForm', this.currentAnnotationPosition);
},
closeCommentForm() {
this.currentAnnotationPosition = null;
this.$emit('closeCommentForm');
},
moveNote({ noteId, discussionId, coordinates }) {
const position = this.getAnnotationPositon(coordinates);
this.$emit('moveNote', { noteId, discussionId, position });
},
onPresentationMousedown({ clientX, clientY }) {
if (!this.isDesignOverflowing()) return;
this.lastDragPosition = {
x: clientX,
y: clientY,
};
},
getDragDelta(clientX, clientY) {
return {
deltaX: this.lastDragPosition.x - clientX,
deltaY: this.lastDragPosition.y - clientY,
};
},
exceedsDragThreshold(clientX, clientY) {
const { deltaX, deltaY } = this.getDragDelta(clientX, clientY);
return Math.abs(deltaX) > CLICK_DRAG_BUFFER_PX || Math.abs(deltaY) > CLICK_DRAG_BUFFER_PX;
},
shouldDragDesign(clientX, clientY) {
return (
this.lastDragPosition &&
(this.isDraggingDesign || this.exceedsDragThreshold(clientX, clientY))
);
},
onPresentationMousemove({ clientX, clientY }) {
const { presentationViewport } = this.$refs;
if (!presentationViewport || !this.shouldDragDesign(clientX, clientY)) return;
this.isDraggingDesign = true;
const { scrollLeft, scrollTop } = presentationViewport;
const { deltaX, deltaY } = this.getDragDelta(clientX, clientY);
presentationViewport.scrollTo(scrollLeft + deltaX, scrollTop + deltaY);
this.lastDragPosition = {
x: clientX,
y: clientY,
};
},
onPresentationMouseup() {
this.lastDragPosition = null;
this.isDraggingDesign = false;
},
isDesignOverflowing() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return false;
return (
presentationViewport.scrollWidth > presentationViewport.offsetWidth ||
presentationViewport.scrollHeight > presentationViewport.offsetHeight
);
},
},
};
</script>
<template>
<div
ref="presentationViewport"
class="h-100 w-100 p-3 overflow-auto position-relative"
:style="presentationStyle"
@mousedown="onPresentationMousedown"
@mousemove="onPresentationMousemove"
@mouseup="onPresentationMouseup"
@mouseleave="onPresentationMouseup"
@touchstart="onPresentationMousedown"
@touchmove="onPresentationMousemove"
@touchend="onPresentationMouseup"
@touchcancel="onPresentationMouseup"
>
<div class="h-100 w-100 d-flex align-items-center position-relative">
<design-image
v-if="image"
:image="image"
:name="imageName"
:scale="scale"
@resize="onImageResize"
/>
<design-overlay
v-if="overlayDimensions && overlayPosition"
:dimensions="overlayDimensions"
:position="overlayPosition"
:notes="discussionStartingNotes"
:current-comment-form="currentCommentForm"
:disable-commenting="isDraggingDesign"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
@openCommentForm="openCommentForm"
@closeCommentForm="closeCommentForm"
@moveNote="moveNote"
/>
</div>
</div>
</template>
<script>
import { GlIcon } from '@gitlab/ui';
const SCALE_STEP_SIZE = 0.2;
const DEFAULT_SCALE = 1;
const MIN_SCALE = 1;
const MAX_SCALE = 2;
export default {
components: {
GlIcon,
},
data() {
return {
scale: DEFAULT_SCALE,
};
},
computed: {
disableReset() {
return this.scale <= MIN_SCALE;
},
disableDecrease() {
return this.scale === DEFAULT_SCALE;
},
disableIncrease() {
return this.scale >= MAX_SCALE;
},
},
methods: {
setScale(scale) {
if (scale < MIN_SCALE) {
return;
}
this.scale = Math.round(scale * 100) / 100;
this.$emit('scale', this.scale);
},
incrementScale() {
this.setScale(this.scale + SCALE_STEP_SIZE);
},
decrementScale() {
this.setScale(this.scale - SCALE_STEP_SIZE);
},
resetScale() {
this.setScale(DEFAULT_SCALE);
},
},
};
</script>
<template>
<div class="design-scaler btn-group" role="group">
<button class="btn" :disabled="disableDecrease" @click="decrementScale">
<span class="d-flex-center gl-icon s16">
</span>
</button>
<button class="btn" :disabled="disableReset" @click="resetScale">
<gl-icon name="redo" />
</button>
<button class="btn" :disabled="disableIncrease" @click="incrementScale">
<gl-icon name="plus" />
</button>
</div>
</template>
<script>
import Cookies from 'js-cookie';
import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui';
import { s__ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
import { extractDiscussions, extractParticipants } from '../utils/design_management_utils';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
import DesignDiscussion from './design_notes/design_discussion.vue';
import Participants from '~/sidebar/components/participants/participants.vue';
export default {
components: {
DesignDiscussion,
Participants,
GlCollapse,
GlButton,
GlPopover,
},
props: {
design: {
type: Object,
required: true,
},
resolvedDiscussionsExpanded: {
type: Boolean,
required: true,
},
markdownPreviewPath: {
type: String,
required: true,
},
},
data() {
return {
isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)),
discussionWithOpenForm: '',
};
},
computed: {
discussions() {
return extractDiscussions(this.design.discussions);
},
issue() {
return {
...this.design.issue,
webPath: this.design.issue.webPath.substr(1),
};
},
discussionParticipants() {
return extractParticipants(this.issue.participants);
},
resolvedDiscussions() {
return this.discussions.filter(discussion => discussion.resolved);
},
unresolvedDiscussions() {
return this.discussions.filter(discussion => !discussion.resolved);
},
resolvedCommentsToggleIcon() {
return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right';
},
},
methods: {
handleSidebarClick() {
this.isResolvedCommentsPopoverHidden = true;
Cookies.set(this.$options.cookieKey, 'true', { expires: 365 * 10 });
this.updateActiveDiscussion();
},
updateActiveDiscussion(id) {
this.$apollo.mutate({
mutation: updateActiveDiscussionMutation,
variables: {
id,
source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion,
},
});
},
closeCommentForm() {
this.comment = '';
this.$emit('closeCommentForm');
},
updateDiscussionWithOpenForm(id) {
this.discussionWithOpenForm = id;
},
},
resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
cookieKey: 'hide_design_resolved_comments_popover',
};
</script>
<template>
<div class="image-notes" @click="handleSidebarClick">
<h2 class="gl-font-weight-bold gl-mt-0">
{{ issue.title }}
</h2>
<a
class="gl-text-gray-400 gl-text-decoration-none gl-mb-6 gl-display-block"
:href="issue.webUrl"
>{{ issue.webPath }}</a
>
<participants
:participants="discussionParticipants"
:show-participant-label="false"
class="gl-mb-4"
/>
<h2
v-if="unresolvedDiscussions.length === 0"
class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4"
data-testid="new-discussion-disclaimer"
>
{{ s__("DesignManagement|Click the image where you'd like to start a new discussion") }}
</h2>
<design-discussion
v-for="discussion in unresolvedDiscussions"
:key="discussion.id"
:discussion="discussion"
:design-id="$route.params.id"
:noteable-id="design.id"
:markdown-preview-path="markdownPreviewPath"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
:discussion-with-open-form="discussionWithOpenForm"
data-testid="unresolved-discussion"
@createNoteError="$emit('onDesignDiscussionError', $event)"
@updateNoteError="$emit('updateNoteError', $event)"
@resolveDiscussionError="$emit('resolveDiscussionError', $event)"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
@openForm="updateDiscussionWithOpenForm"
/>
<template v-if="resolvedDiscussions.length > 0">
<gl-button
id="resolved-comments"
data-testid="resolved-comments"
:icon="resolvedCommentsToggleIcon"
variant="link"
class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
@click="$emit('toggleResolvedComments')"
>{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }})
</gl-button>
<gl-popover
v-if="!isResolvedCommentsPopoverHidden"
:show="!isResolvedCommentsPopoverHidden"
target="resolved-comments"
container="popovercontainer"
placement="top"
:title="s__('DesignManagement|Resolved Comments')"
>
<p>
{{
s__(
'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below',
)
}}
</p>
<a href="#" rel="noopener noreferrer" target="_blank">{{
s__('DesignManagement|Learn more about resolving comments')
}}</a>
</gl-popover>
<gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3">
<design-discussion
v-for="discussion in resolvedDiscussions"
:key="discussion.id"
:discussion="discussion"
:design-id="$route.params.id"
:noteable-id="design.id"
:markdown-preview-path="markdownPreviewPath"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
:discussion-with-open-form="discussionWithOpenForm"
data-testid="resolved-discussion"
@error="$emit('onDesignDiscussionError', $event)"
@updateNoteError="$emit('updateNoteError', $event)"
@openForm="updateDiscussionWithOpenForm"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
/>
</gl-collapse>
</template>
<slot name="replyForm"></slot>
</div>
</template>
<script>
import { throttle } from 'lodash';
import { GlIcon } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
props: {
image: {
type: String,
required: false,
default: '',
},
name: {
type: String,
required: false,
default: '',
},
scale: {
type: Number,
required: false,
default: 1,
},
},
data() {
return {
baseImageSize: null,
imageStyle: null,
imageError: false,
};
},
watch: {
scale(val) {
this.zoom(val);
},
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeThrottled, false);
},
mounted() {
this.onImgLoad();
this.resizeThrottled = throttle(() => {
// NOTE: if imageStyle is set, then baseImageSize
// won't change due to resize. We must still emit a
// `resize` event so that the parent can handle
// resizes appropriately (e.g. for design_overlay)
this.setBaseImageSize();
}, 400);
window.addEventListener('resize', this.resizeThrottled, false);
},
methods: {
onImgLoad() {
requestIdleCallback(this.setBaseImageSize, { timeout: 1000 });
},
onImgError() {
this.imageError = true;
},
setBaseImageSize() {
const { contentImg } = this.$refs;
if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return;
this.baseImageSize = {
height: contentImg.offsetHeight,
width: contentImg.offsetWidth,
};
this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height });
},
onResize({ width, height }) {
this.$emit('resize', { width, height });
},
zoom(amount) {
if (amount === 1) {
this.imageStyle = null;
this.$nextTick(() => {
this.setBaseImageSize();
});
return;
}
const width = this.baseImageSize.width * amount;
const height = this.baseImageSize.height * amount;
this.imageStyle = {
width: `${width}px`,
height: `${height}px`,
};
this.onResize({ width, height });
},
},
};
</script>
<template>
<div class="m-auto js-design-image">
<gl-icon v-if="imageError" class="text-secondary-100" name="media-broken" :size="48" />
<img
v-show="!imageError"
ref="contentImg"
class="mh-100"
:src="image"
:alt="name"
:style="imageStyle"
:class="{ 'img-fluid': !imageStyle }"
@error="onImgError"
@load="onImgLoad"
/>
</div>
</template>
<script>
import { GlLoadingIcon, GlIcon, GlIntersectionObserver } from '@gitlab/ui';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import { n__, __ } from '~/locale';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
components: {
GlLoadingIcon,
GlIntersectionObserver,
GlIcon,
Timeago,
},
props: {
id: {
type: [Number, String],
required: true,
},
event: {
type: String,
required: true,
},
notesCount: {
type: Number,
required: true,
},
image: {
type: String,
required: true,
},
filename: {
type: String,
required: true,
},
updatedAt: {
type: String,
required: false,
default: null,
},
isUploading: {
type: Boolean,
required: false,
default: true,
},
imageV432x230: {
type: String,
required: false,
default: null,
},
},
data() {
return {
imageLoading: true,
imageError: false,
wasInView: false,
};
},
computed: {
icon() {
const normalizedEvent = this.event.toLowerCase();
const icons = {
creation: {
name: 'file-addition-solid',
classes: 'text-success-500',
tooltip: __('Added in this version'),
},
modification: {
name: 'file-modified-solid',
classes: 'text-primary-500',
tooltip: __('Modified in this version'),
},
deletion: {
name: 'file-deletion-solid',
classes: 'text-danger-500',
tooltip: __('Deleted in this version'),
},
};
return icons[normalizedEvent] ? icons[normalizedEvent] : {};
},
notesLabel() {
return n__('%d comment', '%d comments', this.notesCount);
},
imageLink() {
return this.wasInView ? this.imageV432x230 || this.image : '';
},
showLoadingSpinner() {
return this.imageLoading || this.isUploading;
},
showImageErrorIcon() {
return this.wasInView && this.imageError;
},
showImage() {
return !this.showLoadingSpinner && !this.showImageErrorIcon;
},
},
methods: {
onImageLoad() {
this.imageLoading = false;
this.imageError = false;
},
onImageError() {
this.imageLoading = false;
this.imageError = true;
},
onAppear() {
// do nothing if image has previously
// been in view
if (this.wasInView) {
return;
}
this.wasInView = true;
this.imageLoading = true;
},
},
DESIGN_ROUTE_NAME,
};
</script>
<template>
<router-link
:to="{
name: $options.DESIGN_ROUTE_NAME,
params: { id: filename },
query: $route.query,
}"
class="card cursor-pointer text-plain js-design-list-item design-list-item"
>
<div class="card-body p-0 d-flex-center overflow-hidden position-relative">
<div v-if="icon.name" data-testid="designEvent" class="design-event position-absolute">
<span :title="icon.tooltip" :aria-label="icon.tooltip">
<gl-icon :name="icon.name" :size="18" :class="icon.classes" />
</span>
</div>
<gl-intersection-observer @appear="onAppear">
<gl-loading-icon v-if="showLoadingSpinner" size="md" />
<gl-icon
v-else-if="showImageErrorIcon"
name="media-broken"
class="text-secondary"
:size="32"
/>
<img
v-show="showImage"
:src="imageLink"
:alt="filename"
class="block mx-auto mw-100 mh-100 design-img"
data-qa-selector="design_image"
@load="onImageLoad"
@error="onImageError"
/>
</gl-intersection-observer>
</div>
<div class="card-footer d-flex w-100">
<div class="d-flex flex-column str-truncated-100">
<span class="bold str-truncated-100" data-qa-selector="design_file_name">{{
filename
}}</span>
<span v-if="updatedAt" class="str-truncated-100">
{{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" />
</span>
</div>
<div v-if="notesCount" class="ml-auto d-flex align-items-center text-secondary">
<gl-icon name="comments" class="ml-1" />
<span :aria-label="notesLabel" class="ml-1">
{{ notesCount }}
</span>
</div>
</div>
</router-link>
</template>
<script>
import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import Pagination from './pagination.vue';
import DeleteButton from '../delete_button.vue';
import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql';
import appDataQuery from '../../graphql/queries/app_data.query.graphql';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
export default {
components: {
GlIcon,
Pagination,
DeleteButton,
GlDeprecatedButton,
},
mixins: [timeagoMixin],
props: {
id: {
type: String,
required: true,
},
isDeleting: {
type: Boolean,
required: true,
},
filename: {
type: String,
required: false,
default: '',
},
updatedAt: {
type: String,
required: false,
default: null,
},
updatedBy: {
type: Object,
required: false,
default: () => ({}),
},
isLatestVersion: {
type: Boolean,
required: true,
},
image: {
type: String,
required: true,
},
},
data() {
return {
permissions: {
createDesign: false,
},
projectPath: '',
issueIid: null,
};
},
apollo: {
appData: {
query: appDataQuery,
manual: true,
result({ data: { projectPath, issueIid } }) {
this.projectPath = projectPath;
this.issueIid = issueIid;
},
},
permissions: {
query: permissionsQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
};
},
update: data => data.project.issue.userPermissions,
},
},
computed: {
updatedText() {
return sprintf(__('Updated %{updated_at} by %{updated_by}'), {
updated_at: this.timeFormatted(this.updatedAt),
updated_by: this.updatedBy.name,
});
},
canDeleteDesign() {
return this.permissions.createDesign;
},
},
DESIGNS_ROUTE_NAME,
};
</script>
<template>
<header class="d-flex p-2 bg-white align-items-center js-design-header">
<router-link
:to="{
name: $options.DESIGNS_ROUTE_NAME,
query: $route.query,
}"
:aria-label="s__('DesignManagement|Go back to designs')"
class="mr-3 text-plain d-flex justify-content-center align-items-center"
>
<gl-icon :size="18" name="close" />
</router-link>
<div class="overflow-hidden d-flex align-items-center">
<h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
<small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
</div>
<pagination :id="id" class="ml-auto flex-shrink-0" />
<gl-deprecated-button :href="image" class="mr-2">
<gl-icon :size="18" name="download" />
</gl-deprecated-button>
<delete-button
v-if="isLatestVersion && canDeleteDesign"
:is-deleting="isDeleting"
button-variant="danger"
@deleteSelectedDesigns="$emit('delete')"
>
<gl-icon :size="18" name="remove" />
</delete-button>
</header>
</template>
<script>
/* global Mousetrap */
import 'mousetrap';
import { s__, sprintf } from '~/locale';
import PaginationButton from './pagination_button.vue';
import allDesignsMixin from '../../mixins/all_designs';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
components: {
PaginationButton,
},
mixins: [allDesignsMixin],
props: {
id: {
type: String,
required: true,
},
},
computed: {
designsCount() {
return this.designs.length;
},
currentIndex() {
return this.designs.findIndex(design => design.filename === this.id);
},
paginationText() {
return sprintf(s__('DesignManagement|%{current_design} of %{designs_count}'), {
current_design: this.currentIndex + 1,
designs_count: this.designsCount,
});
},
previousDesign() {
if (!this.designsCount) return null;
return this.designs[this.currentIndex - 1];
},
nextDesign() {
if (!this.designsCount) return null;
return this.designs[this.currentIndex + 1];
},
},
mounted() {
Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign));
Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign));
},
beforeDestroy() {
Mousetrap.unbind(['left', 'right'], this.navigateToDesign);
},
methods: {
navigateToDesign(design) {
if (design) {
this.$router.push({
name: DESIGN_ROUTE_NAME,
params: { id: design.filename },
query: this.$route.query,
});
}
},
},
};
</script>
<template>
<div v-if="designsCount" class="d-flex align-items-center">
{{ paginationText }}
<div class="btn-group ml-3 mr-3">
<pagination-button
:design="previousDesign"
:title="s__('DesignManagement|Go to previous design')"
icon-name="angle-left"
class="js-previous-design"
/>
<pagination-button
:design="nextDesign"
:title="s__('DesignManagement|Go to next design')"
icon-name="angle-right"
class="js-next-design"
/>
</div>
</div>
</template>
<script>
import { GlIcon } from '@gitlab/ui';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
components: {
GlIcon,
},
props: {
design: {
type: Object,
required: false,
default: null,
},
title: {
type: String,
required: true,
},
iconName: {
type: String,
required: true,
},
},
computed: {
designLink() {
if (!this.design) return {};
return {
name: DESIGN_ROUTE_NAME,
params: { id: this.design.filename },
query: this.$route.query,
};
},
},
};
</script>
<template>
<router-link
:to="designLink"
:disabled="!design"
:class="{ disabled: !design }"
:aria-label="title"
class="btn btn-default"
>
<gl-icon :name="iconName" />
</router-link>
</template>
<script>
import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
export default {
components: {
GlDeprecatedButton,
GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
isSaving: {
type: Boolean,
required: true,
},
},
methods: {
openFileUpload() {
this.$refs.fileUpload.click();
},
onFileUploadChange(e) {
this.$emit('upload', e.target.files);
},
},
VALID_DESIGN_FILE_MIMETYPE,
};
</script>
<template>
<div>
<gl-deprecated-button
v-gl-tooltip.hover
:title="
s__(
'DesignManagement|Adding a design with the same filename replaces the file in a new version.',
)
"
:disabled="isSaving"
variant="success"
@click="openFileUpload"
>
{{ s__('DesignManagement|Upload designs') }}
<gl-loading-icon v-if="isSaving" inline class="ml-1" />
</gl-deprecated-button>
<input
ref="fileUpload"
type="file"
name="design_file"
:accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
class="hide"
multiple
@change="onFileUploadChange"
/>
</div>
</template>
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql';
import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages';
import { isValidDesignFile } from '../../utils/design_management_utils';
import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
export default {
components: {
GlIcon,
GlLink,
GlSprintf,
},
data() {
return {
dragCounter: 0,
isDragDataValid: false,
};
},
computed: {
dragging() {
return this.dragCounter !== 0;
},
},
methods: {
isValidUpload(files) {
return files.every(isValidDesignFile);
},
isValidDragDataType({ dataTransfer }) {
return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE));
},
ondrop({ dataTransfer = {} }) {
this.dragCounter = 0;
// User already had feedback when dropzone was active, so bail here
if (!this.isDragDataValid) {
return;
}
const { files } = dataTransfer;
if (!this.isValidUpload(Array.from(files))) {
createFlash(UPLOAD_DESIGN_INVALID_FILETYPE_ERROR);
return;
}
this.$emit('change', files);
},
ondragenter(e) {
this.dragCounter += 1;
this.isDragDataValid = this.isValidDragDataType(e);
},
ondragleave() {
this.dragCounter -= 1;
},
openFileUpload() {
this.$refs.fileUpload.click();
},
onDesignInputChange(e) {
this.$emit('change', e.target.files);
},
},
uploadDesignMutation,
VALID_DESIGN_FILE_MIMETYPE,
};
</script>
<template>
<div
class="w-100 position-relative"
@dragstart.prevent.stop
@dragend.prevent.stop
@dragover.prevent.stop
@dragenter.prevent.stop="ondragenter"
@dragleave.prevent.stop="ondragleave"
@drop.prevent.stop="ondrop"
>
<slot>
<button
class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
@click="openFileUpload"
>
<div class="d-flex-center flex-column text-center">
<gl-icon name="doc-new" :size="48" class="mb-4" />
<p>
<gl-sprintf
:message="
__(
'%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}.',
)
"
>
<template #lineOne="{ content }"
><span class="d-block">{{ content }}</span>
</template>
<template #link="{ content }">
<gl-link class="h-100 w-100" @click.stop="openFileUpload">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
</div>
</button>
<input
ref="fileUpload"
type="file"
name="design_file"
:accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
class="hide"
multiple
@change="onDesignInputChange"
/>
</slot>
<transition name="design-dropzone-fade">
<div
v-show="dragging"
class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
>
<div v-show="!isDragDataValid" class="mw-50 text-center">
<h3>{{ __('Oh no!') }}</h3>
<span>{{
__(
'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.',
)
}}</span>
</div>
<div v-show="isDragDataValid" class="mw-50 text-center">
<h3>{{ __('Incoming!') }}</h3>
<span>{{ __('Drop your designs to start your upload.') }}</span>
</div>
</div>
</transition>
</div>
</template>
<script>
import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import allVersionsMixin from '../../mixins/all_versions';
import { findVersionId } from '../../utils/design_management_utils';
export default {
components: {
GlDeprecatedDropdown,
GlDeprecatedDropdownItem,
},
mixins: [allVersionsMixin],
computed: {
queryVersion() {
return this.$route.query.version;
},
currentVersionIdx() {
if (!this.queryVersion) return 0;
const idx = this.allVersions.findIndex(
version => this.findVersionId(version.node.id) === this.queryVersion,
);
// if the currentVersionId isn't a valid version (i.e. not in allVersions)
// then return the latest version (index 0)
return idx !== -1 ? idx : 0;
},
currentVersionId() {
if (this.queryVersion) return this.queryVersion;
const currentVersion = this.allVersions[this.currentVersionIdx];
return this.findVersionId(currentVersion.node.id);
},
dropdownText() {
if (this.isLatestVersion) {
return __('Showing Latest Version');
}
// allVersions is sorted in reverse chronological order (latest first)
const currentVersionNumber = this.allVersions.length - this.currentVersionIdx;
return sprintf(__('Showing Version #%{versionNumber}'), {
versionNumber: currentVersionNumber,
});
},
},
methods: {
findVersionId,
},
};
</script>
<template>
<gl-deprecated-dropdown :text="dropdownText" variant="link" class="design-version-dropdown">
<gl-deprecated-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id">
<router-link
class="d-flex js-version-link"
:to="{ path: $route.path, query: { version: findVersionId(version.node.id) } }"
>
<div class="flex-grow-1 ml-2">
<div>
<strong
>{{ __('Version') }} {{ allVersions.length - index }}
<span v-if="findVersionId(version.node.id) === latestVersionId"
>({{ __('latest') }})</span
>
</strong>
</div>
</div>
<i
v-if="findVersionId(version.node.id) === currentVersionId"
class="fa fa-check float-right gl-mr-2"
></i>
</router-link>
</gl-deprecated-dropdown-item>
</gl-deprecated-dropdown>
</template>
// WARNING: replace this with something
// more sensical as per https://gitlab.com/gitlab-org/gitlab/issues/118611
export const VALID_DESIGN_FILE_MIMETYPE = {
mimetype: 'image/*',
regex: /image\/.+/,
};
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
export const VALID_DATA_TRANSFER_TYPE = 'Files';
export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
pin: 'pin',
discussion: 'discussion',
};
export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0'];
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { uniqueId } from 'lodash';
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql';
import typeDefs from './graphql/typedefs.graphql';
Vue.use(VueApollo);
const resolvers = {
Mutation: {
updateActiveDiscussion: (_, { id = null, source }, { cache }) => {
const data = cache.readQuery({ query: activeDiscussionQuery });
data.activeDiscussion = {
__typename: 'ActiveDiscussion',
id,
source,
};
cache.writeQuery({ query: activeDiscussionQuery, data });
},
},
};
const defaultClient = createDefaultClient(
resolvers,
// This config is added temporarily to resolve an issue with duplicate design IDs.
// Should be removed as soon as https://gitlab.com/gitlab-org/gitlab/issues/13495 is resolved
{
cacheConfig: {
dataIdFromObject: object => {
// eslint-disable-next-line no-underscore-dangle, @gitlab/require-i18n-strings
if (object.__typename === 'Design') {
return object.id && object.image ? `${object.id}-${object.image}` : uniqueId();
}
return defaultDataIdFromObject(object);
},
},
typeDefs,
},
);
export default new VueApollo({
defaultClient,
});
#import "./design_note.fragment.graphql"
#import "./design_list.fragment.graphql"
#import "./diff_refs.fragment.graphql"
#import "./discussion_resolved_status.fragment.graphql"
fragment DesignItem on Design {
...DesignListItem
fullPath
diffRefs {
...DesignDiffRefs
}
discussions {
nodes {
id
replyId
...ResolvedStatus
notes {
nodes {
...DesignNote
}
}
}
}
}
fragment DesignListItem on Design {
id
event
filename
notesCount
image
imageV432x230
}
#import "./diff_refs.fragment.graphql"
#import "~/graphql_shared/fragments/author.fragment.graphql"
#import "./note_permissions.fragment.graphql"
fragment DesignNote on Note {
id
author {
...Author
}
body
bodyHtml
createdAt
resolved
position {
diffRefs {
...DesignDiffRefs
}
x
y
height
width
}
userPermissions {
...DesignNotePermissions
}
discussion {
id
}
}
fragment ResolvedStatus on Discussion {
resolvable
resolved
resolvedAt
resolvedBy {
name
webUrl
}
}
#import "../fragments/design_note.fragment.graphql"
mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
createImageDiffNote(input: $input) {
note {
...DesignNote
discussion {
id
replyId
notes {
edges {
node {
...DesignNote
}
}
}
}
}
errors
}
}
#import "../fragments/design_note.fragment.graphql"
mutation createNote($input: CreateNoteInput!) {
createNote(input: $input) {
note {
...DesignNote
}
errors
}
}
#import "../fragments/version.fragment.graphql"
mutation destroyDesign($filenames: [String!]!, $projectPath: ID!, $iid: ID!) {
designManagementDelete(input: { projectPath: $projectPath, iid: $iid, filenames: $filenames }) {
version {
...VersionListItem
}
errors
}
}
#import "../fragments/design_note.fragment.graphql"
#import "../fragments/discussion_resolved_status.fragment.graphql"
mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) {
discussionToggleResolve(input: { id: $id, resolve: $resolve }) {
discussion {
id
...ResolvedStatus
notes {
nodes {
...DesignNote
}
}
}
errors
}
}
mutation updateActiveDiscussion($id: String, $source: String) {
updateActiveDiscussion(id: $id, source: $source) @client
}
#import "../fragments/design_note.fragment.graphql"
mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) {
updateImageDiffNote(input: $input) {
errors
note {
...DesignNote
}
}
}
#import "../fragments/design_note.fragment.graphql"
mutation updateNote($input: UpdateNoteInput!) {
updateNote(input: $input) {
note {
...DesignNote
}
errors
}
}
#import "../fragments/design.fragment.graphql"
mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) {
designs {
...DesignItem
versions {
edges {
node {
id
sha
}
}
}
}
skippedDesigns {
filename
}
errors
}
}
query permissions($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
id
issue(iid: $iid) {
userPermissions {
createDesign
}
}
}
}
#import "../fragments/design.fragment.graphql"
#import "~/graphql_shared/fragments/author.fragment.graphql"
query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [String!]) {
project(fullPath: $fullPath) {
id
issue(iid: $iid) {
designCollection {
designs(atVersion: $atVersion, filenames: $filenames) {
edges {
node {
...DesignItem
issue {
title
webPath
webUrl
participants {
edges {
node {
...Author
}
}
}
}
}
}
}
}
}
}
}
#import "../fragments/design_list.fragment.graphql"
#import "../fragments/version.fragment.graphql"
query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
project(fullPath: $fullPath) {
id
issue(iid: $iid) {
designCollection {
designs(atVersion: $atVersion) {
edges {
node {
...DesignListItem
}
}
}
versions {
edges {
node {
...VersionListItem
}
}
}
}
}
}
}
type ActiveDiscussion {
id: ID
source: String
}
extend type Query {
activeDiscussion: ActiveDiscussion
}
extend type Mutation {
updateActiveDiscussion(id: ID!, source: String!): Boolean
}
// This application is being moved, please do not touch this files
// Please see https://gitlab.com/gitlab-org/gitlab/-/issues/14744#note_364468096 for details
import $ from 'jquery';
import Vue from 'vue';
import createRouter from './router';
import App from './components/app.vue';
import apolloProvider from './graphql';
import getDesignListQuery from './graphql/queries/get_design_list.query.graphql';
import { DESIGNS_ROUTE_NAME, ROOT_ROUTE_NAME } from './router/constants';
export default () => {
const el = document.querySelector('.js-design-management');
const badge = document.querySelector('.js-designs-count');
const { issueIid, projectPath, issuePath } = el.dataset;
const router = createRouter(issuePath);
$('.js-issue-tabs').on('shown.bs.tab', ({ target: { id } }) => {
if (id === 'designs' && router.currentRoute.name === ROOT_ROUTE_NAME) {
router.push({ name: DESIGNS_ROUTE_NAME });
} else if (id === 'discussion') {
router.push({ name: ROOT_ROUTE_NAME });
}
});
apolloProvider.clients.defaultClient.cache.writeData({
data: {
projectPath,
issueIid,
activeDiscussion: {
__typename: 'ActiveDiscussion',
id: null,
source: null,
},
},
});
apolloProvider.clients.defaultClient
.watchQuery({
query: getDesignListQuery,
variables: {
fullPath: projectPath,
iid: issueIid,
atVersion: null,
},
})
.subscribe(({ data }) => {
if (badge) {
badge.textContent = data.project.issue.designCollection.designs.edges.length;
}
});
return new Vue({
el,
router,
apolloProvider,
render(createElement) {
return createElement(App);
},
});
};
import { propertyOf } from 'lodash';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__ } from '~/locale';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import { extractNodes } from '../utils/design_management_utils';
import allVersionsMixin from './all_versions';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
export default {
mixins: [allVersionsMixin],
apollo: {
designs: {
query: getDesignListQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
atVersion: this.designsVersion,
};
},
update: data => {
const designEdges = propertyOf(data)(['project', 'issue', 'designCollection', 'designs']);
if (designEdges) {
return extractNodes(designEdges);
}
return [];
},
error() {
this.error = true;
},
result() {
if (this.$route.query.version && !this.hasValidVersion) {
createFlash(
s__(
'DesignManagement|Requested design version does not exist. Showing latest version instead',
),
);
this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } });
}
},
},
},
data() {
return {
designs: [],
error: false,
};
},
};
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import appDataQuery from '../graphql/queries/app_data.query.graphql';
import { findVersionId } from '../utils/design_management_utils';
export default {
apollo: {
appData: {
query: appDataQuery,
manual: true,
result({ data: { projectPath, issueIid } }) {
this.projectPath = projectPath;
this.issueIid = issueIid;
},
},
allVersions: {
query: getDesignListQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
atVersion: null,
};
},
update: data => data.project.issue.designCollection.versions.edges,
},
},
computed: {
hasValidVersion() {
return (
this.$route.query.version &&
this.allVersions &&
this.allVersions.some(version => version.node.id.endsWith(this.$route.query.version))
);
},
designsVersion() {
return this.hasValidVersion
? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}`
: null;
},
latestVersionId() {
const latestVersion = this.allVersions[0];
return latestVersion && findVersionId(latestVersion.node.id);
},
isLatestVersion() {
if (this.allVersions.length > 0) {
return (
!this.$route.query.version ||
!this.latestVersionId ||
this.$route.query.version === this.latestVersionId
);
}
return true;
},
},
data() {
return {
allVersions: [],
projectPath: '',
issueIid: null,
};
},
};
<script>
import Mousetrap from 'mousetrap';
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import allVersionsMixin from '../../mixins/all_versions';
import Toolbar from '../../components/toolbar/index.vue';
import DesignDestroyer from '../../components/design_destroyer.vue';
import DesignScaler from '../../components/design_scaler.vue';
import DesignPresentation from '../../components/design_presentation.vue';
import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
import DesignSidebar from '../../components/design_sidebar.vue';
import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
import appDataQuery from '../../graphql/queries/app_data.query.graphql';
import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql';
import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql';
import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql';
import {
extractDiscussions,
extractDesign,
updateImageDiffNoteOptimisticResponse,
} from '../../utils/design_management_utils';
import {
updateStoreAfterAddImageDiffNote,
updateStoreAfterUpdateImageDiffNote,
} from '../../utils/cache_update';
import {
ADD_DISCUSSION_COMMENT_ERROR,
ADD_IMAGE_DIFF_NOTE_ERROR,
UPDATE_IMAGE_DIFF_NOTE_ERROR,
DESIGN_NOT_FOUND_ERROR,
DESIGN_VERSION_NOT_EXIST_ERROR,
UPDATE_NOTE_ERROR,
designDeletionError,
} from '../../utils/error_messages';
import { trackDesignDetailView } from '../../utils/tracking';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
export default {
components: {
ApolloMutation,
DesignReplyForm,
DesignPresentation,
DesignScaler,
DesignDestroyer,
Toolbar,
GlLoadingIcon,
GlAlert,
DesignSidebar,
},
mixins: [allVersionsMixin],
props: {
id: {
type: String,
required: true,
},
},
data() {
return {
design: {},
comment: '',
annotationCoordinates: null,
projectPath: '',
errorMessage: '',
issueIid: '',
scale: 1,
resolvedDiscussionsExpanded: false,
};
},
apollo: {
appData: {
query: appDataQuery,
manual: true,
result({ data: { projectPath, issueIid } }) {
this.projectPath = projectPath;
this.issueIid = issueIid;
},
},
design: {
query: getDesignQuery,
// We want to see cached design version if we have one, and fetch newer version on the background to update discussions
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
variables() {
return this.designVariables;
},
update: data => extractDesign(data),
result(res) {
this.onDesignQueryResult(res);
},
error() {
this.onQueryError(DESIGN_NOT_FOUND_ERROR);
},
},
},
computed: {
isFirstLoading() {
// We only want to show spinner on initial design load (when opened from a deep link to design)
// If we already have cached a design, loading shouldn't be indicated to user
return this.$apollo.queries.design.loading && !this.design.filename;
},
discussions() {
if (!this.design.discussions) {
return [];
}
return extractDiscussions(this.design.discussions);
},
markdownPreviewPath() {
return `/${this.projectPath}/preview_markdown?target_type=Issue`;
},
isSubmitButtonDisabled() {
return this.comment.trim().length === 0;
},
designVariables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
filenames: [this.$route.params.id],
atVersion: this.designsVersion,
};
},
mutationPayload() {
const { x, y, width, height } = this.annotationCoordinates;
return {
noteableId: this.design.id,
body: this.comment,
position: {
headSha: this.design.diffRefs.headSha,
baseSha: this.design.diffRefs.baseSha,
startSha: this.design.diffRefs.startSha,
x,
y,
width,
height,
paths: {
newPath: this.design.fullPath,
},
},
};
},
isAnnotating() {
return Boolean(this.annotationCoordinates);
},
resolvedDiscussions() {
return this.discussions.filter(discussion => discussion.resolved);
},
},
watch: {
resolvedDiscussions(val) {
if (!val.length) {
this.resolvedDiscussionsExpanded = false;
}
},
},
mounted() {
Mousetrap.bind('esc', this.closeDesign);
this.trackEvent();
// We need to reset the active discussion when opening a new design
this.updateActiveDiscussion();
},
beforeDestroy() {
Mousetrap.unbind('esc', this.closeDesign);
},
methods: {
addImageDiffNoteToStore(
store,
{
data: { createImageDiffNote },
},
) {
updateStoreAfterAddImageDiffNote(
store,
createImageDiffNote,
getDesignQuery,
this.designVariables,
);
},
updateImageDiffNoteInStore(
store,
{
data: { updateImageDiffNote },
},
) {
return updateStoreAfterUpdateImageDiffNote(
store,
updateImageDiffNote,
getDesignQuery,
this.designVariables,
);
},
onMoveNote({ noteId, discussionId, position }) {
const discussion = this.discussions.find(({ id }) => id === discussionId);
const note = discussion.notes.find(
({ discussion: noteDiscussion }) => noteDiscussion.id === discussionId,
);
const mutationPayload = {
optimisticResponse: updateImageDiffNoteOptimisticResponse(note, {
position,
}),
variables: {
input: {
id: noteId,
position,
},
},
mutation: updateImageDiffNoteMutation,
update: this.updateImageDiffNoteInStore,
};
return this.$apollo.mutate(mutationPayload).catch(e => this.onUpdateImageDiffNoteError(e));
},
onDesignQueryResult({ data, loading }) {
// On the initial load with cache-and-network policy data is undefined while loading is true
// To prevent throwing an error, we don't perform any logic until loading is false
if (loading) {
return;
}
if (!data || !extractDesign(data)) {
this.onQueryError(DESIGN_NOT_FOUND_ERROR);
} else if (this.$route.query.version && !this.hasValidVersion) {
this.onQueryError(DESIGN_VERSION_NOT_EXIST_ERROR);
}
},
onQueryError(message) {
// because we redirect user to /designs (the issue page),
// we want to create these flashes on the issue page
createFlash(message);
this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME });
},
onError(message, e) {
this.errorMessage = message;
throw e;
},
onCreateImageDiffNoteError(e) {
this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e);
},
onUpdateNoteError(e) {
this.onError(UPDATE_NOTE_ERROR, e);
},
onDesignDiscussionError(e) {
this.onError(ADD_DISCUSSION_COMMENT_ERROR, e);
},
onUpdateImageDiffNoteError(e) {
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
},
onDesignDeleteError(e) {
this.onError(designDeletionError({ singular: true }), e);
},
onResolveDiscussionError(e) {
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
},
openCommentForm(annotationCoordinates) {
this.annotationCoordinates = annotationCoordinates;
if (this.$refs.newDiscussionForm) {
this.$refs.newDiscussionForm.focusInput();
}
},
closeCommentForm() {
this.comment = '';
this.annotationCoordinates = null;
},
closeDesign() {
this.$router.push({
name: this.$options.DESIGNS_ROUTE_NAME,
query: this.$route.query,
});
},
trackEvent() {
// TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue
trackDesignDetailView(
'issue-design-collection',
'issue',
this.$route.query.version || this.latestVersionId,
this.isLatestVersion,
);
},
updateActiveDiscussion(id) {
this.$apollo.mutate({
mutation: updateActiveDiscussionMutation,
variables: {
id,
source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion,
},
});
},
toggleResolvedComments() {
this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded;
},
},
createImageDiffNoteMutation,
DESIGNS_ROUTE_NAME,
};
</script>
<template>
<div
class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
>
<gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" />
<template v-else>
<div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative">
<design-destroyer
:filenames="[design.filename]"
:project-path="projectPath"
:iid="issueIid"
@done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })"
@error="onDesignDeleteError"
>
<template #default="{ mutate, loading }">
<toolbar
:id="id"
:is-deleting="loading"
:is-latest-version="isLatestVersion"
v-bind="design"
@delete="mutate"
/>
</template>
</design-destroyer>
<div v-if="errorMessage" class="p-3">
<gl-alert variant="danger" @dismiss="errorMessage = null">
{{ errorMessage }}
</gl-alert>
</div>
<design-presentation
:image="design.image"
:image-name="design.filename"
:discussions="discussions"
:is-annotating="isAnnotating"
:scale="scale"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
@openCommentForm="openCommentForm"
@closeCommentForm="closeCommentForm"
@moveNote="onMoveNote"
/>
<div class="design-scaler-wrapper position-absolute mb-4 d-flex-center">
<design-scaler @scale="scale = $event" />
</div>
</div>
<design-sidebar
:design="design"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
:markdown-preview-path="markdownPreviewPath"
@onDesignDiscussionError="onDesignDiscussionError"
@onCreateImageDiffNoteError="onCreateImageDiffNoteError"
@updateNoteError="onUpdateNoteError"
@resolveDiscussionError="onResolveDiscussionError"
@toggleResolvedComments="toggleResolvedComments"
>
<template #replyForm>
<apollo-mutation
v-if="isAnnotating"
#default="{ mutate, loading }"
:mutation="$options.createImageDiffNoteMutation"
:variables="{
input: mutationPayload,
}"
:update="addImageDiffNoteToStore"
@done="closeCommentForm"
@error="onCreateImageDiffNoteError"
>
<design-reply-form
ref="newDiscussionForm"
v-model="comment"
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
@submitForm="mutate"
@cancelForm="closeCommentForm"
/> </apollo-mutation
></template>
</design-sidebar>
</template>
</div>
</template>
<script>
import { GlLoadingIcon, GlDeprecatedButton, GlAlert } from '@gitlab/ui';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__, sprintf } from '~/locale';
import UploadButton from '../components/upload/button.vue';
import DeleteButton from '../components/delete_button.vue';
import Design from '../components/list/item.vue';
import DesignDestroyer from '../components/design_destroyer.vue';
import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
import DesignDropzone from '../components/upload/design_dropzone.vue';
import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
import permissionsQuery from '../graphql/queries/design_permissions.query.graphql';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import allDesignsMixin from '../mixins/all_designs';
import {
UPLOAD_DESIGN_ERROR,
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
designUploadSkippedWarning,
designDeletionError,
} from '../utils/error_messages';
import { updateStoreAfterUploadDesign } from '../utils/cache_update';
import {
designUploadOptimisticResponse,
isValidDesignFile,
} from '../utils/design_management_utils';
import { getFilename } from '~/lib/utils/file_upload';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
export default {
components: {
GlLoadingIcon,
GlAlert,
GlDeprecatedButton,
UploadButton,
Design,
DesignDestroyer,
DesignVersionDropdown,
DeleteButton,
DesignDropzone,
},
mixins: [allDesignsMixin],
apollo: {
permissions: {
query: permissionsQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
};
},
update: data => data.project.issue.userPermissions,
},
},
data() {
return {
permissions: {
createDesign: false,
},
filesToBeSaved: [],
selectedDesigns: [],
};
},
computed: {
isLoading() {
return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading;
},
isSaving() {
return this.filesToBeSaved.length > 0;
},
canCreateDesign() {
return this.permissions.createDesign;
},
showToolbar() {
return this.canCreateDesign && this.allVersions.length > 0;
},
hasDesigns() {
return this.designs.length > 0;
},
hasSelectedDesigns() {
return this.selectedDesigns.length > 0;
},
canDeleteDesigns() {
return this.isLatestVersion && this.hasSelectedDesigns;
},
projectQueryBody() {
return {
query: getDesignListQuery,
variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null },
};
},
selectAllButtonText() {
return this.hasSelectedDesigns
? s__('DesignManagement|Deselect all')
: s__('DesignManagement|Select all');
},
},
mounted() {
this.toggleOnPasteListener(this.$route.name);
},
methods: {
resetFilesToBeSaved() {
this.filesToBeSaved = [];
},
/**
* Determine if a design upload is valid, given [files]
* @param {Array<File>} files
*/
isValidDesignUpload(files) {
if (!this.canCreateDesign) return false;
if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
createFlash(
sprintf(
s__(
'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
),
{
upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
},
),
);
return false;
}
return true;
},
onUploadDesign(files) {
// convert to Array so that we have Array methods (.map, .some, etc.)
this.filesToBeSaved = Array.from(files);
if (!this.isValidDesignUpload(this.filesToBeSaved)) return null;
const mutationPayload = {
optimisticResponse: designUploadOptimisticResponse(this.filesToBeSaved),
variables: {
files: this.filesToBeSaved,
projectPath: this.projectPath,
iid: this.issueIid,
},
context: {
hasUpload: true,
},
mutation: uploadDesignMutation,
update: this.afterUploadDesign,
};
return this.$apollo
.mutate(mutationPayload)
.then(res => this.onUploadDesignDone(res))
.catch(() => this.onUploadDesignError());
},
afterUploadDesign(
store,
{
data: { designManagementUpload },
},
) {
updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody);
},
onUploadDesignDone(res) {
const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
if (skippedWarningMessage) {
createFlash(skippedWarningMessage, 'warning');
}
// if this upload resulted in a new version being created, redirect user to the latest version
if (!this.isLatestVersion) {
this.$router.push({ name: DESIGNS_ROUTE_NAME }, () => {});
}
this.resetFilesToBeSaved();
},
onUploadDesignError() {
this.resetFilesToBeSaved();
createFlash(UPLOAD_DESIGN_ERROR);
},
changeSelectedDesigns(filename) {
if (this.isDesignSelected(filename)) {
this.selectedDesigns = this.selectedDesigns.filter(design => design !== filename);
} else {
this.selectedDesigns.push(filename);
}
},
toggleDesignsSelection() {
if (this.hasSelectedDesigns) {
this.selectedDesigns = [];
} else {
this.selectedDesigns = this.designs.map(design => design.filename);
}
},
isDesignSelected(filename) {
return this.selectedDesigns.includes(filename);
},
isDesignToBeSaved(filename) {
return this.filesToBeSaved.some(file => file.name === filename);
},
canSelectDesign(filename) {
return this.isLatestVersion && this.canCreateDesign && !this.isDesignToBeSaved(filename);
},
onDesignDelete() {
this.selectedDesigns = [];
if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME });
},
onDesignDeleteError() {
const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 });
createFlash(errorMessage);
},
onExistingDesignDropzoneChange(files, existingDesignFilename) {
const filesArr = Array.from(files);
if (filesArr.length > 1) {
createFlash(EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE);
return;
}
if (!filesArr.some(({ name }) => existingDesignFilename === name)) {
createFlash(EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE);
return;
}
this.onUploadDesign(files);
},
onDesignPaste(event) {
const { clipboardData } = event;
const files = Array.from(clipboardData.files);
if (clipboardData && files.length > 0) {
if (!files.some(isValidDesignFile)) {
return;
}
event.preventDefault();
let filename = getFilename(event);
if (!filename || filename === 'image.png') {
filename = `design_${Date.now()}.png`;
}
const newFile = new File([files[0]], filename);
this.onUploadDesign([newFile]);
}
},
toggleOnPasteListener(route) {
if (route === DESIGNS_ROUTE_NAME) {
document.addEventListener('paste', this.onDesignPaste);
} else {
document.removeEventListener('paste', this.onDesignPaste);
}
},
},
beforeRouteUpdate(to, from, next) {
this.toggleOnPasteListener(to.name);
this.selectedDesigns = [];
next();
},
beforeRouteLeave(to, from, next) {
this.toggleOnPasteListener(to.name);
next();
},
};
</script>
<template>
<div>
<header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex">
<div class="d-flex justify-content-between align-items-center w-100">
<design-version-dropdown />
<div :class="['qa-selector-toolbar', { 'd-flex': hasDesigns, 'd-none': !hasDesigns }]">
<gl-deprecated-button
v-if="isLatestVersion"
variant="link"
class="mr-2 js-select-all"
@click="toggleDesignsSelection"
>{{ selectAllButtonText }}</gl-deprecated-button
>
<design-destroyer
#default="{ mutate, loading }"
:filenames="selectedDesigns"
:project-path="projectPath"
:iid="issueIid"
@done="onDesignDelete"
@error="onDesignDeleteError"
>
<delete-button
v-if="isLatestVersion"
:is-deleting="loading"
button-class="btn-danger btn-inverted mr-2"
:has-selected-designs="hasSelectedDesigns"
@deleteSelectedDesigns="mutate()"
>
{{ s__('DesignManagement|Delete selected') }}
<gl-loading-icon v-if="loading" inline class="ml-1" />
</delete-button>
</design-destroyer>
<upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" />
</div>
</div>
</header>
<div class="mt-4">
<gl-loading-icon v-if="isLoading" size="md" />
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
{{ __('An error occurred while loading designs. Please try again.') }}
</gl-alert>
<ol v-else class="list-unstyled row">
<li class="col-md-6 col-lg-4 mb-3">
<design-dropzone class="design-list-item" @change="onUploadDesign" />
</li>
<li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-4 mb-3">
<design-dropzone @change="onExistingDesignDropzoneChange($event, design.filename)"
><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)"
/></design-dropzone>
<input
v-if="canSelectDesign(design.filename)"
:checked="isDesignSelected(design.filename)"
type="checkbox"
class="design-checkbox"
@change="changeSelectedDesigns(design.filename)"
/>
</li>
</ol>
</div>
<router-view :key="$route.fullPath" />
</div>
</template>
export const ROOT_ROUTE_NAME = 'root';
export const DESIGNS_ROUTE_NAME = 'designs';
export const DESIGN_ROUTE_NAME = 'design';
import $ from 'jquery';
import Vue from 'vue';
import VueRouter from 'vue-router';
import routes from './routes';
import { DESIGN_ROUTE_NAME } from './constants';
import { getPageLayoutElement } from '~/design_management_legacy/utils/design_management_utils';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants';
Vue.use(VueRouter);
export default function createRouter(base) {
const router = new VueRouter({
base,
mode: 'history',
routes,
});
const pageEl = getPageLayoutElement();
router.beforeEach(({ meta: { el }, name }, _, next) => {
$(`#${el}`).tab('show');
// apply a fullscreen layout style in Design View (a.k.a design detail)
if (pageEl) {
if (name === DESIGN_ROUTE_NAME) {
pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
} else {
pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
}
}
next();
});
return router;
}
import Home from '../pages/index.vue';
import DesignDetail from '../pages/design/index.vue';
import { ROOT_ROUTE_NAME, DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants';
export default [
{
name: ROOT_ROUTE_NAME,
path: '/',
component: Home,
meta: {
el: 'discussion',
},
},
{
name: DESIGNS_ROUTE_NAME,
path: '/designs',
component: Home,
meta: {
el: 'designs',
},
children: [
{
name: DESIGN_ROUTE_NAME,
path: ':id',
component: DesignDetail,
meta: {
el: 'designs',
},
beforeEnter(
{
params: { id },
},
from,
next,
) {
if (typeof id === 'string') {
next();
}
},
props: ({ params: { id } }) => ({ id }),
},
],
},
];
/* eslint-disable @gitlab/require-i18n-strings */
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { extractCurrentDiscussion, extractDesign } from './design_management_utils';
import {
ADD_IMAGE_DIFF_NOTE_ERROR,
UPDATE_IMAGE_DIFF_NOTE_ERROR,
ADD_DISCUSSION_COMMENT_ERROR,
designDeletionError,
} from './error_messages';
const deleteDesignsFromStore = (store, query, selectedDesigns) => {
const data = store.readQuery(query);
const changedDesigns = data.project.issue.designCollection.designs.edges.filter(
({ node }) => !selectedDesigns.includes(node.filename),
);
data.project.issue.designCollection.designs.edges = [...changedDesigns];
store.writeQuery({
...query,
data,
});
};
/**
* Adds a new version of designs to store
*
* @param {Object} store
* @param {Object} query
* @param {Object} version
*/
const addNewVersionToStore = (store, query, version) => {
if (!version) return;
const data = store.readQuery(query);
const newEdge = { node: version, __typename: 'DesignVersionEdge' };
data.project.issue.designCollection.versions.edges = [
newEdge,
...data.project.issue.designCollection.versions.edges,
];
store.writeQuery({
...query,
data,
});
};
const addDiscussionCommentToStore = (store, createNote, query, queryVariables, discussionId) => {
const data = store.readQuery({
query,
variables: queryVariables,
});
const design = extractDesign(data);
const currentDiscussion = extractCurrentDiscussion(design.discussions, discussionId);
currentDiscussion.notes.nodes = [...currentDiscussion.notes.nodes, createNote.note];
design.notesCount += 1;
if (
!design.issue.participants.edges.some(
participant => participant.node.username === createNote.note.author.username,
)
) {
design.issue.participants.edges = [
...design.issue.participants.edges,
{
__typename: 'UserEdge',
node: {
__typename: 'User',
...createNote.note.author,
},
},
];
}
store.writeQuery({
query,
variables: queryVariables,
data: {
...data,
design: {
...design,
},
},
});
};
const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) => {
const data = store.readQuery({
query,
variables,
});
const newDiscussion = {
__typename: 'Discussion',
id: createImageDiffNote.note.discussion.id,
replyId: createImageDiffNote.note.discussion.replyId,
resolvable: true,
resolved: false,
resolvedAt: null,
resolvedBy: null,
notes: {
__typename: 'NoteConnection',
nodes: [createImageDiffNote.note],
},
};
const design = extractDesign(data);
const notesCount = design.notesCount + 1;
design.discussions.nodes = [...design.discussions.nodes, newDiscussion];
if (
!design.issue.participants.edges.some(
participant => participant.node.username === createImageDiffNote.note.author.username,
)
) {
design.issue.participants.edges = [
...design.issue.participants.edges,
{
__typename: 'UserEdge',
node: {
__typename: 'User',
...createImageDiffNote.note.author,
},
},
];
}
store.writeQuery({
query,
variables,
data: {
...data,
design: {
...design,
notesCount,
},
},
});
};
const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => {
const data = store.readQuery({
query,
variables,
});
const design = extractDesign(data);
const discussion = extractCurrentDiscussion(
design.discussions,
updateImageDiffNote.note.discussion.id,
);
discussion.notes = {
...discussion.notes,
nodes: [updateImageDiffNote.note, ...discussion.notes.nodes.slice(1)],
};
store.writeQuery({
query,
variables,
data: {
...data,
design,
},
});
};
const addNewDesignToStore = (store, designManagementUpload, query) => {
const data = store.readQuery(query);
const newDesigns = data.project.issue.designCollection.designs.edges.reduce((acc, design) => {
if (!acc.find(d => d.filename === design.node.filename)) {
acc.push(design.node);
}
return acc;
}, designManagementUpload.designs);
let newVersionNode;
const findNewVersions = designManagementUpload.designs.find(design => design.versions);
if (findNewVersions) {
const findNewVersionsEdges = findNewVersions.versions.edges;
if (findNewVersionsEdges && findNewVersionsEdges.length) {
newVersionNode = [findNewVersionsEdges[0]];
}
}
const newVersions = [
...(newVersionNode || []),
...data.project.issue.designCollection.versions.edges,
];
const updatedDesigns = {
__typename: 'DesignCollection',
designs: {
__typename: 'DesignConnection',
edges: newDesigns.map(design => ({
__typename: 'DesignEdge',
node: design,
})),
},
versions: {
__typename: 'DesignVersionConnection',
edges: newVersions,
},
};
data.project.issue.designCollection = updatedDesigns;
store.writeQuery({
...query,
data,
});
};
const onError = (data, message) => {
createFlash(message);
throw new Error(data.errors);
};
export const hasErrors = ({ errors = [] }) => errors?.length;
/**
* Updates a store after design deletion
*
* @param {Object} store
* @param {Object} data
* @param {Object} query
* @param {Array} designs
*/
export const updateStoreAfterDesignsDelete = (store, data, query, designs) => {
if (hasErrors(data)) {
onError(data, designDeletionError({ singular: designs.length === 1 }));
} else {
deleteDesignsFromStore(store, query, designs);
addNewVersionToStore(store, query, data.version);
}
};
export const updateStoreAfterAddDiscussionComment = (
store,
data,
query,
queryVariables,
discussionId,
) => {
if (hasErrors(data)) {
onError(data, ADD_DISCUSSION_COMMENT_ERROR);
} else {
addDiscussionCommentToStore(store, data, query, queryVariables, discussionId);
}
};
export const updateStoreAfterAddImageDiffNote = (store, data, query, queryVariables) => {
if (hasErrors(data)) {
onError(data, ADD_IMAGE_DIFF_NOTE_ERROR);
} else {
addImageDiffNoteToStore(store, data, query, queryVariables);
}
};
export const updateStoreAfterUpdateImageDiffNote = (store, data, query, queryVariables) => {
if (hasErrors(data)) {
onError(data, UPDATE_IMAGE_DIFF_NOTE_ERROR);
} else {
updateImageDiffNoteInStore(store, data, query, queryVariables);
}
};
export const updateStoreAfterUploadDesign = (store, data, query) => {
if (hasErrors(data)) {
onError(data, data.errors[0]);
} else {
addNewDesignToStore(store, data, query);
}
};
import { uniqueId } from 'lodash';
import { VALID_DESIGN_FILE_MIMETYPE } from '../constants';
export const isValidDesignFile = ({ type }) =>
(type.match(VALID_DESIGN_FILE_MIMETYPE.regex) || []).length > 0;
/**
* Returns formatted array that doesn't contain
* `edges`->`node` nesting
*
* @param {Array} elements
*/
export const extractNodes = elements => elements.edges.map(({ node }) => node);
/**
* Returns formatted array of discussions that doesn't contain
* `edges`->`node` nesting for child notes
*
* @param {Array} discussions
*/
export const extractDiscussions = discussions =>
discussions.nodes.map((discussion, index) => ({
...discussion,
index: index + 1,
notes: discussion.notes.nodes,
}));
/**
* Returns a discussion with the given id from discussions array
*
* @param {Array} discussions
*/
export const extractCurrentDiscussion = (discussions, id) =>
discussions.nodes.find(discussion => discussion.id === id);
export const findVersionId = id => (id.match('::Version/(.+$)') || [])[1];
export const findNoteId = id => (id.match('DiffNote/(.+$)') || [])[1];
export const extractDesigns = data => data.project.issue.designCollection.designs.edges;
export const extractDesign = data => (extractDesigns(data) || [])[0]?.node;
/**
* Generates optimistic response for a design upload mutation
* @param {Array<File>} files
*/
export const designUploadOptimisticResponse = files => {
const designs = files.map(file => ({
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Design',
id: -uniqueId(),
image: '',
imageV432x230: '',
filename: file.name,
fullPath: '',
notesCount: 0,
event: 'NONE',
diffRefs: {
__typename: 'DiffRefs',
baseSha: '',
startSha: '',
headSha: '',
},
discussions: {
__typename: 'DesignDiscussion',
nodes: [],
},
versions: {
__typename: 'DesignVersionConnection',
edges: {
__typename: 'DesignVersionEdge',
node: {
__typename: 'DesignVersion',
id: -uniqueId(),
sha: -uniqueId(),
},
},
},
}));
return {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Mutation',
designManagementUpload: {
__typename: 'DesignManagementUploadPayload',
designs,
skippedDesigns: [],
errors: [],
},
};
};
/**
* Generates optimistic response for a design upload mutation
* @param {Array<File>} files
*/
export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Mutation',
updateImageDiffNote: {
__typename: 'UpdateImageDiffNotePayload',
note: {
...note,
position: {
...note.position,
...position,
},
},
errors: [],
},
});
const normalizeAuthor = author => ({
...author,
web_url: author.webUrl,
avatar_url: author.avatarUrl,
});
export const extractParticipants = users => users.edges.map(({ node }) => normalizeAuthor(node));
export const getPageLayoutElement = () => document.querySelector('.layout-page');
import { __, s__, n__, sprintf } from '~/locale';
export const ADD_DISCUSSION_COMMENT_ERROR = s__(
'DesignManagement|Could not add a new comment. Please try again.',
);
export const ADD_IMAGE_DIFF_NOTE_ERROR = s__(
'DesignManagement|Could not create new discussion. Please try again.',
);
export const UPDATE_IMAGE_DIFF_NOTE_ERROR = s__(
'DesignManagement|Could not update discussion. Please try again.',
);
export const UPDATE_NOTE_ERROR = s__('DesignManagement|Could not update note. Please try again.');
export const UPLOAD_DESIGN_ERROR = s__(
'DesignManagement|Error uploading a new design. Please try again.',
);
export const UPLOAD_DESIGN_INVALID_FILETYPE_ERROR = __(
'Could not upload your designs as one or more files uploaded are not supported.',
);
export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.');
export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.');
const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped.');
const ALL_DESIGNS_SKIPPED_MESSAGE = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
'The designs you tried uploading did not change.',
)}`;
export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __(
'You can only upload one design when dropping onto an existing design.',
);
export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __(
'You must upload a file with the same file name when dropping onto an existing design.',
);
const MAX_SKIPPED_FILES_LISTINGS = 5;
const oneDesignSkippedMessage = filename =>
`${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${sprintf(s__('DesignManagement|%{filename} did not change.'), {
filename,
})}`;
/**
* Return warning message indicating that some (but not all) uploaded
* files were skipped.
* @param {Array<{ filename }>} skippedFiles
*/
const someDesignsSkippedMessage = skippedFiles => {
const designsSkippedMessage = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
'Some of the designs you tried uploading did not change:',
)}`;
const moreText = sprintf(s__(`DesignManagement|and %{moreCount} more.`), {
moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS,
});
return `${designsSkippedMessage} ${skippedFiles
.slice(0, MAX_SKIPPED_FILES_LISTINGS)
.map(({ filename }) => filename)
.join(', ')}${skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS ? `, ${moreText}` : '.'}`;
};
export const designDeletionError = ({ singular = true } = {}) => {
const design = singular ? __('a design') : __('designs');
return sprintf(s__('Could not delete %{design}. Please try again.'), {
design,
});
};
/**
* Return warning message, if applicable, that one, some or all uploaded
* files were skipped.
* @param {Array<{ filename }>} uploadedDesigns
* @param {Array<{ filename }>} skippedFiles
*/
export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => {
if (skippedFiles.length === 0) {
return null;
}
if (skippedFiles.length === uploadedDesigns.length) {
const { filename } = skippedFiles[0];
return n__(oneDesignSkippedMessage(filename), ALL_DESIGNS_SKIPPED_MESSAGE, skippedFiles.length);
}
return someDesignsSkippedMessage(skippedFiles);
};
import Tracking from '~/tracking';
// Tracking Constants
const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0';
const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
const DESIGN_TRACKING_EVENT_NAME = 'view_design';
export function trackDesignDetailView(
referer = '',
owner = '',
designVersion = 1,
latestVersion = false,
) {
Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, {
label: DESIGN_TRACKING_EVENT_NAME,
context: {
schema: DESIGN_TRACKING_CONTEXT_SCHEMA,
data: {
'design-version-number': designVersion,
'design-is-current-version': latestVersion,
'internal-object-referrer': referer,
'design-collection-owner': owner,
},
},
});
}
<script> <script>
import { mapState, mapGetters } from 'vuex'; import { mapState, mapGetters } from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import IdeTree from './ide_tree.vue'; import IdeTree from './ide_tree.vue';
import ResizablePanel from './resizable_panel.vue'; import ResizablePanel from './resizable_panel.vue';
import ActivityBar from './activity_bar.vue'; import ActivityBar from './activity_bar.vue';
......
<script> <script>
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import FileTree from '~/vue_shared/components/file_tree.vue'; import FileTree from '~/vue_shared/components/file_tree.vue';
import IdeFileRow from './ide_file_row.vue'; import IdeFileRow from './ide_file_row.vue';
import NavDropdown from './nav_dropdown.vue'; import NavDropdown from './nav_dropdown.vue';
......
...@@ -208,6 +208,31 @@ export default { ...@@ -208,6 +208,31 @@ export default {
isEmpty() { isEmpty() {
return !this.incidents.list?.length; return !this.incidents.list?.length;
}, },
showList() {
return !this.isEmpty || this.errored || this.loading;
},
activeClosedTabHasNoIncidents() {
const { all, closed } = this.incidentsCount || {};
const isClosedTabActive = this.statusFilter === this.$options.statusTabs[1].filters;
return isClosedTabActive && all && !closed;
},
emptyStateData() {
const {
emptyState: { title, emptyClosedTabTitle, description },
createIncidentBtnLabel,
} = this.$options.i18n;
if (this.activeClosedTabHasNoIncidents) {
return { title: emptyClosedTabTitle };
}
return {
title,
description,
btnLink: this.newIncidentPath,
btnText: createIncidentBtnLabel,
};
},
}, },
methods: { methods: {
onInputChange: debounce(function debounceSearch(input) { onInputChange: debounce(function debounceSearch(input) {
...@@ -279,7 +304,7 @@ export default { ...@@ -279,7 +304,7 @@ export default {
</gl-tabs> </gl-tabs>
<gl-button <gl-button
v-if="!isEmpty" v-if="!isEmpty || activeClosedTabHasNoIncidents"
class="gl-my-3 gl-mr-5 create-incident-button" class="gl-my-3 gl-mr-5 create-incident-button"
data-testid="createIncidentBtn" data-testid="createIncidentBtn"
data-qa-selector="create_incident_button" data-qa-selector="create_incident_button"
...@@ -307,6 +332,7 @@ export default { ...@@ -307,6 +332,7 @@ export default {
{{ s__('IncidentManagement|Incidents') }} {{ s__('IncidentManagement|Incidents') }}
</h4> </h4>
<gl-table <gl-table
v-if="showList"
:items="incidents.list || []" :items="incidents.list || []"
:fields="availableFields" :fields="availableFields"
:show-empty="true" :show-empty="true"
...@@ -379,21 +405,20 @@ export default { ...@@ -379,21 +405,20 @@ export default {
<gl-loading-icon size="lg" color="dark" class="mt-3" /> <gl-loading-icon size="lg" color="dark" class="mt-3" />
</template> </template>
<template #empty> <template v-if="errored" #empty>
<gl-empty-state {{ $options.i18n.noIncidents }}
v-if="!errored"
:title="$options.i18n.emptyState.title"
:svg-path="emptyListSvgPath"
:description="$options.i18n.emptyState.description"
:primary-button-link="newIncidentPath"
:primary-button-text="$options.i18n.createIncidentBtnLabel"
/>
<span v-else>
{{ $options.i18n.noIncidents }}
</span>
</template> </template>
</gl-table> </gl-table>
<gl-empty-state
v-else
:title="emptyStateData.title"
:svg-path="emptyListSvgPath"
:description="emptyStateData.description"
:primary-button-link="emptyStateData.btnLink"
:primary-button-text="emptyStateData.btnText"
/>
<gl-pagination <gl-pagination
v-if="showPaginationControls" v-if="showPaginationControls"
:value="pagination.currentPage" :value="pagination.currentPage"
......
...@@ -9,6 +9,7 @@ export const I18N = { ...@@ -9,6 +9,7 @@ export const I18N = {
searchPlaceholder: __('Search results…'), searchPlaceholder: __('Search results…'),
emptyState: { emptyState: {
title: s__('IncidentManagement|Display your incidents in a dedicated view'), title: s__('IncidentManagement|Display your incidents in a dedicated view'),
emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'),
description: s__( description: s__(
'IncidentManagement|All alerts promoted to incidents will automatically be displayed within the list. You can also create a new incident using the button below.', 'IncidentManagement|All alerts promoted to incidents will automatically be displayed within the list. You can also create a new incident using the button below.',
), ),
......
...@@ -3,7 +3,7 @@ import { toNumber, omit } from 'lodash'; ...@@ -3,7 +3,7 @@ import { toNumber, omit } from 'lodash';
import { import {
GlEmptyState, GlEmptyState,
GlPagination, GlPagination,
GlSkeletonLoading, GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlSafeHtmlDirective as SafeHtml, GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { deprecatedCreateFlash as flash } from '~/flash'; import { deprecatedCreateFlash as flash } from '~/flash';
......
<script> <script>
/* eslint-disable @gitlab/vue-require-i18n-strings */ /* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; import { GlPopover, GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import CiIcon from '../../vue_shared/components/ci_icon.vue'; import CiIcon from '../../vue_shared/components/ci_icon.vue';
import timeagoMixin from '../../vue_shared/mixins/timeago'; import timeagoMixin from '../../vue_shared/mixins/timeago';
import query from '../queries/merge_request.query.graphql'; import query from '../queries/merge_request.query.graphql';
......
...@@ -17,7 +17,7 @@ import Autosize from 'autosize'; ...@@ -17,7 +17,7 @@ import Autosize from 'autosize';
import 'jquery.caret'; // required by at.js import 'jquery.caret'; // required by at.js
import '@gitlab/at.js'; import '@gitlab/at.js';
import Vue from 'vue'; import Vue from 'vue';
import { GlSkeletonLoading } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import AjaxCache from '~/lib/utils/ajax_cache'; import AjaxCache from '~/lib/utils/ajax_cache';
import syntaxHighlight from '~/syntax_highlight'; import syntaxHighlight from '~/syntax_highlight';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
......
<script> <script>
/* eslint-disable vue/no-v-html */ /* eslint-disable vue/no-v-html */
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
......
...@@ -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(() => {});
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlSkeletonLoading, GlEmptyState, GlLink, GlButton } from '@gitlab/ui'; import {
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlEmptyState,
GlLink,
GlButton,
} from '@gitlab/ui';
import { import {
getParameterByName, getParameterByName,
historyPushState, historyPushState,
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import ReleaseBlock from './release_block.vue'; import ReleaseBlock from './release_block.vue';
export default { export default {
......
<script> <script>
import { GlSkeletonLoading, GlButton } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlButton } from '@gitlab/ui';
import { sprintf, __ } from '../../../locale'; import { sprintf, __ } from '../../../locale';
import getRefMixin from '../../mixins/get_ref'; import getRefMixin from '../../mixins/get_ref';
import projectPathQuery from '../../queries/project_path.query.graphql'; import projectPathQuery from '../../queries/project_path.query.graphql';
......
...@@ -4,7 +4,7 @@ import { escapeRegExp } from 'lodash'; ...@@ -4,7 +4,7 @@ import { escapeRegExp } from 'lodash';
import { import {
GlBadge, GlBadge,
GlLink, GlLink,
GlSkeletonLoading, GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlTooltipDirective, GlTooltipDirective,
GlLoadingIcon, GlLoadingIcon,
GlIcon, GlIcon,
......
<script> <script>
import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf } from '@gitlab/ui';
import { n__ } from '~/locale'; import { n__ } from '~/locale';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import MrWidgetExpanableSection from '../mr_widget_expandable_section.vue'; import MrWidgetExpanableSection from '../mr_widget_expandable_section.vue';
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import $ from 'jquery'; import $ from 'jquery';
import '~/behaviors/markdown/render_gfm'; import '~/behaviors/markdown/render_gfm';
import { GlSkeletonLoading } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { forEach, escape } from 'lodash'; import { forEach, escape } from 'lodash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
......
<script> <script>
import { GlSkeletonLoading } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
export default { export default {
......
...@@ -19,7 +19,12 @@ ...@@ -19,7 +19,12 @@
*/ */
import $ from 'jquery'; import $ from 'jquery';
import { mapGetters, mapActions, mapState } from 'vuex'; import { mapGetters, mapActions, mapState } from 'vuex';
import { GlDeprecatedButton, GlSkeletonLoading, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import {
GlDeprecatedButton,
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlTooltipDirective,
GlIcon,
} from '@gitlab/ui';
import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
import noteHeader from '~/notes/components/note_header.vue'; import noteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
......
<script> <script>
/* eslint-disable vue/no-v-html */ /* eslint-disable vue/no-v-html */
import { GlPopover, GlSkeletonLoading, GlIcon } from '@gitlab/ui'; import { GlPopover, GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlIcon } from '@gitlab/ui';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji'; import { glEmojiTag } from '../../../emoji';
......
...@@ -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
---
title: Update empty state behavior for incidents list
merge_request: 40872
author:
type: other
---
title: Add kubernetes_agent_gitops_sync usage ping metric
merge_request: 40568
author:
type: other
---
title: Restore doorkeeper generator to hex due to breaking change
merge_request: 41169
author:
type: fixed
...@@ -3,6 +3,10 @@ Doorkeeper.configure do ...@@ -3,6 +3,10 @@ Doorkeeper.configure do
# Currently supported options are :active_record, :mongoid2, :mongoid3, :mongo_mapper # Currently supported options are :active_record, :mongoid2, :mongoid3, :mongo_mapper
orm :active_record orm :active_record
# Restore to pre-5.1 generator due to breaking change.
# See https://gitlab.com/gitlab-org/gitlab/-/issues/244371
default_generator_method :hex
# This block will be called to check whether the resource owner is authenticated or not. # This block will be called to check whether the resource owner is authenticated or not.
resource_owner_authenticator do resource_owner_authenticator do
# Put your resource owner authentication logic here. # Put your resource owner authentication logic here.
......
...@@ -136,6 +136,27 @@ are true for the user in question: ...@@ -136,6 +136,27 @@ are true for the user in question:
- Run [an LDAP check command](#ldap-check) to make sure that the LDAP settings - Run [an LDAP check command](#ldap-check) to make sure that the LDAP settings
are correct and [GitLab can see your users](#no-users-are-found). are correct and [GitLab can see your users](#no-users-are-found).
#### Access denied for your LDAP account
There is [a bug](https://gitlab.com/gitlab-org/gitlab/-/issues/235930) that
may affect users with [Auditor level access](../../auditor_users.md). When
downgrading from Premium/Ultimate, Auditor users who try to sign in
may see the following message: `Access denied for your LDAP account`.
We have a workaround, based on toggling the access level of affected users:
1. As an administrator, go to **Admin Area > Overview > Users**.
1. Select the name of the affected user.
1. In the user's administrative page, press **Edit** on the top right of the page.
1. Change the user's access level from **Regular** to **Admin** (or vice versa),
and press **Save changes** at the bottom of the page.
1. Press **Edit** on the top right of the user's profile page
again.
1. Restore the user's original access level (**Regular** or **Admin**)
and press **Save changes** again.
The user should now be able to sign in.
#### Email has already been taken #### Email has already been taken
A user tries to sign-in with the correct LDAP credentials, is denied access, A user tries to sign-in with the correct LDAP credentials, is denied access,
......
...@@ -169,9 +169,9 @@ Parameters: ...@@ -169,9 +169,9 @@ Parameters:
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
|:------------|:--------|:---------|:-------------------------------------------------------------------| |:------------|:--------|:---------|:-------------------------------------------------------------------|
| `id` | integer | yes | ID of snippet to retrieve | | `id` | integer | yes | ID of snippet to retrieve. |
| `ref` | string | yes | Reference to a tag, branch or commit | | `ref` | string | yes | Reference to a tag, branch or commit. |
| `file_path` | string | yes | URL-encoded path to the file | | `file_path` | string | yes | URL-encoded path to the file. |
Example request: Example request:
......
...@@ -377,7 +377,7 @@ Alternatively, when users need to [link SAML to their existing GitLab.com accoun ...@@ -377,7 +377,7 @@ Alternatively, when users need to [link SAML to their existing GitLab.com accoun
| Cause | Solution | | Cause | Solution |
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| As mentioned in the [NameID](#nameid) section, if the NameID changes for any user, the user can be locked out. This is a common problem when an email address is used as the identifier. | Follow the steps outlined in the ["SAML authentication failed: User has already been taken"](#message-saml-authentication-failed-user-has-already-been-taken) section. If many users are affected, we recommend that you use the appropriate API. | | As mentioned in the [NameID](#nameid) section, if the NameID changes for any user, the user can be locked out. This is a common problem when an email address is used as the identifier. | Follow the steps outlined in the ["SAML authentication failed: User has already been taken"](#message-saml-authentication-failed-user-has-already-been-taken) section. |
### I need to change my SAML app ### I need to change my SAML app
......
...@@ -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,
......
...@@ -96,6 +96,25 @@ module API ...@@ -96,6 +96,25 @@ module API
gitaly_repository: gitaly_repository(project) gitaly_repository: gitaly_repository(project)
} }
end end
desc 'POST usage metrics' do
detail 'Updates usage metrics for agent'
end
route_setting :authentication, cluster_agent_token_allowed: true
params do
requires :gitops_sync_count, type: Integer, desc: 'The count to increment the gitops_sync metric by'
end
post '/usage_metrics' do
gitops_sync_count = params[:gitops_sync_count]
if gitops_sync_count < 0
bad_request!('gitops_sync_count must be greater than or equal to zero')
else
Gitlab::UsageDataCounters::KubernetesAgentCounter.increment_gitops_sync(gitops_sync_count)
no_content!
end
end
end end
end end
end end
......
...@@ -26,8 +26,6 @@ module Gitlab ...@@ -26,8 +26,6 @@ module Gitlab
# Sanitize fields based on those sanitized from Rails. # Sanitize fields based on those sanitized from Rails.
config.sanitize_fields = Rails.application.config.filter_parameters.map(&:to_s) config.sanitize_fields = Rails.application.config.filter_parameters.map(&:to_s)
config.processors << ::Gitlab::ErrorTracking::Processor::SidekiqProcessor config.processors << ::Gitlab::ErrorTracking::Processor::SidekiqProcessor
config.processors << ::Gitlab::ErrorTracking::Processor::GrpcErrorProcessor
# Sanitize authentication headers # Sanitize authentication headers
config.sanitize_http_headers = %w[Authorization Private-Token] config.sanitize_http_headers = %w[Authorization Private-Token]
config.tags = extra_tags_from_env.merge(program: Gitlab.process_name) config.tags = extra_tags_from_env.merge(program: Gitlab.process_name)
......
# frozen_string_literal: true
module Gitlab
module ErrorTracking
module Processor
class GrpcErrorProcessor < ::Raven::Processor
DEBUG_ERROR_STRING_REGEX = RE2('(.*) debug_error_string:(.*)')
def process(value)
return value unless grpc_exception?(value)
process_message(value)
process_exception_values(value)
process_custom_fingerprint(value)
value
end
def grpc_exception?(value)
value[:exception] && value[:message].start_with?('GRPC::')
end
def process_message(value)
message, debug_str = split_debug_error_string(value[:message])
return unless message
value[:message] = message
extra = value[:extra] || {}
extra[:grpc_debug_error_string] = debug_str if debug_str
end
def process_exception_values(value)
exceptions = value.dig(:exception, :values)
return unless exceptions.is_a?(Array)
exceptions.each do |entry|
message, _ = split_debug_error_string(entry[:value])
entry[:value] = message if message
end
end
def process_custom_fingerprint(value)
fingerprint = value[:fingerprint]
return value unless custom_grpc_fingerprint?(fingerprint)
message, _ = split_debug_error_string(fingerprint[1])
fingerprint[1] = message if message
end
private
def custom_grpc_fingerprint?(fingerprint)
fingerprint.is_a?(Array) && fingerprint.length == 2 && fingerprint[0].start_with?('GRPC::')
end
def split_debug_error_string(message)
return unless message
match = DEBUG_ERROR_STRING_REGEX.match(message)
return unless match
[match[1], match[2]]
end
end
end
end
end
...@@ -13,7 +13,6 @@ module Gitlab ...@@ -13,7 +13,6 @@ module Gitlab
TAG_REF_PREFIX = "refs/tags/" TAG_REF_PREFIX = "refs/tags/"
BRANCH_REF_PREFIX = "refs/heads/" BRANCH_REF_PREFIX = "refs/heads/"
BaseError = Class.new(StandardError)
CommandError = Class.new(BaseError) CommandError = Class.new(BaseError)
CommitError = Class.new(BaseError) CommitError = Class.new(BaseError)
OSError = Class.new(BaseError) OSError = Class.new(BaseError)
......
# frozen_string_literal: true
module Gitlab
module Git
class BaseError < StandardError
DEBUG_ERROR_STRING_REGEX = /(.*?) debug_error_string:.*$/m.freeze
def initialize(msg = nil)
if msg
raw_message = msg.to_s
match = DEBUG_ERROR_STRING_REGEX.match(raw_message)
raw_message = match[1] if match
super(raw_message)
else
super
end
end
end
end
end
...@@ -245,7 +245,8 @@ module Gitlab ...@@ -245,7 +245,8 @@ module Gitlab
Gitlab::UsageDataCounters::ProductivityAnalyticsCounter, Gitlab::UsageDataCounters::ProductivityAnalyticsCounter,
Gitlab::UsageDataCounters::SourceCodeCounter, Gitlab::UsageDataCounters::SourceCodeCounter,
Gitlab::UsageDataCounters::MergeRequestCounter, Gitlab::UsageDataCounters::MergeRequestCounter,
Gitlab::UsageDataCounters::DesignsCounter Gitlab::UsageDataCounters::DesignsCounter,
Gitlab::UsageDataCounters::KubernetesAgentCounter
] ]
end end
......
# frozen_string_literal: true
module Gitlab
module UsageDataCounters
class KubernetesAgentCounter < BaseCounter
PREFIX = 'kubernetes_agent'
KNOWN_EVENTS = %w[gitops_sync].freeze
class << self
def increment_gitops_sync(incr)
raise ArgumentError, 'must be greater than or equal to zero' if incr < 0
# rather then hitting redis for this no-op, we return early
# note: redis returns the increment, so we mimic this here
return 0 if incr == 0
increment_by(redis_key(:gitops_sync), incr)
end
end
end
end
end
...@@ -9,6 +9,12 @@ module Gitlab ...@@ -9,6 +9,12 @@ module Gitlab
Gitlab::Redis::SharedState.with { |redis| redis.incr(redis_counter_key) } Gitlab::Redis::SharedState.with { |redis| redis.incr(redis_counter_key) }
end end
def increment_by(redis_counter_key, incr)
return unless Gitlab::CurrentSettings.usage_ping_enabled
Gitlab::Redis::SharedState.with { |redis| redis.incrby(redis_counter_key, incr) }
end
def total_count(redis_counter_key) def total_count(redis_counter_key)
Gitlab::Redis::SharedState.with { |redis| redis.get(redis_counter_key).to_i } Gitlab::Redis::SharedState.with { |redis| redis.get(redis_counter_key).to_i }
end end
......
此差异已折叠。
...@@ -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
此差异已折叠。
此差异已折叠。
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册