diff options
Diffstat (limited to 'app/assets/javascripts/design_management')
22 files changed, 648 insertions, 288 deletions
diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue index 37686dd5a46..970197ef41b 100644 --- a/app/assets/javascripts/design_management/components/delete_button.vue +++ b/app/assets/javascripts/design_management/components/delete_button.vue @@ -98,6 +98,7 @@ export default { :loading="loading" :icon="buttonIcon" :disabled="isDeleting || !hasSelectedDesigns" - /> + ><slot></slot + ></gl-button> </div> </template> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 6a20517eed7..845f1aec8cf 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -1,19 +1,20 @@ <script> import { ApolloMutation } from 'vue-apollo'; -import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink, GlBadge } from '@gitlab/ui'; import { s__ } from '~/locale'; +import createFlash from '~/flash'; 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'; +import { hasErrors } from '../../utils/cache_update'; +import { ADD_DISCUSSION_COMMENT_ERROR } from '../../utils/error_messages'; export default { components: { @@ -26,6 +27,7 @@ export default { GlLink, ToggleRepliesWidget, TimeAgoTooltip, + GlBadge, }, directives: { GlTooltip: GlTooltipDirective, @@ -62,22 +64,20 @@ export default { 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', - }); - } + + this.$nextTick(() => { + // 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 (this.$el && this.shouldScrollToDiscussion(data.activeDiscussion)) { + this.$el.scrollIntoView({ + behavior: 'smooth', + inline: 'start', + }); + } + }); }, }, }, @@ -107,8 +107,8 @@ export default { atVersion: this.designsVersion, }; }, - isDiscussionHighlighted() { - return this.discussion.notes[0].id === this.activeDiscussion.id; + isDiscussionActive() { + return this.discussion.notes.some(({ id }) => id === this.activeDiscussion.id); }, resolveCheckboxText() { return this.discussion.resolved @@ -138,21 +138,10 @@ export default { }, }, methods: { - addDiscussionComment( - store, - { - data: { createNote }, - }, - ) { - updateStoreAfterAddDiscussionComment( - store, - createNote, - getDesignQuery, - this.designVariables, - this.discussion.id, - ); - }, - onDone() { + onDone({ data: { createNote } }) { + if (hasErrors(createNote)) { + createFlash({ message: ADD_DISCUSSION_COMMENT_ERROR }); + } this.discussionComment = ''; this.hideForm(); if (this.shouldChangeResolvedStatus) { @@ -160,14 +149,14 @@ export default { } }, onCreateNoteError(err) { - this.$emit('createNoteError', err); + this.$emit('create-note-error', err); }, hideForm() { this.isFormRendered = false; this.discussionComment = ''; }, showForm() { - this.$emit('openForm', this.discussion.id); + this.$emit('open-form', this.discussion.id); this.isFormRendered = true; }, toggleResolvedStatus() { @@ -179,16 +168,24 @@ export default { }) .then(({ data }) => { if (data.errors?.length > 0) { - this.$emit('resolveDiscussionError', data.errors[0]); + this.$emit('resolve-discussion-error', data.errors[0]); } }) .catch(err => { - this.$emit('resolveDiscussionError', err); + this.$emit('resolve-discussion-error', err); }) .finally(() => { this.isResolving = false; }); }, + shouldScrollToDiscussion(activeDiscussion) { + const ALLOWED_ACTIVE_DISCUSSION_SOURCES = [ + ACTIVE_DISCUSSION_SOURCE_TYPES.pin, + ACTIVE_DISCUSSION_SOURCE_TYPES.url, + ]; + const { source } = activeDiscussion; + return ALLOWED_ACTIVE_DISCUSSION_SOURCES.includes(source) && this.isDiscussionActive; + }, }, createNoteMutation, }; @@ -196,13 +193,12 @@ export default { <template> <div class="design-discussion-wrapper"> - <div - class="badge badge-pill gl-display-flex gl-align-items-center gl-justify-content-center" + <gl-badge + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-cursor-pointer" :class="{ resolved: discussion.resolved }" - type="button" > {{ discussion.index }} - </div> + </gl-badge> <ul class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none" data-qa-selector="design_discussion_content" @@ -211,8 +207,8 @@ export default { :note="firstNote" :markdown-preview-path="markdownPreviewPath" :is-resolving="isResolving" - :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }" - @error="$emit('updateNoteError', $event)" + :class="{ 'gl-bg-blue-50': isDiscussionActive }" + @error="$emit('update-note-error', $event)" > <template v-if="discussion.resolvable" #resolveDiscussion> <button @@ -220,7 +216,6 @@ export default { :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" @@ -255,8 +250,8 @@ export default { :note="note" :markdown-preview-path="markdownPreviewPath" :is-resolving="isResolving" - :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }" - @error="$emit('updateNoteError', $event)" + :class="{ 'gl-bg-blue-50': isDiscussionActive }" + @error="$emit('update-note-error', $event)" /> <li v-show="isReplyPlaceholderVisible" class="reply-wrapper"> <reply-placeholder @@ -272,7 +267,6 @@ export default { :variables="{ input: mutationPayload, }" - :update="addDiscussionComment" @done="onDone" @error="onCreateNoteError" > @@ -280,8 +274,8 @@ export default { v-model="discussionComment" :is-saving="loading" :markdown-preview-path="markdownPreviewPath" - @submitForm="mutate" - @cancelForm="hideForm" + @submit-form="mutate" + @cancel-form="hideForm" > <template v-if="discussion.resolvable" #resolveCheckbox> <label data-testid="resolve-checkbox"> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index 172e61920ef..7f4b3b31024 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -1,12 +1,12 @@ <script> import { ApolloMutation } from 'vue-apollo'; -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlLink, GlSafeHtmlDirective } 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 { findNoteId, extractDesignNoteId } from '../../utils/design_management_utils'; import { hasErrors } from '../../utils/cache_update'; export default { @@ -17,9 +17,11 @@ export default { DesignReplyForm, ApolloMutation, GlIcon, + GlLink, }, directives: { GlTooltip: GlTooltipDirective, + SafeHtml: GlSafeHtmlDirective, }, props: { note: { @@ -46,7 +48,7 @@ export default { return findNoteId(this.note.id); }, isNoteLinked() { - return this.$route.hash === `#note_${this.noteAnchorId}`; + return extractDesignNoteId(this.$route.hash) === this.noteAnchorId; }, mutationPayload() { return { @@ -58,11 +60,6 @@ export default { return !this.isEditing && this.note.userPermissions.adminNote; }, }, - mounted() { - if (this.isNoteLinked) { - this.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' }); - } - }, methods: { hideForm() { this.isEditing = false; @@ -87,30 +84,30 @@ export default { :img-alt="author.username" :img-size="40" /> - <div class="d-flex justify-content-between"> + <div class="gl-display-flex gl-justify-content-space-between"> <div> - <a + <gl-link 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-header-author-name gl-font-weight-bold">{{ author.name }}</span> + <span v-if="author.status_tooltip_html" v-safe-html="author.status_tooltip_html"></span> <span class="note-headline-light">@{{ author.username }}</span> - </a> + </gl-link> <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> + <gl-link + class="note-timestamp system-note-separator gl-display-block gl-mb-2" + :href="`#note_${noteAnchorId}`" + > + <time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" /> + </gl-link> </span> </div> - <div class="gl-display-flex"> + <div class="gl-display-flex gl-align-items-baseline"> <slot name="resolveDiscussion"></slot> <button v-if="isEditButtonVisible" @@ -126,9 +123,9 @@ export default { </div> <template v-if="!isEditing"> <div + v-safe-html="note.bodyHtml" class="note-text js-note-text md" data-qa-selector="note_content" - v-html="note.bodyHtml" ></div> <slot name="resolvedStatus"></slot> </template> @@ -147,9 +144,9 @@ export default { :is-saving="loading" :markdown-preview-path="markdownPreviewPath" :is-new-comment="false" - class="mt-5" - @submitForm="mutate" - @cancelForm="hideForm" + class="gl-mt-5" + @submit-form="mutate" + @cancel-form="hideForm" /> </apollo-mutation> </timeline-entry-item> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue index 969034909f2..3754e1dbbc1 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -1,5 +1,5 @@ <script> -import { GlDeprecatedButton, GlModal } from '@gitlab/ui'; +import { GlButton, GlModal } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { s__ } from '~/locale'; @@ -7,7 +7,7 @@ export default { name: 'DesignReplyForm', components: { MarkdownField, - GlDeprecatedButton, + GlButton, GlModal, }, props: { @@ -66,13 +66,13 @@ export default { }, methods: { submitForm() { - if (this.hasValue) this.$emit('submitForm'); + if (this.hasValue) this.$emit('submit-form'); }, cancelComment() { if (this.hasValue && this.formText !== this.value) { this.$refs.cancelCommentModal.show(); } else { - this.$emit('cancelForm'); + this.$emit('cancel-form'); } }, focusInput() { @@ -112,20 +112,21 @@ export default { </markdown-field> <slot name="resolveCheckbox"></slot> <div class="note-form-actions gl-display-flex gl-justify-content-space-between"> - <gl-deprecated-button + <gl-button ref="submitButton" :disabled="!hasValue || isSaving" + category="primary" variant="success" type="submit" data-track-event="click_button" data-qa-selector="save_comment_button" - @click="$emit('submitForm')" + @click="$emit('submit-form')" > {{ buttonText }} - </gl-deprecated-button> - <gl-deprecated-button ref="cancelButton" @click="cancelComment">{{ + </gl-button> + <gl-button ref="cancelButton" variant="default" category="primary" @click="cancelComment">{{ __('Cancel') - }}</gl-deprecated-button> + }}</gl-button> </div> <gl-modal ref="cancelCommentModal" @@ -134,7 +135,7 @@ export default { :ok-title="modalSettings.okTitle" :cancel-title="modalSettings.cancelTitle" modal-id="cancel-comment-modal" - @ok="$emit('cancelForm')" + @ok="$emit('cancel-form')" >{{ modalSettings.content }} </gl-modal> </form> diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index 926e7c74802..5c4a3ab5f94 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; 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'; @@ -236,18 +237,26 @@ export default { }); }, isNoteInactive(note) { - return this.activeDiscussion.id && this.activeDiscussion.id !== note.id; + const discussionNotes = note.discussion.notes.nodes || []; + + return ( + this.activeDiscussion.id && + !discussionNotes.some(({ id }) => id === this.activeDiscussion.id) + ); }, designPinClass(note) { return { inactive: this.isNoteInactive(note), resolved: note.resolved }; }, }, + i18n: { + newCommentButtonLabel: __('Add comment to design'), + }, }; </script> <template> <div - class="position-absolute image-diff-overlay frame" + class="gl-absolute gl-top-0 gl-left-0 frame" :style="overlayStyle" @mousemove="onOverlayMousemove" @mouseleave="onNoteMouseup" @@ -255,26 +264,28 @@ export default { <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" + role="button" + :aria-label="$options.i18n.newCommentButtonLabel" + class="gl-absolute gl-w-full gl-h-full gl-p-0 gl-top-0 gl-left-0 gl-outline-0! btn-transparent design-detail-overlay-add-comment" 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-for="note in notes" + 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)" + /> <design-note-pin v-if="currentCommentForm" diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue index e5a3590877e..df425e3b96d 100644 --- a/app/assets/javascripts/design_management/components/design_sidebar.vue +++ b/app/assets/javascripts/design_management/components/design_sidebar.vue @@ -8,6 +8,8 @@ import { extractDiscussions, extractParticipants } from '../utils/design_managem import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; import DesignDiscussion from './design_notes/design_discussion.vue'; import Participants from '~/sidebar/components/participants/participants.vue'; +import DesignTodoButton from './design_todo_button.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -16,7 +18,9 @@ export default { GlCollapse, GlButton, GlPopover, + DesignTodoButton, }, + mixins: [glFeatureFlagsMixin()], props: { design: { type: Object, @@ -37,6 +41,14 @@ export default { discussionWithOpenForm: '', }; }, + inject: { + projectPath: { + default: '', + }, + issueIid: { + default: '', + }, + }, computed: { discussions() { return extractDiscussions(this.design.discussions); @@ -59,6 +71,26 @@ export default { resolvedCommentsToggleIcon() { return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right'; }, + showTodoButton() { + return this.glFeatures.designManagementTodoButton; + }, + sidebarWrapperClass() { + return { + 'gl-pt-0': this.showTodoButton, + }; + }, + }, + watch: { + isResolvedCommentsPopoverHidden(newVal) { + if (!newVal) { + this.$refs.resolvedComments.scrollIntoView(); + } + }, + }, + mounted() { + if (!this.isResolvedCommentsPopoverHidden && this.$refs.resolvedComments) { + this.$refs.resolvedComments.$el.scrollIntoView(); + } }, methods: { handleSidebarClick() { @@ -89,7 +121,14 @@ export default { </script> <template> - <div class="image-notes" @click="handleSidebarClick"> + <div class="image-notes" :class="sidebarWrapperClass" @click="handleSidebarClick"> + <div + v-if="showTodoButton" + class="gl-py-4 gl-mb-4 gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + > + <span>{{ __('To-Do') }}</span> + <design-todo-button :design="design" @error="$emit('todoError', $event)" /> + </div> <h2 class="gl-font-weight-bold gl-mt-0"> {{ issue.title }} </h2> @@ -120,15 +159,16 @@ export default { :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)" + @create-note-error="$emit('onDesignDiscussionError', $event)" + @update-note-error="$emit('updateNoteError', $event)" + @resolve-discussion-error="$emit('resolveDiscussionError', $event)" @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)" - @openForm="updateDiscussionWithOpenForm" + @open-form="updateDiscussionWithOpenForm" /> <template v-if="resolvedDiscussions.length > 0"> <gl-button id="resolved-comments" + ref="resolvedComments" data-testid="resolved-comments" :icon="resolvedCommentsToggleIcon" variant="link" @@ -151,9 +191,12 @@ export default { ) }} </p> - <a href="#" rel="noopener noreferrer" target="_blank">{{ - s__('DesignManagement|Learn more about resolving comments') - }}</a> + <a + href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads" + 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 diff --git a/app/assets/javascripts/design_management/components/design_todo_button.vue b/app/assets/javascripts/design_management/components/design_todo_button.vue new file mode 100644 index 00000000000..aff4f348d15 --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_todo_button.vue @@ -0,0 +1,168 @@ +<script> +import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql'; +import getDesignQuery from '../graphql/queries/get_design.query.graphql'; +import createDesignTodoMutation from '../graphql/mutations/create_design_todo.mutation.graphql'; +import TodoButton from '~/vue_shared/components/todo_button.vue'; +import allVersionsMixin from '../mixins/all_versions'; +import { updateStoreAfterDeleteDesignTodo } from '../utils/cache_update'; +import { findIssueId, findDesignId } from '../utils/design_management_utils'; +import { CREATE_DESIGN_TODO_ERROR, DELETE_DESIGN_TODO_ERROR } from '../utils/error_messages'; + +export default { + components: { + TodoButton, + }, + mixins: [allVersionsMixin], + props: { + design: { + type: Object, + required: true, + }, + }, + inject: { + projectPath: { + default: '', + }, + issueIid: { + default: '', + }, + }, + data() { + return { + todoLoading: false, + }; + }, + computed: { + designVariables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + filenames: [this.$route.params.id], + atVersion: this.designsVersion, + }; + }, + designTodoVariables() { + return { + projectPath: this.projectPath, + issueId: findIssueId(this.design.issue.id), + designId: findDesignId(this.design.id), + issueIid: this.issueIid, + filenames: [this.$route.params.id], + atVersion: this.designsVersion, + }; + }, + pendingTodo() { + // TODO data structure pending BE MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40555#note_405732940 + return this.design.currentUserTodos?.nodes[0]; + }, + hasPendingTodo() { + return Boolean(this.pendingTodo); + }, + }, + methods: { + updateGlobalTodoCount(additionalTodoCount) { + const currentCount = parseInt(document.querySelector('.js-todos-count').innerText, 10); + const todoToggleEvent = new CustomEvent('todo:toggle', { + detail: { + count: Math.max(currentCount + additionalTodoCount, 0), + }, + }); + + document.dispatchEvent(todoToggleEvent); + }, + incrementGlobalTodoCount() { + this.updateGlobalTodoCount(1); + }, + decrementGlobalTodoCount() { + this.updateGlobalTodoCount(-1); + }, + createTodo() { + this.todoLoading = true; + return this.$apollo + .mutate({ + mutation: createDesignTodoMutation, + variables: this.designTodoVariables, + update: (store, { data: { createDesignTodo } }) => { + // because this is a @client mutation, + // we control what is in errors, and therefore + // we are certain that there is at most 1 item in the array + const createDesignTodoError = (createDesignTodo.errors || [])[0]; + if (createDesignTodoError) { + this.$emit('error', Error(createDesignTodoError.message)); + } + }, + }) + .then(() => { + this.incrementGlobalTodoCount(); + }) + .catch(err => { + this.$emit('error', Error(CREATE_DESIGN_TODO_ERROR)); + throw err; + }) + .finally(() => { + this.todoLoading = false; + }); + }, + deleteTodo() { + if (!this.hasPendingTodo) return Promise.reject(); + + const { id } = this.pendingTodo; + const { designVariables } = this; + + this.todoLoading = true; + return this.$apollo + .mutate({ + mutation: todoMarkDoneMutation, + variables: { + id, + }, + update( + store, + { + data: { todoMarkDone }, + }, + ) { + const todoMarkDoneFirstError = (todoMarkDone.errors || [])[0]; + if (todoMarkDoneFirstError) { + this.$emit('error', Error(todoMarkDoneFirstError)); + } else { + updateStoreAfterDeleteDesignTodo( + store, + todoMarkDone, + getDesignQuery, + designVariables, + ); + } + }, + }) + .then(() => { + this.decrementGlobalTodoCount(); + }) + .catch(err => { + this.$emit('error', Error(DELETE_DESIGN_TODO_ERROR)); + throw err; + }) + .finally(() => { + this.todoLoading = false; + }); + }, + toggleTodo() { + if (this.hasPendingTodo) { + return this.deleteTodo(); + } + + return this.createTodo(); + }, + }, +}; +</script> + +<template> + <todo-button + issuable-type="design" + :issuable-id="design.iid" + :is-todo="hasPendingTodo" + :loading="todoLoading" + @click.stop.prevent="toggleTodo" + /> +</template> diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index 292b6e09055..36ea812d92e 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -1,6 +1,5 @@ <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'; @@ -10,7 +9,6 @@ export default { GlLoadingIcon, GlIntersectionObserver, GlIcon, - Icon, Timeago, }, props: { @@ -127,12 +125,14 @@ export default { params: { id: filename }, query: $route.query, }" - class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" > - <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"> + <div + class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative" + > + <div v-if="icon.name" data-testid="designEvent" class="design-event gl-absolute"> <span :title="icon.tooltip" :aria-label="icon.tooltip"> - <icon :name="icon.name" :size="18" :class="icon.classes" /> + <gl-icon :name="icon.name" :size="18" :class="icon.classes" /> </span> </div> <gl-intersection-observer @appear="onAppear"> @@ -147,25 +147,28 @@ export default { v-show="showImage" :src="imageLink" :alt="filename" - class="block mx-auto mw-100 mh-100 design-img" + class="gl-display-block gl-mx-auto gl-max-w-full 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">{{ + <div class="card-footer gl-display-flex gl-w-full"> + <div class="gl-display-flex gl-flex-direction-column str-truncated-100"> + <span class="gl-font-weight-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"> + <div + v-if="notesCount" + class="gl-ml-auto gl-display-flex gl-align-items-center gl-text-gray-500" + > + <gl-icon name="comments" class="gl-ml-2" /> + <span :aria-label="notesLabel" class="gl-ml-2"> {{ notesCount }} </span> </div> diff --git a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue index a03982cb91b..4a1be7b720a 100644 --- a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue +++ b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue @@ -1,13 +1,13 @@ <script> -import { GlNewDropdown, GlNewDropdownItem, GlSprintf } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import allVersionsMixin from '../../mixins/all_versions'; import { findVersionId } from '../../utils/design_management_utils'; export default { components: { - GlNewDropdown, - GlNewDropdownItem, + GlDropdown, + GlDropdownItem, GlSprintf, }, mixins: [allVersionsMixin], @@ -63,8 +63,8 @@ export default { </script> <template> - <gl-new-dropdown :text="dropdownText" size="small"> - <gl-new-dropdown-item + <gl-dropdown :text="dropdownText" size="small"> + <gl-dropdown-item v-for="(version, index) in allVersions" :key="version.id" :is-check-item="true" @@ -76,6 +76,6 @@ export default { {{ allVersions.length - index }} </template> </gl-sprintf> - </gl-new-dropdown-item> - </gl-new-dropdown> + </gl-dropdown-item> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js index 21ff361a277..63a92ef5ec0 100644 --- a/app/assets/javascripts/design_management/constants.js +++ b/app/assets/javascripts/design_management/constants.js @@ -11,6 +11,7 @@ export const VALID_DATA_TRANSFER_TYPE = 'Files'; export const ACTIVE_DISCUSSION_SOURCE_TYPES = { pin: 'pin', discussion: 'discussion', + url: 'url', }; export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0']; diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js index fae337aa75b..d1fe977b969 100644 --- a/app/assets/javascripts/design_management/graphql.js +++ b/app/assets/javascripts/design_management/graphql.js @@ -1,24 +1,70 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { uniqueId } from 'lodash'; +import produce from 'immer'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import axios from '~/lib/utils/axios_utils'; import createDefaultClient from '~/lib/graphql'; import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql'; +import getDesignQuery from './graphql/queries/get_design.query.graphql'; import typeDefs from './graphql/typedefs.graphql'; +import { extractTodoIdFromDeletePath, createPendingTodo } from './utils/design_management_utils'; +import { CREATE_DESIGN_TODO_EXISTS_ERROR } from './utils/error_messages'; +import { addPendingTodoToStore } from './utils/cache_update'; Vue.use(VueApollo); const resolvers = { Mutation: { updateActiveDiscussion: (_, { id = null, source }, { cache }) => { - const data = cache.readQuery({ query: activeDiscussionQuery }); - data.activeDiscussion = { - __typename: 'ActiveDiscussion', - id, - source, - }; + const sourceData = cache.readQuery({ query: activeDiscussionQuery }); + + const data = produce(sourceData, draftData => { + // eslint-disable-next-line no-param-reassign + draftData.activeDiscussion = { + __typename: 'ActiveDiscussion', + id, + source, + }; + }); + cache.writeQuery({ query: activeDiscussionQuery, data }); }, + createDesignTodo: ( + _, + { projectPath, issueId, designId, issueIid, filenames, atVersion }, + { cache }, + ) => { + return axios + .post(`/${projectPath}/todos`, { + issue_id: issueId, + issuable_id: designId, + issuable_type: 'design', + }) + .then(({ data }) => { + const { delete_path } = data; + const todoId = extractTodoIdFromDeletePath(delete_path); + if (!todoId) { + return { + errors: [ + { + message: CREATE_DESIGN_TODO_EXISTS_ERROR, + }, + ], + }; + } + + const pendingTodo = createPendingTodo(todoId); + addPendingTodoToStore(cache, pendingTodo, getDesignQuery, { + fullPath: projectPath, + iid: issueIid, + filenames, + atVersion, + }); + + return pendingTodo; + }); + }, }, }; @@ -37,6 +83,7 @@ const defaultClient = createDefaultClient( }, }, typeDefs, + assumeImmutableResults: true, }, ); diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql index bc3132f9b42..9bd70e7e886 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql @@ -5,4 +5,9 @@ fragment DesignListItem on Design { notesCount image imageV432x230 + currentUserTodos(state: pending) { + nodes { + id + } + } } diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql index 26edd2c0be1..28224671326 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql @@ -25,5 +25,10 @@ fragment DesignNote on Note { } discussion { id + notes { + nodes { + id + } + } } } diff --git a/app/assets/javascripts/design_management/graphql/mutations/create_design_todo.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/create_design_todo.mutation.graphql new file mode 100644 index 00000000000..0c989b2fdde --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/create_design_todo.mutation.graphql @@ -0,0 +1,17 @@ +mutation createDesignTodo( + $projectPath: String! + $issueId: String! + $designId: String! + $issueIid: String! + $filenames: [String]! + $atVersion: String +) { + createDesignTodo( + projectPath: $projectPath + issueId: $issueId + designId: $designId + issueIid: $issueIid + filenames: $filenames + atVersion: $atVersion + ) @client +} diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql index ab987dda525..96869a404b1 100644 --- a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql @@ -10,6 +10,7 @@ query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [Stri nodes { ...DesignItem issue { + id title webPath webUrl diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js index 20c9cacf83f..1a87dd38137 100644 --- a/app/assets/javascripts/design_management/index.js +++ b/app/assets/javascripts/design_management/index.js @@ -4,7 +4,7 @@ import App from './components/app.vue'; import apolloProvider from './graphql'; export default () => { - const el = document.querySelector('.js-design-management-new'); + const el = document.querySelector('.js-design-management'); const { issueIid, projectPath, issuePath } = el.dataset; const router = createRouter(issuePath); diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 17b72e73127..c6225c516e2 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -19,6 +19,8 @@ import { extractDiscussions, extractDesign, updateImageDiffNoteOptimisticResponse, + toDiffNoteGid, + extractDesignNoteId, } from '../../utils/design_management_utils'; import { updateStoreAfterAddImageDiffNote, @@ -31,6 +33,7 @@ import { DESIGN_NOT_FOUND_ERROR, DESIGN_VERSION_NOT_EXIST_ERROR, UPDATE_NOTE_ERROR, + TOGGLE_TODO_ERROR, designDeletionError, } from '../../utils/error_messages'; import { trackDesignDetailView } from '../../utils/tracking'; @@ -145,8 +148,11 @@ export default { mounted() { Mousetrap.bind('esc', this.closeDesign); this.trackEvent(); - // We need to reset the active discussion when opening a new design - this.updateActiveDiscussion(); + + // Set active discussion immediately. + // This will ensure that, if a note is specified in the URL hash, + // the browser will scroll to, and highlight, the note in the UI + this.updateActiveDiscussionFromUrl(); }, beforeDestroy() { Mousetrap.unbind('esc', this.closeDesign); @@ -221,7 +227,7 @@ export default { }, onError(message, e) { this.errorMessage = message; - throw e; + if (e) throw e; }, onCreateImageDiffNoteError(e) { this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e); @@ -241,6 +247,9 @@ export default { onResolveDiscussionError(e) { this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e); }, + onTodoError(e) { + this.onError(e?.message || TOGGLE_TODO_ERROR, e); + }, openCommentForm(annotationCoordinates) { this.annotationCoordinates = annotationCoordinates; if (this.$refs.newDiscussionForm) { @@ -266,15 +275,20 @@ export default { this.isLatestVersion, ); }, - updateActiveDiscussion(id) { + updateActiveDiscussion(id, source = ACTIVE_DISCUSSION_SOURCE_TYPES.discussion) { this.$apollo.mutate({ mutation: updateActiveDiscussionMutation, variables: { id, - source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion, + source, }, }); }, + updateActiveDiscussionFromUrl() { + const noteId = extractDesignNoteId(this.$route.hash); + const diffNoteGid = noteId ? toDiffNoteGid(noteId) : undefined; + return this.updateActiveDiscussion(diffNoteGid, ACTIVE_DISCUSSION_SOURCE_TYPES.url); + }, toggleResolvedComments() { this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded; }, @@ -339,6 +353,7 @@ export default { @updateNoteError="onUpdateNoteError" @resolveDiscussionError="onResolveDiscussionError" @toggleResolvedComments="toggleResolvedComments" + @todoError="onTodoError" > <template #replyForm> <apollo-mutation @@ -357,8 +372,8 @@ export default { v-model="comment" :is-saving="loading" :markdown-preview-path="markdownPreviewPath" - @submitForm="mutate" - @cancelForm="closeCommentForm" + @submit-form="mutate" + @cancel-form="closeCommentForm" /> </apollo-mutation ></template> </design-sidebar> diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index cd68e9d6c5b..6c4c8c75054 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -281,13 +281,8 @@ export default { .mutate({ mutation: moveDesignMutation, variables: this.designMoveVariables(newIndex, element), - update: (store, { data: { designManagementMove } }) => { - return updateDesignsOnStoreAfterReorder( - store, - designManagementMove, - this.projectQueryBody, - ); - }, + update: (store, { data: { designManagementMove } }) => + updateDesignsOnStoreAfterReorder(store, designManagementMove, this.projectQueryBody), optimisticResponse: moveDesignOptimisticResponse(this.reorderedDesigns), }) .catch(() => { @@ -327,7 +322,7 @@ export default { v-if="isLatestVersion" variant="link" size="small" - class="gl-mr-3 js-select-all" + class="gl-mr-4 js-select-all" @click="toggleDesignsSelection" >{{ selectAllButtonText }} </gl-button> diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js index b79df9d01d5..ff41136fd54 100644 --- a/app/assets/javascripts/design_management/utils/cache_update.js +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -1,22 +1,27 @@ /* eslint-disable @gitlab/require-i18n-strings */ import { groupBy } from 'lodash'; +import produce from 'immer'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import { extractCurrentDiscussion, extractDesign } from './design_management_utils'; +import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils'; import { ADD_IMAGE_DIFF_NOTE_ERROR, UPDATE_IMAGE_DIFF_NOTE_ERROR, - ADD_DISCUSSION_COMMENT_ERROR, + DELETE_DESIGN_TODO_ERROR, designDeletionError, } from './error_messages'; +const designsOf = data => data.project.issue.designCollection.designs; + const deleteDesignsFromStore = (store, query, selectedDesigns) => { - const data = store.readQuery(query); + const sourceData = store.readQuery(query); - const changedDesigns = data.project.issue.designCollection.designs.nodes.filter( - node => !selectedDesigns.includes(node.filename), - ); - data.project.issue.designCollection.designs.nodes = [...changedDesigns]; + const data = produce(sourceData, draftData => { + const changedDesigns = designsOf(sourceData).nodes.filter( + design => !selectedDesigns.includes(design.filename), + ); + designsOf(draftData).nodes = [...changedDesigns]; + }); store.writeQuery({ ...query, @@ -33,13 +38,15 @@ const deleteDesignsFromStore = (store, query, selectedDesigns) => { */ const addNewVersionToStore = (store, query, version) => { if (!version) return; + const sourceData = store.readQuery(query); - const data = store.readQuery(query); - - data.project.issue.designCollection.versions.nodes = [ - version, - ...data.project.issue.designCollection.versions.nodes, - ]; + const data = produce(sourceData, draftData => { + // eslint-disable-next-line no-param-reassign + draftData.project.issue.designCollection.versions.nodes = [ + version, + ...draftData.project.issue.designCollection.versions.nodes, + ]; + }); store.writeQuery({ ...query, @@ -47,47 +54,12 @@ const addNewVersionToStore = (store, query, version) => { }); }; -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.nodes.some( - participant => participant.username === createNote.note.author.username, - ) - ) { - design.issue.participants.nodes = [ - ...design.issue.participants.nodes, - { - __typename: 'User', - ...createNote.note.author, - }, - ]; - } - store.writeQuery({ - query, - variables: queryVariables, - data: { - ...data, - design: { - ...design, - }, - }, - }); -}; - const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) => { - const data = store.readQuery({ + const sourceData = store.readQuery({ query, variables, }); + const newDiscussion = { __typename: 'Discussion', id: createImageDiffNote.note.discussion.id, @@ -101,100 +73,100 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) = nodes: [createImageDiffNote.note], }, }; - const design = extractDesign(data); - const notesCount = design.notesCount + 1; - design.discussions.nodes = [...design.discussions.nodes, newDiscussion]; - if ( - !design.issue.participants.nodes.some( - participant => participant.username === createImageDiffNote.note.author.username, - ) - ) { - design.issue.participants.nodes = [ - ...design.issue.participants.nodes, - { - __typename: 'User', - ...createImageDiffNote.note.author, - }, - ]; - } + + const data = produce(sourceData, draftData => { + const design = extractDesign(draftData); + design.notesCount += 1; + design.discussions.nodes = [...design.discussions.nodes, newDiscussion]; + + if ( + !design.issue.participants.nodes.some( + participant => participant.username === createImageDiffNote.note.author.username, + ) + ) { + design.issue.participants.nodes = [ + ...design.issue.participants.nodes, + { + __typename: 'User', + ...createImageDiffNote.note.author, + }, + ]; + } + }); + store.writeQuery({ query, variables, - data: { - ...data, - design: { - ...design, - notesCount, - }, - }, + data, }); }; const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => { - const data = store.readQuery({ + const sourceData = 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)], - }; + const data = produce(sourceData, draftData => { + const design = extractDesign(draftData); + 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, - }, + data, }); }; const addNewDesignToStore = (store, designManagementUpload, query) => { - const data = store.readQuery(query); + const sourceData = store.readQuery(query); - const currentDesigns = data.project.issue.designCollection.designs.nodes; - const existingDesigns = groupBy(currentDesigns, 'filename'); - const newDesigns = currentDesigns.concat( - designManagementUpload.designs.filter(d => !existingDesigns[d.filename]), - ); + const data = produce(sourceData, draftData => { + const currentDesigns = extractDesigns(draftData); + const existingDesigns = groupBy(currentDesigns, 'filename'); + const newDesigns = currentDesigns.concat( + designManagementUpload.designs.filter(d => !existingDesigns[d.filename]), + ); - let newVersionNode; - const findNewVersions = designManagementUpload.designs.find(design => design.versions); + let newVersionNode; + const findNewVersions = designManagementUpload.designs.find(design => design.versions); - if (findNewVersions) { - const findNewVersionsNodes = findNewVersions.versions.nodes; + if (findNewVersions) { + const findNewVersionsNodes = findNewVersions.versions.nodes; - if (findNewVersionsNodes && findNewVersionsNodes.length) { - newVersionNode = [findNewVersionsNodes[0]]; + if (findNewVersionsNodes && findNewVersionsNodes.length) { + newVersionNode = [findNewVersionsNodes[0]]; + } } - } - - const newVersions = [ - ...(newVersionNode || []), - ...data.project.issue.designCollection.versions.nodes, - ]; - const updatedDesigns = { - __typename: 'DesignCollection', - designs: { - __typename: 'DesignConnection', - nodes: newDesigns, - }, - versions: { - __typename: 'DesignVersionConnection', - nodes: newVersions, - }, - }; + const newVersions = [ + ...(newVersionNode || []), + ...draftData.project.issue.designCollection.versions.nodes, + ]; - data.project.issue.designCollection = updatedDesigns; + const updatedDesigns = { + __typename: 'DesignCollection', + designs: { + __typename: 'DesignConnection', + nodes: newDesigns, + }, + versions: { + __typename: 'DesignVersionConnection', + nodes: newVersions, + }, + }; + // eslint-disable-next-line no-param-reassign + draftData.project.issue.designCollection = updatedDesigns; + }); store.writeQuery({ ...query, @@ -203,14 +175,63 @@ const addNewDesignToStore = (store, designManagementUpload, query) => { }; const moveDesignInStore = (store, designManagementMove, query) => { - const data = store.readQuery(query); - data.project.issue.designCollection.designs = designManagementMove.designCollection.designs; + const sourceData = store.readQuery(query); + + const data = produce(sourceData, draftData => { + // eslint-disable-next-line no-param-reassign + draftData.project.issue.designCollection.designs = + designManagementMove.designCollection.designs; + }); + store.writeQuery({ ...query, data, }); }; +export const addPendingTodoToStore = (store, pendingTodo, query, queryVariables) => { + const sourceData = store.readQuery({ + query, + variables: queryVariables, + }); + + const data = produce(sourceData, draftData => { + const design = extractDesign(draftData); + const existingTodos = design.currentUserTodos?.nodes || []; + const newTodoNodes = [...existingTodos, { ...pendingTodo, __typename: 'Todo' }]; + + if (!design.currentUserTodos) { + design.currentUserTodos = { + __typename: 'TodoConnection', + nodes: newTodoNodes, + }; + } else { + design.currentUserTodos.nodes = newTodoNodes; + } + }); + + store.writeQuery({ query, variables: queryVariables, data }); +}; + +export const deletePendingTodoFromStore = (store, todoMarkDone, query, queryVariables) => { + const sourceData = store.readQuery({ + query, + variables: queryVariables, + }); + + const { + todo: { id: todoId }, + } = todoMarkDone; + const data = produce(sourceData, draftData => { + const design = extractDesign(draftData); + const existingTodos = design.currentUserTodos?.nodes || []; + + design.currentUserTodos.nodes = existingTodos.filter(({ id }) => id !== todoId); + }); + + store.writeQuery({ query, variables: queryVariables, data }); +}; + const onError = (data, message) => { createFlash(message); throw new Error(data.errors); @@ -235,20 +256,6 @@ export const updateStoreAfterDesignsDelete = (store, data, query, designs) => { } }; -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); @@ -280,3 +287,11 @@ export const updateDesignsOnStoreAfterReorder = (store, data, query) => { moveDesignInStore(store, data, query); } }; + +export const updateStoreAfterDeleteDesignTodo = (store, data, query, queryVariables) => { + if (hasErrors(data)) { + onError(data, DELETE_DESIGN_TODO_ERROR); + } else { + deletePendingTodoFromStore(store, data, query, queryVariables); + } +}; diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js index da8f89ff960..93e4d6060c3 100644 --- a/app/assets/javascripts/design_management/utils/design_management_utils.js +++ b/app/assets/javascripts/design_management/utils/design_management_utils.js @@ -30,10 +30,25 @@ export const findVersionId = id => (id.match('::Version/(.+$)') || [])[1]; export const findNoteId = id => (id.match('DiffNote/(.+$)') || [])[1]; +export const findIssueId = id => (id.match('Issue/(.+$)') || [])[1]; + +export const findDesignId = id => (id.match('Design/(.+$)') || [])[1]; + export const extractDesigns = data => data.project.issue.designCollection.designs.nodes; export const extractDesign = data => (extractDesigns(data) || [])[0]; +export const toDiffNoteGid = noteId => `gid://gitlab/DiffNote/${noteId}`; + +/** + * Return the note ID from a URL hash parameter + * @param {String} urlHash URL hash, including `#` prefix + */ +export const extractDesignNoteId = urlHash => { + const [, noteId] = urlHash.match('#note_([0-9]+$)') || []; + return noteId || null; +}; + /** * Generates optimistic response for a design upload mutation * @param {Array<File>} files @@ -135,3 +150,22 @@ const normalizeAuthor = author => ({ export const extractParticipants = users => users.map(node => normalizeAuthor(node)); export const getPageLayoutElement = () => document.querySelector('.layout-page'); + +/** + * Extract the ID of the To-Do for a given 'delete' path + * Example of todoDeletePath: /delete/1234 + * @param {String} todoDeletePath delete_path from REST API response + */ +export const extractTodoIdFromDeletePath = todoDeletePath => + (todoDeletePath.match('todos/([0-9]+$)') || [])[1]; + +const createTodoGid = todoId => { + return `gid://gitlab/Todo/${todoId}`; +}; + +export const createPendingTodo = todoId => { + return { + __typename: 'Todo', // eslint-disable-line @gitlab/require-i18n-strings + id: createTodoGid(todoId), + }; +}; diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js index c815b11737d..bd21d711462 100644 --- a/app/assets/javascripts/design_management/utils/error_messages.js +++ b/app/assets/javascripts/design_management/utils/error_messages.js @@ -44,6 +44,14 @@ export const MOVE_DESIGN_ERROR = __( 'Something went wrong when reordering designs. Please try again', ); +export const CREATE_DESIGN_TODO_ERROR = __('Failed to create To-Do for the design.'); + +export const CREATE_DESIGN_TODO_EXISTS_ERROR = __('There is already a To-Do for this design.'); + +export const DELETE_DESIGN_TODO_ERROR = __('Failed to remove To-Do for the design.'); + +export const TOGGLE_TODO_ERROR = __('Failed to toggle To-Do for the design.'); + const MAX_SKIPPED_FILES_LISTINGS = 5; const oneDesignSkippedMessage = filename => diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js index b3ecc1453a6..49fa306914c 100644 --- a/app/assets/javascripts/design_management/utils/tracking.js +++ b/app/assets/javascripts/design_management/utils/tracking.js @@ -5,7 +5,6 @@ const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_contex const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design'; const DESIGN_TRACKING_EVENT_NAME = 'view_design'; -// eslint-disable-next-line import/prefer-default-export export function trackDesignDetailView( referer = '', owner = '', |