diff options
Diffstat (limited to 'app/assets/javascripts/design_management/pages/design/index.vue')
-rw-r--r-- | app/assets/javascripts/design_management/pages/design/index.vue | 400 |
1 files changed, 400 insertions, 0 deletions
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue new file mode 100644 index 00000000000..7ff3271394d --- /dev/null +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -0,0 +1,400 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import Mousetrap from 'mousetrap'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { fetchPolicies } from '~/lib/graphql'; +import allVersionsMixin from '../../mixins/all_versions'; +import Toolbar from '../../components/toolbar/index.vue'; +import DesignDiscussion from '../../components/design_notes/design_discussion.vue'; +import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; +import DesignDestroyer from '../../components/design_destroyer.vue'; +import DesignScaler from '../../components/design_scaler.vue'; +import Participants from '~/sidebar/components/participants/participants.vue'; +import DesignPresentation from '../../components/design_presentation.vue'; +import getDesignQuery from '../../graphql/queries/getDesign.query.graphql'; +import appDataQuery from '../../graphql/queries/appData.query.graphql'; +import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql'; +import updateImageDiffNoteMutation from '../../graphql/mutations/updateImageDiffNote.mutation.graphql'; +import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql'; +import { + extractDiscussions, + extractDesign, + extractParticipants, + updateImageDiffNoteOptimisticResponse, +} from '../../utils/design_management_utils'; +import { + updateStoreAfterAddImageDiffNote, + updateStoreAfterUpdateImageDiffNote, +} from '../../utils/cache_update'; +import { + ADD_DISCUSSION_COMMENT_ERROR, + ADD_IMAGE_DIFF_NOTE_ERROR, + UPDATE_IMAGE_DIFF_NOTE_ERROR, + DESIGN_NOT_FOUND_ERROR, + DESIGN_VERSION_NOT_EXIST_ERROR, + UPDATE_NOTE_ERROR, + designDeletionError, +} from '../../utils/error_messages'; +import { trackDesignDetailView } from '../../utils/tracking'; +import { DESIGNS_ROUTE_NAME } from '../../router/constants'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; + +export default { + components: { + ApolloMutation, + DesignPresentation, + DesignDiscussion, + DesignScaler, + DesignDestroyer, + Toolbar, + DesignReplyForm, + GlLoadingIcon, + GlAlert, + Participants, + }, + mixins: [allVersionsMixin], + props: { + id: { + type: String, + required: true, + }, + }, + data() { + return { + design: {}, + comment: '', + annotationCoordinates: null, + projectPath: '', + errorMessage: '', + issueIid: '', + scale: 1, + }; + }, + apollo: { + appData: { + query: appDataQuery, + manual: true, + result({ data: { projectPath, issueIid } }) { + this.projectPath = projectPath; + this.issueIid = issueIid; + }, + }, + design: { + query: getDesignQuery, + // We want to see cached design version if we have one, and fetch newer version on the background to update discussions + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + variables() { + return this.designVariables; + }, + update: data => extractDesign(data), + result(res) { + this.onDesignQueryResult(res); + }, + error() { + this.onQueryError(DESIGN_NOT_FOUND_ERROR); + }, + }, + }, + computed: { + isFirstLoading() { + // We only want to show spinner on initial design load (when opened from a deep link to design) + // If we already have cached a design, loading shouldn't be indicated to user + return this.$apollo.queries.design.loading && !this.design.filename; + }, + discussions() { + return extractDiscussions(this.design.discussions); + }, + discussionParticipants() { + return extractParticipants(this.design.issue.participants); + }, + markdownPreviewPath() { + return `/${this.projectPath}/preview_markdown?target_type=Issue`; + }, + isSubmitButtonDisabled() { + return this.comment.trim().length === 0; + }, + renderDiscussions() { + return this.discussions.length || this.annotationCoordinates; + }, + designVariables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + filenames: [this.$route.params.id], + atVersion: this.designsVersion, + }; + }, + mutationPayload() { + const { x, y, width, height } = this.annotationCoordinates; + return { + noteableId: this.design.id, + body: this.comment, + position: { + headSha: this.design.diffRefs.headSha, + baseSha: this.design.diffRefs.baseSha, + startSha: this.design.diffRefs.startSha, + x, + y, + width, + height, + paths: { + newPath: this.design.fullPath, + }, + }, + }; + }, + issue() { + return { + ...this.design.issue, + webPath: this.design.issue.webPath.substr(1), + }; + }, + isAnnotating() { + return Boolean(this.annotationCoordinates); + }, + }, + mounted() { + Mousetrap.bind('esc', this.closeDesign); + }, + beforeDestroy() { + Mousetrap.unbind('esc', this.closeDesign); + }, + methods: { + addImageDiffNoteToStore( + store, + { + data: { createImageDiffNote }, + }, + ) { + updateStoreAfterAddImageDiffNote( + store, + createImageDiffNote, + getDesignQuery, + this.designVariables, + ); + }, + updateImageDiffNoteInStore( + store, + { + data: { updateImageDiffNote }, + }, + ) { + return updateStoreAfterUpdateImageDiffNote( + store, + updateImageDiffNote, + getDesignQuery, + this.designVariables, + ); + }, + onMoveNote({ noteId, discussionId, position }) { + const discussion = this.discussions.find(({ id }) => id === discussionId); + const note = discussion.notes.find( + ({ discussion: noteDiscussion }) => noteDiscussion.id === discussionId, + ); + + const mutationPayload = { + optimisticResponse: updateImageDiffNoteOptimisticResponse(note, { + position, + }), + variables: { + input: { + id: noteId, + position, + }, + }, + mutation: updateImageDiffNoteMutation, + update: this.updateImageDiffNoteInStore, + }; + + return this.$apollo.mutate(mutationPayload).catch(e => this.onUpdateImageDiffNoteError(e)); + }, + onDesignQueryResult({ data, loading }) { + // On the initial load with cache-and-network policy data is undefined while loading is true + // To prevent throwing an error, we don't perform any logic until loading is false + if (loading) { + return; + } + + if (!data || !extractDesign(data)) { + this.onQueryError(DESIGN_NOT_FOUND_ERROR); + } else if (this.$route.query.version && !this.hasValidVersion) { + this.onQueryError(DESIGN_VERSION_NOT_EXIST_ERROR); + } + }, + onQueryError(message) { + // because we redirect user to /designs (the issue page), + // we want to create these flashes on the issue page + createFlash(message); + this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME }); + }, + onError(message, e) { + this.errorMessage = message; + throw e; + }, + onCreateImageDiffNoteError(e) { + this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e); + }, + onUpdateNoteError(e) { + this.onError(UPDATE_NOTE_ERROR, e); + }, + onDesignDiscussionError(e) { + this.onError(ADD_DISCUSSION_COMMENT_ERROR, e); + }, + onUpdateImageDiffNoteError(e) { + this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e); + }, + onDesignDeleteError(e) { + this.onError(designDeletionError({ singular: true }), e); + }, + openCommentForm(annotationCoordinates) { + this.annotationCoordinates = annotationCoordinates; + }, + closeCommentForm() { + this.comment = ''; + this.annotationCoordinates = null; + }, + closeDesign() { + this.$router.push({ + name: this.$options.DESIGNS_ROUTE_NAME, + query: this.$route.query, + }); + }, + trackEvent() { + // TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue + trackDesignDetailView( + 'issue-design-collection', + 'issue', + this.$route.query.version || this.latestVersionId, + this.isLatestVersion, + ); + }, + updateActiveDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion, + }, + }); + }, + }, + beforeRouteEnter(to, from, next) { + next(vm => { + vm.trackEvent(); + }); + }, + beforeRouteUpdate(to, from, next) { + this.trackEvent(); + this.closeCommentForm(); + // We need to reset the active discussion when opening a new design + this.updateActiveDiscussion(); + next(); + }, + beforeRouteLeave(to, from, next) { + // We need to reset the active discussion when moving to design list view + this.updateActiveDiscussion(); + next(); + }, + createImageDiffNoteMutation, + DESIGNS_ROUTE_NAME, +}; +</script> + +<template> + <div + class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" + > + <gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" /> + <template v-else> + <div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"> + <design-destroyer + :filenames="[design.filename]" + :project-path="projectPath" + :iid="issueIid" + @done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })" + @error="onDesignDeleteError" + > + <template #default="{ mutate, loading }"> + <toolbar + :id="id" + :is-deleting="loading" + :is-latest-version="isLatestVersion" + v-bind="design" + @delete="mutate" + /> + </template> + </design-destroyer> + + <div v-if="errorMessage" class="p-3"> + <gl-alert variant="danger" @dismiss="errorMessage = null"> + {{ errorMessage }} + </gl-alert> + </div> + <design-presentation + :image="design.image" + :image-name="design.filename" + :discussions="discussions" + :is-annotating="isAnnotating" + :scale="scale" + @openCommentForm="openCommentForm" + @closeCommentForm="closeCommentForm" + @moveNote="onMoveNote" + /> + + <div class="design-scaler-wrapper position-absolute mb-4 d-flex-center"> + <design-scaler @scale="scale = $event" /> + </div> + </div> + <div class="image-notes" @click="updateActiveDiscussion()"> + <h2 class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0"> + {{ issue.title }} + </h2> + <a class="text-tertiary text-decoration-none mb-3 d-block" :href="issue.webUrl">{{ + issue.webPath + }}</a> + <participants + :participants="discussionParticipants" + :show-participant-label="false" + class="mb-4" + /> + <template v-if="renderDiscussions"> + <design-discussion + v-for="(discussion, index) in discussions" + :key="discussion.id" + :discussion="discussion" + :design-id="id" + :noteable-id="design.id" + :discussion-index="index + 1" + :markdown-preview-path="markdownPreviewPath" + @error="onDesignDiscussionError" + @updateNoteError="onUpdateNoteError" + @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)" + /> + <apollo-mutation + v-if="annotationCoordinates" + #default="{ mutate, loading }" + :mutation="$options.createImageDiffNoteMutation" + :variables="{ + input: mutationPayload, + }" + :update="addImageDiffNoteToStore" + @done="closeCommentForm" + @error="onCreateImageDiffNoteError" + > + <design-reply-form + v-model="comment" + :is-saving="loading" + :markdown-preview-path="markdownPreviewPath" + @submitForm="mutate" + @cancelForm="closeCommentForm" + /> + </apollo-mutation> + </template> + <h2 v-else class="new-discussion-disclaimer gl-font-base m-0"> + {{ __("Click the image where you'd like to start a new discussion") }} + </h2> + </div> + </template> + </div> +</template> |