diff options
Diffstat (limited to 'app/assets/javascripts/design_management_legacy/components')
20 files changed, 2519 insertions, 0 deletions
diff --git a/app/assets/javascripts/design_management_legacy/components/app.vue b/app/assets/javascripts/design_management_legacy/components/app.vue new file mode 100644 index 00000000000..98240aef810 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/app.vue @@ -0,0 +1,3 @@ +<template> + <router-view /> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/delete_button.vue b/app/assets/javascripts/design_management_legacy/components/delete_button.vue new file mode 100644 index 00000000000..1fd902c9ed7 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/delete_button.vue @@ -0,0 +1,64 @@ +<script> +import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; + +export default { + name: 'DeleteButton', + components: { + GlDeprecatedButton, + GlModal, + }, + directives: { + GlModalDirective, + }, + props: { + isDeleting: { + type: Boolean, + required: false, + default: false, + }, + buttonClass: { + type: String, + required: false, + default: '', + }, + buttonVariant: { + type: String, + required: false, + default: '', + }, + hasSelectedDesigns: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + modalId: uniqueId('design-deletion-confirmation-'), + }; + }, +}; +</script> + +<template> + <div> + <gl-modal + :modal-id="modalId" + :title="s__('DesignManagement|Delete designs confirmation')" + :ok-title="s__('DesignManagement|Delete')" + ok-variant="danger" + @ok="$emit('deleteSelectedDesigns')" + > + <p>{{ s__('DesignManagement|Are you sure you want to delete the selected designs?') }}</p> + </gl-modal> + <gl-deprecated-button + v-gl-modal-directive="modalId" + :variant="buttonVariant" + :disabled="isDeleting || !hasSelectedDesigns" + :class="buttonClass" + > + <slot></slot> + </gl-deprecated-button> + </div> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/design_destroyer.vue b/app/assets/javascripts/design_management_legacy/components/design_destroyer.vue new file mode 100644 index 00000000000..62460ca551c --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/design_destroyer.vue @@ -0,0 +1,66 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql'; +import { updateStoreAfterDesignsDelete } from '../utils/cache_update'; + +export default { + components: { + ApolloMutation, + }, + props: { + filenames: { + type: Array, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + iid: { + type: String, + required: true, + }, + }, + computed: { + projectQueryBody() { + return { + query: getDesignListQuery, + variables: { fullPath: this.projectPath, iid: this.iid, atVersion: null }, + }; + }, + }, + methods: { + updateStoreAfterDelete( + store, + { + data: { designManagementDelete }, + }, + ) { + updateStoreAfterDesignsDelete( + store, + designManagementDelete, + this.projectQueryBody, + this.filenames, + ); + }, + }, + destroyDesignMutation, +}; +</script> + +<template> + <apollo-mutation + #default="{ mutate, loading, error }" + :mutation="$options.destroyDesignMutation" + :variables="{ + filenames, + projectPath, + iid, + }" + :update="updateStoreAfterDelete" + v-on="$listeners" + > + <slot v-bind="{ mutate, loading, error }"></slot> + </apollo-mutation> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/design_note_pin.vue b/app/assets/javascripts/design_management_legacy/components/design_note_pin.vue new file mode 100644 index 00000000000..2b5e62c2870 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/design_note_pin.vue @@ -0,0 +1,61 @@ +<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> diff --git a/app/assets/javascripts/design_management_legacy/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management_legacy/components/design_notes/design_discussion.vue new file mode 100644 index 00000000000..6a20517eed7 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/design_notes/design_discussion.vue @@ -0,0 +1,297 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import allVersionsMixin from '../../mixins/all_versions'; +import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql'; +import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql'; +import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; +import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql'; +import DesignNote from './design_note.vue'; +import DesignReplyForm from './design_reply_form.vue'; +import { updateStoreAfterAddDiscussionComment } from '../../utils/cache_update'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; +import ToggleRepliesWidget from './toggle_replies_widget.vue'; + +export default { + components: { + ApolloMutation, + DesignNote, + ReplyPlaceholder, + DesignReplyForm, + GlIcon, + GlLoadingIcon, + GlLink, + ToggleRepliesWidget, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [allVersionsMixin], + props: { + discussion: { + type: Object, + required: true, + }, + noteableId: { + type: String, + required: true, + }, + designId: { + type: String, + required: true, + }, + markdownPreviewPath: { + type: String, + required: false, + default: '', + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + discussionWithOpenForm: { + type: String, + required: true, + }, + }, + apollo: { + activeDiscussion: { + query: activeDiscussionQuery, + result({ data }) { + const discussionId = data.activeDiscussion.id; + if (this.discussion.resolved && !this.resolvedDiscussionsExpanded) { + return; + } + // We watch any changes to the active discussion from the design pins and scroll to this discussion if it exists + // We don't want scrollIntoView to be triggered from the discussion click itself + if ( + discussionId && + data.activeDiscussion.source === ACTIVE_DISCUSSION_SOURCE_TYPES.pin && + discussionId === this.discussion.notes[0].id + ) { + this.$el.scrollIntoView({ + behavior: 'smooth', + inline: 'start', + }); + } + }, + }, + }, + data() { + return { + discussionComment: '', + isFormRendered: false, + activeDiscussion: {}, + isResolving: false, + shouldChangeResolvedStatus: false, + areRepliesCollapsed: this.discussion.resolved, + }; + }, + computed: { + mutationPayload() { + return { + noteableId: this.noteableId, + body: this.discussionComment, + discussionId: this.discussion.id, + }; + }, + designVariables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + filenames: [this.$route.params.id], + atVersion: this.designsVersion, + }; + }, + isDiscussionHighlighted() { + return this.discussion.notes[0].id === this.activeDiscussion.id; + }, + resolveCheckboxText() { + return this.discussion.resolved + ? s__('DesignManagement|Unresolve thread') + : s__('DesignManagement|Resolve thread'); + }, + firstNote() { + return this.discussion.notes[0]; + }, + discussionReplies() { + return this.discussion.notes.slice(1); + }, + areRepliesShown() { + return !this.discussion.resolved || !this.areRepliesCollapsed; + }, + resolveIconName() { + return this.discussion.resolved ? 'check-circle-filled' : 'check-circle'; + }, + isRepliesWidgetVisible() { + return this.discussion.resolved && this.discussionReplies.length > 0; + }, + isReplyPlaceholderVisible() { + return this.areRepliesShown || !this.discussionReplies.length; + }, + isFormVisible() { + return this.isFormRendered && this.discussionWithOpenForm === this.discussion.id; + }, + }, + methods: { + addDiscussionComment( + store, + { + data: { createNote }, + }, + ) { + updateStoreAfterAddDiscussionComment( + store, + createNote, + getDesignQuery, + this.designVariables, + this.discussion.id, + ); + }, + onDone() { + this.discussionComment = ''; + this.hideForm(); + if (this.shouldChangeResolvedStatus) { + this.toggleResolvedStatus(); + } + }, + onCreateNoteError(err) { + this.$emit('createNoteError', err); + }, + hideForm() { + this.isFormRendered = false; + this.discussionComment = ''; + }, + showForm() { + this.$emit('openForm', this.discussion.id); + this.isFormRendered = true; + }, + toggleResolvedStatus() { + this.isResolving = true; + this.$apollo + .mutate({ + mutation: toggleResolveDiscussionMutation, + variables: { id: this.discussion.id, resolve: !this.discussion.resolved }, + }) + .then(({ data }) => { + if (data.errors?.length > 0) { + this.$emit('resolveDiscussionError', data.errors[0]); + } + }) + .catch(err => { + this.$emit('resolveDiscussionError', err); + }) + .finally(() => { + this.isResolving = false; + }); + }, + }, + createNoteMutation, +}; +</script> + +<template> + <div class="design-discussion-wrapper"> + <div + class="badge badge-pill gl-display-flex gl-align-items-center gl-justify-content-center" + :class="{ resolved: discussion.resolved }" + type="button" + > + {{ discussion.index }} + </div> + <ul + class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none" + data-qa-selector="design_discussion_content" + > + <design-note + :note="firstNote" + :markdown-preview-path="markdownPreviewPath" + :is-resolving="isResolving" + :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }" + @error="$emit('updateNoteError', $event)" + > + <template v-if="discussion.resolvable" #resolveDiscussion> + <button + v-gl-tooltip + :class="{ 'is-active': discussion.resolved }" + :title="resolveCheckboxText" + :aria-label="resolveCheckboxText" + type="button" + class="line-resolve-btn note-action-button gl-mr-3" + data-testid="resolve-button" + @click.stop="toggleResolvedStatus" + > + <gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" /> + <gl-loading-icon v-else inline /> + </button> + </template> + <template v-if="discussion.resolved" #resolvedStatus> + <p class="gl-text-gray-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> diff --git a/app/assets/javascripts/design_management_legacy/components/design_notes/design_note.vue b/app/assets/javascripts/design_management_legacy/components/design_notes/design_note.vue new file mode 100644 index 00000000000..b1f3a43a66d --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/design_notes/design_note.vue @@ -0,0 +1,156 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import DesignReplyForm from './design_reply_form.vue'; +import { findNoteId } from '../../utils/design_management_utils'; +import { hasErrors } from '../../utils/cache_update'; + +export default { + components: { + UserAvatarLink, + TimelineEntryItem, + TimeAgoTooltip, + DesignReplyForm, + ApolloMutation, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + note: { + type: Object, + required: true, + }, + markdownPreviewPath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + noteText: this.note.body, + isEditing: false, + }; + }, + computed: { + author() { + return this.note.author; + }, + noteAnchorId() { + return findNoteId(this.note.id); + }, + isNoteLinked() { + return this.$route.hash === `#note_${this.noteAnchorId}`; + }, + mutationPayload() { + return { + id: this.note.id, + body: this.noteText, + }; + }, + isEditButtonVisible() { + return !this.isEditing && this.note.userPermissions.adminNote; + }, + }, + mounted() { + if (this.isNoteLinked) { + this.$refs.anchor.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' }); + } + }, + methods: { + hideForm() { + this.isEditing = false; + this.noteText = this.note.body; + }, + onDone({ data }) { + this.hideForm(); + if (hasErrors(data.updateNote)) { + this.$emit('error', data.errors[0]); + } + }, + }, + updateNoteMutation, +}; +</script> + +<template> + <timeline-entry-item :id="`note_${noteAnchorId}`" ref="anchor" class="design-note note-form"> + <user-avatar-link + :link-href="author.webUrl" + :img-src="author.avatarUrl" + :img-alt="author.username" + :img-size="40" + /> + <div class="d-flex justify-content-between"> + <div> + <a + v-once + :href="author.webUrl" + class="js-user-link" + :data-user-id="author.id" + :data-username="author.username" + > + <span class="note-header-author-name bold">{{ author.name }}</span> + <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> + <span class="note-headline-light">@{{ author.username }}</span> + </a> + <span class="note-headline-light note-headline-meta"> + <span class="system-note-message"> <slot></slot> </span> + <template v-if="note.createdAt"> + <span class="system-note-separator"></span> + <a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`"> + <time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" /> + </a> + </template> + </span> + </div> + <div class="gl-display-flex"> + <slot name="resolveDiscussion"></slot> + <button + v-if="isEditButtonVisible" + v-gl-tooltip + type="button" + :title="__('Edit comment')" + class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button" + @click="isEditing = true" + > + <gl-icon name="pencil" class="link-highlight" /> + </button> + </div> + </div> + <template v-if="!isEditing"> + <div + class="note-text js-note-text md" + data-qa-selector="note_content" + v-html="note.bodyHtml" + ></div> + <slot name="resolvedStatus"></slot> + </template> + <apollo-mutation + v-else + #default="{ mutate, loading }" + :mutation="$options.updateNoteMutation" + :variables="{ + input: mutationPayload, + }" + @error="$emit('error', $event)" + @done="onDone" + > + <design-reply-form + v-model="noteText" + :is-saving="loading" + :markdown-preview-path="markdownPreviewPath" + :is-new-comment="false" + class="mt-5" + @submitForm="mutate" + @cancelForm="hideForm" + /> + </apollo-mutation> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management_legacy/components/design_notes/design_reply_form.vue new file mode 100644 index 00000000000..969034909f2 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/design_notes/design_reply_form.vue @@ -0,0 +1,141 @@ +<script> +import { GlDeprecatedButton, GlModal } from '@gitlab/ui'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { s__ } from '~/locale'; + +export default { + name: 'DesignReplyForm', + components: { + MarkdownField, + GlDeprecatedButton, + GlModal, + }, + props: { + markdownPreviewPath: { + type: String, + required: false, + default: '', + }, + value: { + type: String, + required: true, + }, + isSaving: { + type: Boolean, + required: true, + }, + isNewComment: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + formText: this.value, + }; + }, + computed: { + hasValue() { + return this.value.trim().length > 0; + }, + modalSettings() { + if (this.isNewComment) { + return { + title: s__('DesignManagement|Cancel comment confirmation'), + okTitle: s__('DesignManagement|Discard comment'), + cancelTitle: s__('DesignManagement|Keep comment'), + content: s__('DesignManagement|Are you sure you want to cancel creating this comment?'), + }; + } + return { + title: s__('DesignManagement|Cancel comment update confirmation'), + okTitle: s__('DesignManagement|Cancel changes'), + cancelTitle: s__('DesignManagement|Keep changes'), + content: s__('DesignManagement|Are you sure you want to cancel changes to this comment?'), + }; + }, + buttonText() { + return this.isNewComment + ? s__('DesignManagement|Comment') + : s__('DesignManagement|Save comment'); + }, + }, + mounted() { + this.focusInput(); + }, + methods: { + submitForm() { + if (this.hasValue) this.$emit('submitForm'); + }, + cancelComment() { + if (this.hasValue && this.formText !== this.value) { + this.$refs.cancelCommentModal.show(); + } else { + this.$emit('cancelForm'); + } + }, + focusInput() { + this.$refs.textarea.focus(); + }, + }, +}; +</script> + +<template> + <form class="new-note common-note-form" @submit.prevent> + <markdown-field + :markdown-preview-path="markdownPreviewPath" + :can-attach-file="false" + :enable-autocomplete="true" + :textarea-value="value" + markdown-docs-path="/help/user/markdown" + class="bordered-box" + > + <template #textarea> + <textarea + ref="textarea" + :value="value" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + data-supports-quick-actions="false" + data-qa-selector="note_textarea" + :aria-label="__('Description')" + :placeholder="__('Write a comment…')" + @input="$emit('input', $event.target.value)" + @keydown.meta.enter="submitForm" + @keydown.ctrl.enter="submitForm" + @keyup.esc.stop="cancelComment" + > + </textarea> + </template> + </markdown-field> + <slot name="resolveCheckbox"></slot> + <div class="note-form-actions gl-display-flex gl-justify-content-space-between"> + <gl-deprecated-button + ref="submitButton" + :disabled="!hasValue || isSaving" + variant="success" + type="submit" + data-track-event="click_button" + data-qa-selector="save_comment_button" + @click="$emit('submitForm')" + > + {{ buttonText }} + </gl-deprecated-button> + <gl-deprecated-button ref="cancelButton" @click="cancelComment">{{ + __('Cancel') + }}</gl-deprecated-button> + </div> + <gl-modal + ref="cancelCommentModal" + ok-variant="danger" + :title="modalSettings.title" + :ok-title="modalSettings.okTitle" + :cancel-title="modalSettings.cancelTitle" + modal-id="cancel-comment-modal" + @ok="$emit('cancelForm')" + >{{ modalSettings.content }} + </gl-modal> + </form> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/design_notes/toggle_replies_widget.vue b/app/assets/javascripts/design_management_legacy/components/design_notes/toggle_replies_widget.vue new file mode 100644 index 00000000000..2e366282de3 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/design_notes/toggle_replies_widget.vue @@ -0,0 +1,70 @@ +<script> +import { GlIcon, GlButton, GlLink } from '@gitlab/ui'; +import { __, n__ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'ToggleNotesWidget', + components: { + GlIcon, + GlButton, + GlLink, + TimeAgoTooltip, + }, + props: { + collapsed: { + type: Boolean, + required: true, + }, + replies: { + type: Array, + required: true, + }, + }, + computed: { + lastReply() { + return this.replies[this.replies.length - 1]; + }, + iconName() { + return this.collapsed ? 'chevron-right' : 'chevron-down'; + }, + toggleText() { + return this.collapsed + ? `${this.replies.length} ${n__('reply', 'replies', this.replies.length)}` + : __('Collapse replies'); + }, + }, +}; +</script> + +<template> + <li + class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3" + :class="{ expanded: !collapsed }" + data-testid="toggle-comments-wrapper" + > + <gl-icon :name="iconName" class="gl-ml-3" @click.stop="$emit('toggle')" /> + <gl-button + variant="link" + class="toggle-comments-button gl-ml-2 gl-mr-2" + @click.stop="$emit('toggle')" + > + {{ toggleText }} + </gl-button> + <template v-if="collapsed"> + <span class="gl-text-gray-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> diff --git a/app/assets/javascripts/design_management_legacy/components/design_overlay.vue b/app/assets/javascripts/design_management_legacy/components/design_overlay.vue new file mode 100644 index 00000000000..926e7c74802 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/design_overlay.vue @@ -0,0 +1,287 @@ +<script> +import activeDiscussionQuery from '../graphql/queries/active_discussion.query.graphql'; +import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; +import DesignNotePin from './design_note_pin.vue'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; + +export default { + name: 'DesignOverlay', + components: { + DesignNotePin, + }, + props: { + dimensions: { + type: Object, + required: true, + }, + position: { + type: Object, + required: true, + }, + notes: { + type: Array, + required: false, + default: () => [], + }, + currentCommentForm: { + type: Object, + required: false, + default: null, + }, + disableCommenting: { + type: Boolean, + required: false, + default: false, + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + }, + apollo: { + activeDiscussion: { + query: activeDiscussionQuery, + }, + }, + data() { + return { + movingNoteNewPosition: null, + movingNoteStartPosition: null, + activeDiscussion: {}, + }; + }, + computed: { + overlayStyle() { + const cursor = this.disableCommenting ? 'unset' : undefined; + + return { + cursor, + width: `${this.dimensions.width}px`, + height: `${this.dimensions.height}px`, + ...this.position, + }; + }, + isMovingCurrentComment() { + return Boolean(this.movingNoteStartPosition && !this.movingNoteStartPosition.noteId); + }, + currentCommentPositionStyle() { + return this.isMovingCurrentComment && this.movingNoteNewPosition + ? this.getNotePositionStyle(this.movingNoteNewPosition) + : this.getNotePositionStyle(this.currentCommentForm); + }, + }, + methods: { + setNewNoteCoordinates({ x, y }) { + this.$emit('openCommentForm', { x, y }); + }, + getNoteRelativePosition(position) { + const { x, y, width, height } = position; + const widthRatio = this.dimensions.width / width; + const heightRatio = this.dimensions.height / height; + return { + left: Math.round(x * widthRatio), + top: Math.round(y * heightRatio), + }; + }, + getNotePositionStyle(position) { + const { left, top } = this.getNoteRelativePosition(position); + return { + left: `${left}px`, + top: `${top}px`, + }; + }, + getMovingNotePositionDelta(e) { + let deltaX = 0; + let deltaY = 0; + + if (this.movingNoteStartPosition) { + const { clientX, clientY } = this.movingNoteStartPosition; + deltaX = e.clientX - clientX; + deltaY = e.clientY - clientY; + } + + return { + deltaX, + deltaY, + }; + }, + isMovingNote(noteId) { + const movingNoteId = this.movingNoteStartPosition?.noteId; + return Boolean(movingNoteId && movingNoteId === noteId); + }, + canMoveNote(note) { + const { userPermissions } = note; + const { adminNote } = userPermissions || {}; + + return Boolean(adminNote); + }, + isPositionInOverlay(position) { + const { top, left } = this.getNoteRelativePosition(position); + const { height, width } = this.dimensions; + + return top >= 0 && top <= height && left >= 0 && left <= width; + }, + onNewNoteMove(e) { + if (!this.isMovingCurrentComment) return; + + const { deltaX, deltaY } = this.getMovingNotePositionDelta(e); + const x = this.currentCommentForm.x + deltaX; + const y = this.currentCommentForm.y + deltaY; + + const movingNoteNewPosition = { + x, + y, + width: this.dimensions.width, + height: this.dimensions.height, + }; + + if (!this.isPositionInOverlay(movingNoteNewPosition)) { + this.onNewNoteMouseup(); + return; + } + + this.movingNoteNewPosition = movingNoteNewPosition; + }, + onExistingNoteMove(e) { + const note = this.notes.find(({ id }) => id === this.movingNoteStartPosition.noteId); + if (!note || !this.canMoveNote(note)) return; + + const { position } = note; + const { width, height } = position; + const widthRatio = this.dimensions.width / width; + const heightRatio = this.dimensions.height / height; + + const { deltaX, deltaY } = this.getMovingNotePositionDelta(e); + const x = position.x * widthRatio + deltaX; + const y = position.y * heightRatio + deltaY; + + const movingNoteNewPosition = { + x, + y, + width: this.dimensions.width, + height: this.dimensions.height, + }; + + if (!this.isPositionInOverlay(movingNoteNewPosition)) { + this.onExistingNoteMouseup(); + return; + } + + this.movingNoteNewPosition = movingNoteNewPosition; + }, + onNewNoteMouseup() { + if (!this.movingNoteNewPosition) return; + + const { x, y } = this.movingNoteNewPosition; + this.setNewNoteCoordinates({ x, y }); + }, + onExistingNoteMouseup(note) { + if (!this.movingNoteStartPosition || !this.movingNoteNewPosition) { + this.updateActiveDiscussion(note.id); + this.$emit('closeCommentForm'); + return; + } + + const { x, y } = this.movingNoteNewPosition; + this.$emit('moveNote', { + noteId: this.movingNoteStartPosition.noteId, + discussionId: this.movingNoteStartPosition.discussionId, + coordinates: { x, y }, + }); + }, + onNoteMousedown({ clientX, clientY }, note) { + this.movingNoteStartPosition = { + noteId: note?.id, + discussionId: note?.discussion.id, + clientX, + clientY, + }; + }, + onOverlayMousemove(e) { + if (!this.movingNoteStartPosition) return; + + if (this.isMovingCurrentComment) { + this.onNewNoteMove(e); + } else { + this.onExistingNoteMove(e); + } + }, + onNoteMouseup(note) { + if (!this.movingNoteStartPosition) return; + + if (this.isMovingCurrentComment) { + this.onNewNoteMouseup(); + } else { + this.onExistingNoteMouseup(note); + } + + this.movingNoteStartPosition = null; + this.movingNoteNewPosition = null; + }, + onAddCommentMouseup({ offsetX, offsetY }) { + if (this.disableCommenting) return; + if (this.activeDiscussion.id) { + this.updateActiveDiscussion(); + } + + this.setNewNoteCoordinates({ x: offsetX, y: offsetY }); + }, + updateActiveDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin, + }, + }); + }, + isNoteInactive(note) { + return this.activeDiscussion.id && this.activeDiscussion.id !== note.id; + }, + designPinClass(note) { + return { inactive: this.isNoteInactive(note), resolved: note.resolved }; + }, + }, +}; +</script> + +<template> + <div + class="position-absolute image-diff-overlay frame" + :style="overlayStyle" + @mousemove="onOverlayMousemove" + @mouseleave="onNoteMouseup" + > + <button + v-show="!disableCommenting" + type="button" + class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button" + data-qa-selector="design_image_button" + @mouseup="onAddCommentMouseup" + ></button> + <template v-for="note in notes"> + <design-note-pin + v-if="resolvedDiscussionsExpanded || !note.resolved" + :key="note.id" + :label="note.index" + :repositioning="isMovingNote(note.id)" + :position=" + isMovingNote(note.id) && movingNoteNewPosition + ? getNotePositionStyle(movingNoteNewPosition) + : getNotePositionStyle(note.position) + " + :class="designPinClass(note)" + @mousedown.stop="onNoteMousedown($event, note)" + @mouseup.stop="onNoteMouseup(note)" + /> + </template> + + <design-note-pin + v-if="currentCommentForm" + :position="currentCommentPositionStyle" + :repositioning="isMovingCurrentComment" + @mousedown.stop="onNoteMousedown" + @mouseup.stop="onNoteMouseup" + /> + </div> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/design_presentation.vue b/app/assets/javascripts/design_management_legacy/components/design_presentation.vue new file mode 100644 index 00000000000..84dbb2809d9 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/design_presentation.vue @@ -0,0 +1,322 @@ +<script> +import { throttle } from 'lodash'; +import DesignImage from './image.vue'; +import DesignOverlay from './design_overlay.vue'; + +const CLICK_DRAG_BUFFER_PX = 2; + +export default { + components: { + DesignImage, + DesignOverlay, + }, + props: { + image: { + type: String, + required: false, + default: '', + }, + imageName: { + type: String, + required: false, + default: '', + }, + discussions: { + type: Array, + required: true, + }, + isAnnotating: { + type: Boolean, + required: false, + default: false, + }, + scale: { + type: Number, + required: false, + default: 1, + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + }, + data() { + return { + overlayDimensions: null, + overlayPosition: null, + currentAnnotationPosition: null, + zoomFocalPoint: { + x: 0, + y: 0, + width: 0, + height: 0, + }, + initialLoad: true, + lastDragPosition: null, + isDraggingDesign: false, + }; + }, + computed: { + discussionStartingNotes() { + return this.discussions.map(discussion => ({ + ...discussion.notes[0], + index: discussion.index, + })); + }, + currentCommentForm() { + return (this.isAnnotating && this.currentAnnotationPosition) || null; + }, + presentationStyle() { + return { + cursor: this.isDraggingDesign ? 'grabbing' : undefined, + }; + }, + }, + beforeDestroy() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + presentationViewport.removeEventListener('scroll', this.scrollThrottled, false); + }, + mounted() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + this.scrollThrottled = throttle(() => { + this.shiftZoomFocalPoint(); + }, 400); + + presentationViewport.addEventListener('scroll', this.scrollThrottled, false); + }, + methods: { + syncCurrentAnnotationPosition() { + if (!this.currentAnnotationPosition) return; + + const widthRatio = this.overlayDimensions.width / this.currentAnnotationPosition.width; + const heightRatio = this.overlayDimensions.height / this.currentAnnotationPosition.height; + const x = this.currentAnnotationPosition.x * widthRatio; + const y = this.currentAnnotationPosition.y * heightRatio; + + this.currentAnnotationPosition = this.getAnnotationPositon({ x, y }); + }, + setOverlayDimensions(overlayDimensions) { + this.overlayDimensions = overlayDimensions; + + // every time we set overlay dimensions, we need to + // update the current annotation as well + this.syncCurrentAnnotationPosition(); + }, + setOverlayPosition() { + if (!this.overlayDimensions) { + this.overlayPosition = {}; + } + + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + // default to center + this.overlayPosition = { + left: `calc(50% - ${this.overlayDimensions.width / 2}px)`, + top: `calc(50% - ${this.overlayDimensions.height / 2}px)`, + }; + + // if the overlay overflows, then don't center + if (this.overlayDimensions.width > presentationViewport.offsetWidth) { + this.overlayPosition.left = '0'; + } + if (this.overlayDimensions.height > presentationViewport.offsetHeight) { + this.overlayPosition.top = '0'; + } + }, + /** + * Return a point that represents the center of an + * overflowing child element w.r.t it's parent + */ + getViewportCenter() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return {}; + + // get height of scroll bars (i.e. the max values for scrollTop, scrollLeft) + const scrollBarWidth = presentationViewport.scrollWidth - presentationViewport.offsetWidth; + const scrollBarHeight = presentationViewport.scrollHeight - presentationViewport.offsetHeight; + + // determine how many child pixels have been scrolled + const xScrollRatio = + presentationViewport.scrollLeft > 0 ? presentationViewport.scrollLeft / scrollBarWidth : 0; + const yScrollRatio = + presentationViewport.scrollTop > 0 ? presentationViewport.scrollTop / scrollBarHeight : 0; + const xScrollOffset = + (presentationViewport.scrollWidth - presentationViewport.offsetWidth - 0) * xScrollRatio; + const yScrollOffset = + (presentationViewport.scrollHeight - presentationViewport.offsetHeight - 0) * yScrollRatio; + + const viewportCenterX = presentationViewport.offsetWidth / 2; + const viewportCenterY = presentationViewport.offsetHeight / 2; + const focalPointX = viewportCenterX + xScrollOffset; + const focalPointY = viewportCenterY + yScrollOffset; + + return { + x: focalPointX, + y: focalPointY, + }; + }, + /** + * Scroll the viewport such that the focal point is positioned centrally + */ + scrollToFocalPoint() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + const scrollX = this.zoomFocalPoint.x - presentationViewport.offsetWidth / 2; + const scrollY = this.zoomFocalPoint.y - presentationViewport.offsetHeight / 2; + + presentationViewport.scrollTo(scrollX, scrollY); + }, + scaleZoomFocalPoint() { + const { x, y, width, height } = this.zoomFocalPoint; + const widthRatio = this.overlayDimensions.width / width; + const heightRatio = this.overlayDimensions.height / height; + + this.zoomFocalPoint = { + x: Math.round(x * widthRatio * 100) / 100, + y: Math.round(y * heightRatio * 100) / 100, + ...this.overlayDimensions, + }; + }, + shiftZoomFocalPoint() { + this.zoomFocalPoint = { + ...this.getViewportCenter(), + ...this.overlayDimensions, + }; + }, + onImageResize(imageDimensions) { + this.setOverlayDimensions(imageDimensions); + this.setOverlayPosition(); + + this.$nextTick(() => { + if (this.initialLoad) { + // set focal point on initial load + this.shiftZoomFocalPoint(); + this.initialLoad = false; + } else { + this.scaleZoomFocalPoint(); + this.scrollToFocalPoint(); + } + }); + }, + getAnnotationPositon(coordinates) { + const { x, y } = coordinates; + const { width, height } = this.overlayDimensions; + return { + x: Math.round(x), + y: Math.round(y), + width: Math.round(width), + height: Math.round(height), + }; + }, + openCommentForm(coordinates) { + this.currentAnnotationPosition = this.getAnnotationPositon(coordinates); + this.$emit('openCommentForm', this.currentAnnotationPosition); + }, + closeCommentForm() { + this.currentAnnotationPosition = null; + this.$emit('closeCommentForm'); + }, + moveNote({ noteId, discussionId, coordinates }) { + const position = this.getAnnotationPositon(coordinates); + this.$emit('moveNote', { noteId, discussionId, position }); + }, + onPresentationMousedown({ clientX, clientY }) { + if (!this.isDesignOverflowing()) return; + + this.lastDragPosition = { + x: clientX, + y: clientY, + }; + }, + getDragDelta(clientX, clientY) { + return { + deltaX: this.lastDragPosition.x - clientX, + deltaY: this.lastDragPosition.y - clientY, + }; + }, + exceedsDragThreshold(clientX, clientY) { + const { deltaX, deltaY } = this.getDragDelta(clientX, clientY); + + return Math.abs(deltaX) > CLICK_DRAG_BUFFER_PX || Math.abs(deltaY) > CLICK_DRAG_BUFFER_PX; + }, + shouldDragDesign(clientX, clientY) { + return ( + this.lastDragPosition && + (this.isDraggingDesign || this.exceedsDragThreshold(clientX, clientY)) + ); + }, + onPresentationMousemove({ clientX, clientY }) { + const { presentationViewport } = this.$refs; + if (!presentationViewport || !this.shouldDragDesign(clientX, clientY)) return; + + this.isDraggingDesign = true; + + const { scrollLeft, scrollTop } = presentationViewport; + const { deltaX, deltaY } = this.getDragDelta(clientX, clientY); + presentationViewport.scrollTo(scrollLeft + deltaX, scrollTop + deltaY); + + this.lastDragPosition = { + x: clientX, + y: clientY, + }; + }, + onPresentationMouseup() { + this.lastDragPosition = null; + this.isDraggingDesign = false; + }, + isDesignOverflowing() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return false; + + return ( + presentationViewport.scrollWidth > presentationViewport.offsetWidth || + presentationViewport.scrollHeight > presentationViewport.offsetHeight + ); + }, + }, +}; +</script> + +<template> + <div + ref="presentationViewport" + class="h-100 w-100 p-3 overflow-auto position-relative" + :style="presentationStyle" + @mousedown="onPresentationMousedown" + @mousemove="onPresentationMousemove" + @mouseup="onPresentationMouseup" + @mouseleave="onPresentationMouseup" + @touchstart="onPresentationMousedown" + @touchmove="onPresentationMousemove" + @touchend="onPresentationMouseup" + @touchcancel="onPresentationMouseup" + > + <div class="h-100 w-100 d-flex align-items-center position-relative"> + <design-image + v-if="image" + :image="image" + :name="imageName" + :scale="scale" + @resize="onImageResize" + /> + <design-overlay + v-if="overlayDimensions && overlayPosition" + :dimensions="overlayDimensions" + :position="overlayPosition" + :notes="discussionStartingNotes" + :current-comment-form="currentCommentForm" + :disable-commenting="isDraggingDesign" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + @openCommentForm="openCommentForm" + @closeCommentForm="closeCommentForm" + @moveNote="moveNote" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/design_scaler.vue b/app/assets/javascripts/design_management_legacy/components/design_scaler.vue new file mode 100644 index 00000000000..55dee74bef5 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/design_scaler.vue @@ -0,0 +1,65 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +const SCALE_STEP_SIZE = 0.2; +const DEFAULT_SCALE = 1; +const MIN_SCALE = 1; +const MAX_SCALE = 2; + +export default { + components: { + GlIcon, + }, + data() { + return { + scale: DEFAULT_SCALE, + }; + }, + computed: { + disableReset() { + return this.scale <= MIN_SCALE; + }, + disableDecrease() { + return this.scale === DEFAULT_SCALE; + }, + disableIncrease() { + return this.scale >= MAX_SCALE; + }, + }, + methods: { + setScale(scale) { + if (scale < MIN_SCALE) { + return; + } + + this.scale = Math.round(scale * 100) / 100; + this.$emit('scale', this.scale); + }, + incrementScale() { + this.setScale(this.scale + SCALE_STEP_SIZE); + }, + decrementScale() { + this.setScale(this.scale - SCALE_STEP_SIZE); + }, + resetScale() { + this.setScale(DEFAULT_SCALE); + }, + }, +}; +</script> + +<template> + <div class="design-scaler btn-group" role="group"> + <button class="btn" :disabled="disableDecrease" @click="decrementScale"> + <span class="d-flex-center gl-icon s16"> + – + </span> + </button> + <button class="btn" :disabled="disableReset" @click="resetScale"> + <gl-icon name="redo" /> + </button> + <button class="btn" :disabled="disableIncrease" @click="incrementScale"> + <gl-icon name="plus" /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/design_sidebar.vue b/app/assets/javascripts/design_management_legacy/components/design_sidebar.vue new file mode 100644 index 00000000000..622120e2008 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/design_sidebar.vue @@ -0,0 +1,178 @@ +<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> diff --git a/app/assets/javascripts/design_management_legacy/components/image.vue b/app/assets/javascripts/design_management_legacy/components/image.vue new file mode 100644 index 00000000000..91b7b576e0c --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/image.vue @@ -0,0 +1,110 @@ +<script> +import { throttle } from 'lodash'; +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + props: { + image: { + type: String, + required: false, + default: '', + }, + name: { + type: String, + required: false, + default: '', + }, + scale: { + type: Number, + required: false, + default: 1, + }, + }, + data() { + return { + baseImageSize: null, + imageStyle: null, + imageError: false, + }; + }, + watch: { + scale(val) { + this.zoom(val); + }, + }, + beforeDestroy() { + window.removeEventListener('resize', this.resizeThrottled, false); + }, + mounted() { + this.onImgLoad(); + + this.resizeThrottled = throttle(() => { + // NOTE: if imageStyle is set, then baseImageSize + // won't change due to resize. We must still emit a + // `resize` event so that the parent can handle + // resizes appropriately (e.g. for design_overlay) + this.setBaseImageSize(); + }, 400); + window.addEventListener('resize', this.resizeThrottled, false); + }, + methods: { + onImgLoad() { + requestIdleCallback(this.setBaseImageSize, { timeout: 1000 }); + }, + onImgError() { + this.imageError = true; + }, + setBaseImageSize() { + const { contentImg } = this.$refs; + if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return; + + this.baseImageSize = { + height: contentImg.offsetHeight, + width: contentImg.offsetWidth, + }; + this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height }); + }, + onResize({ width, height }) { + this.$emit('resize', { width, height }); + }, + zoom(amount) { + if (amount === 1) { + this.imageStyle = null; + this.$nextTick(() => { + this.setBaseImageSize(); + }); + return; + } + const width = this.baseImageSize.width * amount; + const height = this.baseImageSize.height * amount; + + this.imageStyle = { + width: `${width}px`, + height: `${height}px`, + }; + + this.onResize({ width, height }); + }, + }, +}; +</script> + +<template> + <div class="m-auto js-design-image"> + <gl-icon v-if="imageError" class="text-secondary-100" name="media-broken" :size="48" /> + <img + v-show="!imageError" + ref="contentImg" + class="mh-100" + :src="image" + :alt="name" + :style="imageStyle" + :class="{ 'img-fluid': !imageStyle }" + @error="onImgError" + @load="onImgLoad" + /> + </div> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/list/item.vue b/app/assets/javascripts/design_management_legacy/components/list/item.vue new file mode 100644 index 00000000000..13c703b8a88 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/list/item.vue @@ -0,0 +1,174 @@ +<script> +import { GlLoadingIcon, GlIcon, GlIntersectionObserver } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; +import { n__, __ } from '~/locale'; +import { DESIGN_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + GlLoadingIcon, + GlIntersectionObserver, + GlIcon, + Icon, + Timeago, + }, + props: { + id: { + type: [Number, String], + required: true, + }, + event: { + type: String, + required: true, + }, + notesCount: { + type: Number, + required: true, + }, + image: { + type: String, + required: true, + }, + filename: { + type: String, + required: true, + }, + updatedAt: { + type: String, + required: false, + default: null, + }, + isUploading: { + type: Boolean, + required: false, + default: true, + }, + imageV432x230: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + imageLoading: true, + imageError: false, + wasInView: false, + }; + }, + computed: { + icon() { + const normalizedEvent = this.event.toLowerCase(); + const icons = { + creation: { + name: 'file-addition-solid', + classes: 'text-success-500', + tooltip: __('Added in this version'), + }, + modification: { + name: 'file-modified-solid', + classes: 'text-primary-500', + tooltip: __('Modified in this version'), + }, + deletion: { + name: 'file-deletion-solid', + classes: 'text-danger-500', + tooltip: __('Deleted in this version'), + }, + }; + + return icons[normalizedEvent] ? icons[normalizedEvent] : {}; + }, + notesLabel() { + return n__('%d comment', '%d comments', this.notesCount); + }, + imageLink() { + return this.wasInView ? this.imageV432x230 || this.image : ''; + }, + showLoadingSpinner() { + return this.imageLoading || this.isUploading; + }, + showImageErrorIcon() { + return this.wasInView && this.imageError; + }, + showImage() { + return !this.showLoadingSpinner && !this.showImageErrorIcon; + }, + }, + methods: { + onImageLoad() { + this.imageLoading = false; + this.imageError = false; + }, + onImageError() { + this.imageLoading = false; + this.imageError = true; + }, + onAppear() { + // do nothing if image has previously + // been in view + if (this.wasInView) { + return; + } + + this.wasInView = true; + this.imageLoading = true; + }, + }, + DESIGN_ROUTE_NAME, +}; +</script> + +<template> + <router-link + :to="{ + name: $options.DESIGN_ROUTE_NAME, + params: { id: filename }, + query: $route.query, + }" + class="card cursor-pointer text-plain js-design-list-item design-list-item" + > + <div class="card-body p-0 d-flex-center overflow-hidden position-relative"> + <div v-if="icon.name" data-testid="designEvent" class="design-event position-absolute"> + <span :title="icon.tooltip" :aria-label="icon.tooltip"> + <icon :name="icon.name" :size="18" :class="icon.classes" /> + </span> + </div> + <gl-intersection-observer @appear="onAppear"> + <gl-loading-icon v-if="showLoadingSpinner" size="md" /> + <gl-icon + v-else-if="showImageErrorIcon" + name="media-broken" + class="text-secondary" + :size="32" + /> + <img + v-show="showImage" + :src="imageLink" + :alt="filename" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + @load="onImageLoad" + @error="onImageError" + /> + </gl-intersection-observer> + </div> + <div class="card-footer d-flex w-100"> + <div class="d-flex flex-column str-truncated-100"> + <span class="bold str-truncated-100" data-qa-selector="design_file_name">{{ + filename + }}</span> + <span v-if="updatedAt" class="str-truncated-100"> + {{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" /> + </span> + </div> + <div v-if="notesCount" class="ml-auto d-flex align-items-center text-secondary"> + <icon name="comments" class="ml-1" /> + <span :aria-label="notesLabel" class="ml-1"> + {{ notesCount }} + </span> + </div> + </div> + </router-link> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/toolbar/index.vue b/app/assets/javascripts/design_management_legacy/components/toolbar/index.vue new file mode 100644 index 00000000000..b998dfc47b8 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/toolbar/index.vue @@ -0,0 +1,126 @@ +<script> +import { GlDeprecatedButton } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import Pagination from './pagination.vue'; +import DeleteButton from '../delete_button.vue'; +import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql'; +import appDataQuery from '../../graphql/queries/app_data.query.graphql'; +import { DESIGNS_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + Icon, + Pagination, + DeleteButton, + GlDeprecatedButton, + }, + mixins: [timeagoMixin], + props: { + id: { + type: String, + required: true, + }, + isDeleting: { + type: Boolean, + required: true, + }, + filename: { + type: String, + required: false, + default: '', + }, + updatedAt: { + type: String, + required: false, + default: null, + }, + updatedBy: { + type: Object, + required: false, + default: () => ({}), + }, + isLatestVersion: { + type: Boolean, + required: true, + }, + image: { + type: String, + required: true, + }, + }, + data() { + return { + permissions: { + createDesign: false, + }, + projectPath: '', + issueIid: null, + }; + }, + apollo: { + appData: { + query: appDataQuery, + manual: true, + result({ data: { projectPath, issueIid } }) { + this.projectPath = projectPath; + this.issueIid = issueIid; + }, + }, + permissions: { + query: permissionsQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + }; + }, + update: data => data.project.issue.userPermissions, + }, + }, + computed: { + updatedText() { + return sprintf(__('Updated %{updated_at} by %{updated_by}'), { + updated_at: this.timeFormatted(this.updatedAt), + updated_by: this.updatedBy.name, + }); + }, + canDeleteDesign() { + return this.permissions.createDesign; + }, + }, + DESIGNS_ROUTE_NAME, +}; +</script> + +<template> + <header class="d-flex p-2 bg-white align-items-center js-design-header"> + <router-link + :to="{ + name: $options.DESIGNS_ROUTE_NAME, + query: $route.query, + }" + :aria-label="s__('DesignManagement|Go back to designs')" + class="mr-3 text-plain d-flex justify-content-center align-items-center" + > + <icon :size="18" name="close" /> + </router-link> + <div class="overflow-hidden d-flex align-items-center"> + <h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2> + <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small> + </div> + <pagination :id="id" class="ml-auto flex-shrink-0" /> + <gl-deprecated-button :href="image" class="mr-2"> + <icon :size="18" name="download" /> + </gl-deprecated-button> + <delete-button + v-if="isLatestVersion && canDeleteDesign" + :is-deleting="isDeleting" + button-variant="danger" + @deleteSelectedDesigns="$emit('delete')" + > + <icon :size="18" name="remove" /> + </delete-button> + </header> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/toolbar/pagination.vue b/app/assets/javascripts/design_management_legacy/components/toolbar/pagination.vue new file mode 100644 index 00000000000..bf62a8f66a6 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/toolbar/pagination.vue @@ -0,0 +1,83 @@ +<script> +/* global Mousetrap */ +import 'mousetrap'; +import { s__, sprintf } from '~/locale'; +import PaginationButton from './pagination_button.vue'; +import allDesignsMixin from '../../mixins/all_designs'; +import { DESIGN_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + PaginationButton, + }, + mixins: [allDesignsMixin], + props: { + id: { + type: String, + required: true, + }, + }, + computed: { + designsCount() { + return this.designs.length; + }, + currentIndex() { + return this.designs.findIndex(design => design.filename === this.id); + }, + paginationText() { + return sprintf(s__('DesignManagement|%{current_design} of %{designs_count}'), { + current_design: this.currentIndex + 1, + designs_count: this.designsCount, + }); + }, + previousDesign() { + if (!this.designsCount) return null; + + return this.designs[this.currentIndex - 1]; + }, + nextDesign() { + if (!this.designsCount) return null; + + return this.designs[this.currentIndex + 1]; + }, + }, + mounted() { + Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign)); + Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign)); + }, + beforeDestroy() { + Mousetrap.unbind(['left', 'right'], this.navigateToDesign); + }, + methods: { + navigateToDesign(design) { + if (design) { + this.$router.push({ + name: DESIGN_ROUTE_NAME, + params: { id: design.filename }, + query: this.$route.query, + }); + } + }, + }, +}; +</script> + +<template> + <div v-if="designsCount" class="d-flex align-items-center"> + {{ paginationText }} + <div class="btn-group ml-3 mr-3"> + <pagination-button + :design="previousDesign" + :title="s__('DesignManagement|Go to previous design')" + icon-name="angle-left" + class="js-previous-design" + /> + <pagination-button + :design="nextDesign" + :title="s__('DesignManagement|Go to next design')" + icon-name="angle-right" + class="js-next-design" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/toolbar/pagination_button.vue b/app/assets/javascripts/design_management_legacy/components/toolbar/pagination_button.vue new file mode 100644 index 00000000000..f00ecefca01 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/toolbar/pagination_button.vue @@ -0,0 +1,48 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { DESIGN_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + Icon, + }, + props: { + design: { + type: Object, + required: false, + default: null, + }, + title: { + type: String, + required: true, + }, + iconName: { + type: String, + required: true, + }, + }, + computed: { + designLink() { + if (!this.design) return {}; + + return { + name: DESIGN_ROUTE_NAME, + params: { id: this.design.filename }, + query: this.$route.query, + }; + }, + }, +}; +</script> + +<template> + <router-link + :to="designLink" + :disabled="!design" + :class="{ disabled: !design }" + :aria-label="title" + class="btn btn-default" + > + <icon :name="iconName" /> + </router-link> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/upload/button.vue b/app/assets/javascripts/design_management_legacy/components/upload/button.vue new file mode 100644 index 00000000000..68555104a3c --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/upload/button.vue @@ -0,0 +1,58 @@ +<script> +import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; + +export default { + components: { + GlDeprecatedButton, + GlLoadingIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + isSaving: { + type: Boolean, + required: true, + }, + }, + methods: { + openFileUpload() { + this.$refs.fileUpload.click(); + }, + onFileUploadChange(e) { + this.$emit('upload', e.target.files); + }, + }, + VALID_DESIGN_FILE_MIMETYPE, +}; +</script> + +<template> + <div> + <gl-deprecated-button + v-gl-tooltip.hover + :title=" + s__( + 'DesignManagement|Adding a design with the same filename replaces the file in a new version.', + ) + " + :disabled="isSaving" + variant="success" + @click="openFileUpload" + > + {{ s__('DesignManagement|Upload designs') }} + <gl-loading-icon v-if="isSaving" inline class="ml-1" /> + </gl-deprecated-button> + + <input + ref="fileUpload" + type="file" + name="design_file" + :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype" + class="hide" + multiple + @change="onFileUploadChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/design_management_legacy/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management_legacy/components/upload/design_dropzone.vue new file mode 100644 index 00000000000..e435c84c959 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/upload/design_dropzone.vue @@ -0,0 +1,134 @@ +<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> diff --git a/app/assets/javascripts/design_management_legacy/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management_legacy/components/upload/design_version_dropdown.vue new file mode 100644 index 00000000000..879d2523848 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/components/upload/design_version_dropdown.vue @@ -0,0 +1,76 @@ +<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> |