diff options
Diffstat (limited to 'app/assets/javascripts/design_management_new/pages')
-rw-r--r-- | app/assets/javascripts/design_management_new/pages/design/index.vue | 367 | ||||
-rw-r--r-- | app/assets/javascripts/design_management_new/pages/index.vue | 346 |
2 files changed, 713 insertions, 0 deletions
diff --git a/app/assets/javascripts/design_management_new/pages/design/index.vue b/app/assets/javascripts/design_management_new/pages/design/index.vue new file mode 100644 index 00000000000..47f5e3a786f --- /dev/null +++ b/app/assets/javascripts/design_management_new/pages/design/index.vue @@ -0,0 +1,367 @@ +<script> +import Mousetrap from 'mousetrap'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { ApolloMutation } from 'vue-apollo'; +import createFlash from '~/flash'; +import { fetchPolicies } from '~/lib/graphql'; +import allVersionsMixin from '../../mixins/all_versions'; +import Toolbar from '../../components/toolbar/index.vue'; +import DesignDestroyer from '../../components/design_destroyer.vue'; +import DesignScaler from '../../components/design_scaler.vue'; +import DesignPresentation from '../../components/design_presentation.vue'; +import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; +import DesignSidebar from '../../components/design_sidebar.vue'; +import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; +import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql'; +import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql'; +import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql'; +import { + extractDiscussions, + extractDesign, + updateImageDiffNoteOptimisticResponse, +} from '../../utils/design_management_utils'; +import { + updateStoreAfterAddImageDiffNote, + updateStoreAfterUpdateImageDiffNote, +} from '../../utils/cache_update'; +import { + ADD_DISCUSSION_COMMENT_ERROR, + ADD_IMAGE_DIFF_NOTE_ERROR, + UPDATE_IMAGE_DIFF_NOTE_ERROR, + DESIGN_NOT_FOUND_ERROR, + DESIGN_VERSION_NOT_EXIST_ERROR, + UPDATE_NOTE_ERROR, + designDeletionError, +} from '../../utils/error_messages'; +import { trackDesignDetailView } from '../../utils/tracking'; +import { DESIGNS_ROUTE_NAME } from '../../router/constants'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; + +export default { + components: { + ApolloMutation, + DesignReplyForm, + DesignPresentation, + DesignScaler, + DesignDestroyer, + Toolbar, + GlLoadingIcon, + GlAlert, + DesignSidebar, + }, + mixins: [allVersionsMixin], + props: { + id: { + type: String, + required: true, + }, + }, + data() { + return { + design: {}, + comment: '', + annotationCoordinates: null, + errorMessage: '', + scale: 1, + resolvedDiscussionsExpanded: false, + }; + }, + apollo: { + design: { + query: getDesignQuery, + // We want to see cached design version if we have one, and fetch newer version on the background to update discussions + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + variables() { + return this.designVariables; + }, + update: data => extractDesign(data), + result(res) { + this.onDesignQueryResult(res); + }, + error() { + this.onQueryError(DESIGN_NOT_FOUND_ERROR); + }, + }, + }, + computed: { + isFirstLoading() { + // We only want to show spinner on initial design load (when opened from a deep link to design) + // If we already have cached a design, loading shouldn't be indicated to user + return this.$apollo.queries.design.loading && !this.design.filename; + }, + discussions() { + if (!this.design.discussions) { + return []; + } + return extractDiscussions(this.design.discussions); + }, + markdownPreviewPath() { + return `/${this.projectPath}/preview_markdown?target_type=Issue`; + }, + isSubmitButtonDisabled() { + return this.comment.trim().length === 0; + }, + designVariables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + filenames: [this.$route.params.id], + atVersion: this.designsVersion, + }; + }, + mutationPayload() { + const { x, y, width, height } = this.annotationCoordinates; + return { + noteableId: this.design.id, + body: this.comment, + position: { + headSha: this.design.diffRefs.headSha, + baseSha: this.design.diffRefs.baseSha, + startSha: this.design.diffRefs.startSha, + x, + y, + width, + height, + paths: { + newPath: this.design.fullPath, + }, + }, + }; + }, + isAnnotating() { + return Boolean(this.annotationCoordinates); + }, + resolvedDiscussions() { + return this.discussions.filter(discussion => discussion.resolved); + }, + }, + watch: { + resolvedDiscussions(val) { + if (!val.length) { + this.resolvedDiscussionsExpanded = false; + } + }, + }, + mounted() { + Mousetrap.bind('esc', this.closeDesign); + this.trackEvent(); + // We need to reset the active discussion when opening a new design + this.updateActiveDiscussion(); + }, + beforeDestroy() { + Mousetrap.unbind('esc', this.closeDesign); + }, + methods: { + addImageDiffNoteToStore( + store, + { + data: { createImageDiffNote }, + }, + ) { + updateStoreAfterAddImageDiffNote( + store, + createImageDiffNote, + getDesignQuery, + this.designVariables, + ); + }, + updateImageDiffNoteInStore( + store, + { + data: { updateImageDiffNote }, + }, + ) { + return updateStoreAfterUpdateImageDiffNote( + store, + updateImageDiffNote, + getDesignQuery, + this.designVariables, + ); + }, + onMoveNote({ noteId, discussionId, position }) { + const discussion = this.discussions.find(({ id }) => id === discussionId); + const note = discussion.notes.find( + ({ discussion: noteDiscussion }) => noteDiscussion.id === discussionId, + ); + + const mutationPayload = { + optimisticResponse: updateImageDiffNoteOptimisticResponse(note, { + position, + }), + variables: { + input: { + id: noteId, + position, + }, + }, + mutation: updateImageDiffNoteMutation, + update: this.updateImageDiffNoteInStore, + }; + + return this.$apollo.mutate(mutationPayload).catch(e => this.onUpdateImageDiffNoteError(e)); + }, + onDesignQueryResult({ data, loading }) { + // On the initial load with cache-and-network policy data is undefined while loading is true + // To prevent throwing an error, we don't perform any logic until loading is false + if (loading) { + return; + } + + if (!data || !extractDesign(data)) { + this.onQueryError(DESIGN_NOT_FOUND_ERROR); + } else if (this.$route.query.version && !this.hasValidVersion) { + this.onQueryError(DESIGN_VERSION_NOT_EXIST_ERROR); + } + }, + onQueryError(message) { + // because we redirect user to /designs (the issue page), + // we want to create these flashes on the issue page + createFlash(message); + this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME }); + }, + onError(message, e) { + this.errorMessage = message; + throw e; + }, + onCreateImageDiffNoteError(e) { + this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e); + }, + onUpdateNoteError(e) { + this.onError(UPDATE_NOTE_ERROR, e); + }, + onDesignDiscussionError(e) { + this.onError(ADD_DISCUSSION_COMMENT_ERROR, e); + }, + onUpdateImageDiffNoteError(e) { + this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e); + }, + onDesignDeleteError(e) { + this.onError(designDeletionError({ singular: true }), e); + }, + onResolveDiscussionError(e) { + this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e); + }, + openCommentForm(annotationCoordinates) { + this.annotationCoordinates = annotationCoordinates; + if (this.$refs.newDiscussionForm) { + this.$refs.newDiscussionForm.focusInput(); + } + }, + closeCommentForm() { + this.comment = ''; + this.annotationCoordinates = null; + }, + closeDesign() { + this.$router.push({ + name: this.$options.DESIGNS_ROUTE_NAME, + query: this.$route.query, + }); + }, + trackEvent() { + // TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue + trackDesignDetailView( + 'issue-design-collection', + 'issue', + this.$route.query.version || this.latestVersionId, + this.isLatestVersion, + ); + }, + updateActiveDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion, + }, + }); + }, + toggleResolvedComments() { + this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded; + }, + }, + createImageDiffNoteMutation, + DESIGNS_ROUTE_NAME, +}; +</script> + +<template> + <div + class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" + > + <gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" /> + <template v-else> + <div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"> + <design-destroyer + :filenames="[design.filename]" + :project-path="projectPath" + :iid="issueIid" + @done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })" + @error="onDesignDeleteError" + > + <template #default="{ mutate, loading }"> + <toolbar + :id="id" + :is-deleting="loading" + :is-latest-version="isLatestVersion" + v-bind="design" + @delete="mutate" + /> + </template> + </design-destroyer> + + <div v-if="errorMessage" class="p-3"> + <gl-alert variant="danger" @dismiss="errorMessage = null"> + {{ errorMessage }} + </gl-alert> + </div> + <design-presentation + :image="design.image" + :image-name="design.filename" + :discussions="discussions" + :is-annotating="isAnnotating" + :scale="scale" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + @openCommentForm="openCommentForm" + @closeCommentForm="closeCommentForm" + @moveNote="onMoveNote" + /> + + <div class="design-scaler-wrapper position-absolute mb-4 d-flex-center"> + <design-scaler @scale="scale = $event" /> + </div> + </div> + <design-sidebar + :design="design" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + :markdown-preview-path="markdownPreviewPath" + @onDesignDiscussionError="onDesignDiscussionError" + @onCreateImageDiffNoteError="onCreateImageDiffNoteError" + @updateNoteError="onUpdateNoteError" + @resolveDiscussionError="onResolveDiscussionError" + @toggleResolvedComments="toggleResolvedComments" + > + <template #replyForm> + <apollo-mutation + v-if="isAnnotating" + #default="{ mutate, loading }" + :mutation="$options.createImageDiffNoteMutation" + :variables="{ + input: mutationPayload, + }" + :update="addImageDiffNoteToStore" + @done="closeCommentForm" + @error="onCreateImageDiffNoteError" + > + <design-reply-form + ref="newDiscussionForm" + v-model="comment" + :is-saving="loading" + :markdown-preview-path="markdownPreviewPath" + @submitForm="mutate" + @cancelForm="closeCommentForm" + /> </apollo-mutation + ></template> + </design-sidebar> + </template> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/pages/index.vue b/app/assets/javascripts/design_management_new/pages/index.vue new file mode 100644 index 00000000000..2a100fae280 --- /dev/null +++ b/app/assets/javascripts/design_management_new/pages/index.vue @@ -0,0 +1,346 @@ +<script> +import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { s__, sprintf } from '~/locale'; +import UploadButton from '../components/upload/button.vue'; +import DeleteButton from '../components/delete_button.vue'; +import Design from '../components/list/item.vue'; +import DesignDestroyer from '../components/design_destroyer.vue'; +import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue'; +import DesignDropzone from '../components/upload/design_dropzone.vue'; +import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql'; +import permissionsQuery from '../graphql/queries/design_permissions.query.graphql'; +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import allDesignsMixin from '../mixins/all_designs'; +import { + UPLOAD_DESIGN_ERROR, + EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, + EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, + designUploadSkippedWarning, + designDeletionError, +} from '../utils/error_messages'; +import { updateStoreAfterUploadDesign } from '../utils/cache_update'; +import { + designUploadOptimisticResponse, + isValidDesignFile, +} from '../utils/design_management_utils'; +import { getFilename } from '~/lib/utils/file_upload'; +import { DESIGNS_ROUTE_NAME } from '../router/constants'; + +const MAXIMUM_FILE_UPLOAD_LIMIT = 10; + +export default { + components: { + GlLoadingIcon, + GlAlert, + GlButton, + UploadButton, + Design, + DesignDestroyer, + DesignVersionDropdown, + DeleteButton, + DesignDropzone, + }, + mixins: [allDesignsMixin], + apollo: { + permissions: { + query: permissionsQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + }; + }, + update: data => data.project.issue.userPermissions, + }, + }, + data() { + return { + permissions: { + createDesign: false, + }, + filesToBeSaved: [], + selectedDesigns: [], + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading; + }, + isSaving() { + return this.filesToBeSaved.length > 0; + }, + canCreateDesign() { + return this.permissions.createDesign; + }, + showToolbar() { + return this.canCreateDesign && this.allVersions.length > 0; + }, + hasDesigns() { + return this.designs.length > 0; + }, + hasSelectedDesigns() { + return this.selectedDesigns.length > 0; + }, + canDeleteDesigns() { + return this.isLatestVersion && this.hasSelectedDesigns; + }, + projectQueryBody() { + return { + query: getDesignListQuery, + variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null }, + }; + }, + selectAllButtonText() { + return this.hasSelectedDesigns + ? s__('DesignManagement|Deselect all') + : s__('DesignManagement|Select all'); + }, + isDesignListEmpty() { + return !this.isSaving && !this.hasDesigns; + }, + designDropzoneWrapperClass() { + return this.isDesignListEmpty + ? 'col-12' + : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3'; + }, + }, + mounted() { + this.toggleOnPasteListener(this.$route.name); + }, + methods: { + resetFilesToBeSaved() { + this.filesToBeSaved = []; + }, + /** + * Determine if a design upload is valid, given [files] + * @param {Array<File>} files + */ + isValidDesignUpload(files) { + if (!this.canCreateDesign) return false; + + if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) { + createFlash( + sprintf( + s__( + 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.', + ), + { + upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT, + }, + ), + ); + + return false; + } + return true; + }, + onUploadDesign(files) { + // convert to Array so that we have Array methods (.map, .some, etc.) + this.filesToBeSaved = Array.from(files); + if (!this.isValidDesignUpload(this.filesToBeSaved)) return null; + + const mutationPayload = { + optimisticResponse: designUploadOptimisticResponse(this.filesToBeSaved), + variables: { + files: this.filesToBeSaved, + projectPath: this.projectPath, + iid: this.issueIid, + }, + context: { + hasUpload: true, + }, + mutation: uploadDesignMutation, + update: this.afterUploadDesign, + }; + + return this.$apollo + .mutate(mutationPayload) + .then(res => this.onUploadDesignDone(res)) + .catch(() => this.onUploadDesignError()); + }, + afterUploadDesign( + store, + { + data: { designManagementUpload }, + }, + ) { + updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody); + }, + onUploadDesignDone(res) { + const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || []; + const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles); + if (skippedWarningMessage) { + createFlash(skippedWarningMessage, 'warning'); + } + + // if this upload resulted in a new version being created, redirect user to the latest version + if (!this.isLatestVersion) { + this.$router.push({ name: DESIGNS_ROUTE_NAME }); + } + this.resetFilesToBeSaved(); + }, + onUploadDesignError() { + this.resetFilesToBeSaved(); + createFlash(UPLOAD_DESIGN_ERROR); + }, + changeSelectedDesigns(filename) { + if (this.isDesignSelected(filename)) { + this.selectedDesigns = this.selectedDesigns.filter(design => design !== filename); + } else { + this.selectedDesigns.push(filename); + } + }, + toggleDesignsSelection() { + if (this.hasSelectedDesigns) { + this.selectedDesigns = []; + } else { + this.selectedDesigns = this.designs.map(design => design.filename); + } + }, + isDesignSelected(filename) { + return this.selectedDesigns.includes(filename); + }, + isDesignToBeSaved(filename) { + return this.filesToBeSaved.some(file => file.name === filename); + }, + canSelectDesign(filename) { + return this.isLatestVersion && this.canCreateDesign && !this.isDesignToBeSaved(filename); + }, + onDesignDelete() { + this.selectedDesigns = []; + if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME }); + }, + onDesignDeleteError() { + const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 }); + createFlash(errorMessage); + }, + onExistingDesignDropzoneChange(files, existingDesignFilename) { + const filesArr = Array.from(files); + + if (filesArr.length > 1) { + createFlash(EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE); + return; + } + + if (!filesArr.some(({ name }) => existingDesignFilename === name)) { + createFlash(EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE); + return; + } + + this.onUploadDesign(files); + }, + onDesignPaste(event) { + const { clipboardData } = event; + const files = Array.from(clipboardData.files); + if (clipboardData && files.length > 0) { + if (!files.some(isValidDesignFile)) { + return; + } + event.preventDefault(); + let filename = getFilename(event); + if (!filename || filename === 'image.png') { + filename = `design_${Date.now()}.png`; + } + const newFile = new File([files[0]], filename); + this.onUploadDesign([newFile]); + } + }, + toggleOnPasteListener(route) { + if (route === DESIGNS_ROUTE_NAME) { + document.addEventListener('paste', this.onDesignPaste); + } else { + document.removeEventListener('paste', this.onDesignPaste); + } + }, + }, + beforeRouteUpdate(to, from, next) { + this.toggleOnPasteListener(to.name); + this.selectedDesigns = []; + next(); + }, + beforeRouteLeave(to, from, next) { + this.toggleOnPasteListener(to.name); + next(); + }, +}; +</script> + +<template> + <div data-testid="designs-root" class="gl-mt-5"> + <header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex"> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full"> + <div> + <span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span> + <design-version-dropdown /> + </div> + <div v-show="hasDesigns" class="qa-selector-toolbar gl-display-flex"> + <gl-button + v-if="isLatestVersion" + variant="link" + size="small" + class="gl-mr-2 js-select-all" + @click="toggleDesignsSelection" + >{{ selectAllButtonText }} + </gl-button> + <design-destroyer + #default="{ mutate, loading }" + :filenames="selectedDesigns" + @done="onDesignDelete" + @error="onDesignDeleteError" + > + <delete-button + v-if="isLatestVersion" + :is-deleting="loading" + button-variant="danger" + button-class="gl-mr-4" + button-size="small" + :has-selected-designs="hasSelectedDesigns" + @deleteSelectedDesigns="mutate()" + > + {{ s__('DesignManagement|Delete selected') }} + <gl-loading-icon v-if="loading" inline class="ml-1" /> + </delete-button> + </design-destroyer> + <upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" /> + </div> + </div> + </header> + <div class="mt-4"> + <gl-loading-icon v-if="isLoading" size="md" /> + <gl-alert v-else-if="error" variant="danger" :dismissible="false"> + {{ __('An error occurred while loading designs. Please try again.') }} + </gl-alert> + <ol v-else class="list-unstyled row"> + <span + v-if="isDesignListEmpty && !allVersions.length" + class="gl-font-weight-bold gl-font-weight-bold gl-ml-5 gl-mb-4" + >{{ s__('DesignManagement|Designs') }}</span + > + <li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper"> + <design-dropzone + :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }" + :has-designs="hasDesigns" + @change="onUploadDesign" + /> + </li> + <li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-3 gl-mb-3"> + <design-dropzone + :has-designs="hasDesigns" + @change="onExistingDesignDropzoneChange($event, design.filename)" + ><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)" + /></design-dropzone> + + <input + v-if="canSelectDesign(design.filename)" + :checked="isDesignSelected(design.filename)" + type="checkbox" + class="design-checkbox" + @change="changeSelectedDesigns(design.filename)" + /> + </li> + </ol> + </div> + <router-view :key="$route.fullPath" /> + </div> +</template> |