diff options
Diffstat (limited to 'app/assets/javascripts/snippets/components')
5 files changed, 203 insertions, 99 deletions
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index a6651515e47..c01f9524ca8 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -3,9 +3,8 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import Flash from '~/flash'; import { __, sprintf } from '~/locale'; -import axios from '~/lib/utils/axios_utils'; import TitleField from '~/vue_shared/components/form/title.vue'; -import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility'; +import { redirectTo } from '~/lib/utils/url_utility'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql'; @@ -15,6 +14,9 @@ import { SNIPPET_VISIBILITY_PRIVATE, SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR, + SNIPPET_BLOB_ACTION_CREATE, + SNIPPET_BLOB_ACTION_UPDATE, + SNIPPET_BLOB_ACTION_MOVE, } from '../constants'; import SnippetBlobEdit from './snippet_blob_edit.vue'; import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; @@ -53,17 +55,25 @@ export default { }, data() { return { - blob: {}, - fileName: '', - content: '', - isContentLoading: true, + blobsActions: {}, isUpdating: false, newSnippet: false, }; }, computed: { + getActionsEntries() { + return Object.values(this.blobsActions); + }, + allBlobsHaveContent() { + const entries = this.getActionsEntries; + return entries.length > 0 && !entries.find(action => !action.content); + }, + allBlobChangesRegistered() { + const entries = this.getActionsEntries; + return entries.length > 0 && !entries.find(action => action.action === ''); + }, updatePrevented() { - return this.snippet.title === '' || this.content === '' || this.isUpdating; + return this.snippet.title === '' || !this.allBlobsHaveContent || this.isUpdating; }, isProjectSnippet() { return Boolean(this.projectPath); @@ -74,8 +84,7 @@ export default { title: this.snippet.title, description: this.snippet.description, visibilityLevel: this.snippet.visibilityLevel, - fileName: this.fileName, - content: this.content, + files: this.getActionsEntries.filter(entry => entry.action !== ''), }; }, saveButtonLabel() { @@ -97,9 +106,57 @@ export default { return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`; }, }, + created() { + window.addEventListener('beforeunload', this.onBeforeUnload); + }, + destroyed() { + window.removeEventListener('beforeunload', this.onBeforeUnload); + }, methods: { - updateFileName(newName) { - this.fileName = newName; + onBeforeUnload(e = {}) { + const returnValue = __('Are you sure you want to lose unsaved changes?'); + + if (!this.allBlobChangesRegistered) return undefined; + + Object.assign(e, { returnValue }); + return returnValue; + }, + updateBlobActions(args = {}) { + // `_constants` is the internal prop that + // should not be sent to the mutation. Hence we filter it out from + // the argsToUpdateAction that is the data-basis for the mutation. + const { _constants: blobConstants, ...argsToUpdateAction } = args; + const { previousPath, filePath, content } = argsToUpdateAction; + let actionEntry = this.blobsActions[blobConstants.id] || {}; + let tunedActions = { + action: '', + previousPath, + }; + + if (this.newSnippet) { + // new snippet, hence new blob + tunedActions = { + action: SNIPPET_BLOB_ACTION_CREATE, + previousPath: '', + }; + } else if (previousPath && filePath) { + // renaming of a blob + renaming & content update + const renamedToOriginal = filePath === blobConstants.originalPath; + tunedActions = { + action: renamedToOriginal ? SNIPPET_BLOB_ACTION_UPDATE : SNIPPET_BLOB_ACTION_MOVE, + previousPath: !renamedToOriginal ? blobConstants.originalPath : '', + }; + } else if (content !== blobConstants.originalContent) { + // content update only + tunedActions = { + action: SNIPPET_BLOB_ACTION_UPDATE, + previousPath: '', + }; + } + + actionEntry = { ...actionEntry, ...argsToUpdateAction, ...tunedActions }; + + this.$set(this.blobsActions, blobConstants.id, actionEntry); }, flashAPIFailure(err) { const defaultErrorMsg = this.newSnippet @@ -111,24 +168,9 @@ export default { onNewSnippetFetched() { this.newSnippet = true; this.snippet = this.$options.newSnippetSchema; - this.blob = this.snippet.blob; - this.isContentLoading = false; }, onExistingSnippetFetched() { this.newSnippet = false; - const { blob } = this.snippet; - this.blob = blob; - this.fileName = blob.name; - const baseUrl = getBaseURL(); - const url = joinPaths(baseUrl, blob.rawPath); - - axios - .get(url) - .then(res => { - this.content = res.data; - this.isContentLoading = false; - }) - .catch(e => this.flashAPIFailure(e)); }, onSnippetFetch(snippetRes) { if (snippetRes.data.snippets.edges.length === 0) { @@ -172,6 +214,7 @@ export default { if (errors.length) { this.flashAPIFailure(errors[0]); } else { + this.originalContent = this.content; redirectTo(baseObj.snippet.webUrl); } }) @@ -184,7 +227,6 @@ export default { title: '', description: '', visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, - blob: {}, }, }; </script> @@ -215,12 +257,16 @@ export default { :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" /> - <snippet-blob-edit - v-model="content" - :file-name="fileName" - :is-loading="isContentLoading" - @name-change="updateFileName" - /> + <template v-if="blobs.length"> + <snippet-blob-edit + v-for="blob in blobs" + :key="blob.name" + :blob="blob" + @blob-updated="updateBlobActions" + /> + </template> + <snippet-blob-edit v-else @blob-updated="updateBlobActions" /> + <snippet-visibility-edit v-model="snippet.visibilityLevel" :help-link="visibilityHelpLink" diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index bc0034d397e..0779e87e6b6 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -1,19 +1,27 @@ <script> +import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; import SnippetHeader from './snippet_header.vue'; import SnippetTitle from './snippet_title.vue'; import SnippetBlob from './snippet_blob_view.vue'; import { GlLoadingIcon } from '@gitlab/ui'; import { getSnippetMixin } from '../mixins/snippets'; +import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; export default { components: { + BlobEmbeddable, SnippetHeader, SnippetTitle, GlLoadingIcon, SnippetBlob, }, mixins: [getSnippetMixin], + computed: { + embeddable() { + return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC; + }, + }, }; </script> <template> @@ -27,7 +35,10 @@ export default { <template v-else> <snippet-header :snippet="snippet" /> <snippet-title :snippet="snippet" /> - <snippet-blob :snippet="snippet" /> + <blob-embeddable v-if="embeddable" class="gl-mb-5" :url="snippet.webUrl" /> + <div v-for="blob in blobs" :key="blob.path"> + <snippet-blob :snippet="snippet" :blob="blob" /> + </div> </template> </div> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index 62c29b0c7cd..3c2dbfff6e1 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -2,6 +2,17 @@ import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; import { GlLoadingIcon } from '@gitlab/ui'; +import { getBaseURL, joinPaths } from '~/lib/utils/url_utility'; +import axios from '~/lib/utils/axios_utils'; +import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants'; +import Flash from '~/flash'; +import { sprintf } from '~/locale'; + +function localId() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); +} export default { components: { @@ -11,20 +22,70 @@ export default { }, inheritAttrs: false, props: { - value: { - type: String, + blob: { + type: Object, required: false, - default: '', + default: null, + validator: ({ rawPath }) => Boolean(rawPath), }, - fileName: { - type: String, - required: false, - default: '', + }, + data() { + return { + id: localId(), + filePath: this.blob?.path || '', + previousPath: '', + originalPath: this.blob?.path || '', + content: this.blob?.content || '', + originalContent: '', + isContentLoading: this.blob, + }; + }, + watch: { + filePath(filePath, previousPath) { + this.previousPath = previousPath; + this.notifyAboutUpdates({ previousPath }); }, - isLoading: { - type: Boolean, - required: false, - default: true, + content() { + this.notifyAboutUpdates(); + }, + }, + mounted() { + if (this.blob) { + this.fetchBlobContent(); + } + }, + methods: { + notifyAboutUpdates(args = {}) { + const { filePath, previousPath } = args; + this.$emit('blob-updated', { + filePath: filePath || this.filePath, + previousPath: previousPath || this.previousPath, + content: this.content, + _constants: { + originalPath: this.originalPath, + originalContent: this.originalContent, + id: this.id, + }, + }); + }, + fetchBlobContent() { + const baseUrl = getBaseURL(); + const url = joinPaths(baseUrl, this.blob.rawPath); + + axios + .get(url) + .then(res => { + this.originalContent = res.data; + this.content = res.data; + }) + .catch(e => this.flashAPIFailure(e)) + .finally(() => { + this.isContentLoading = false; + }); + }, + flashAPIFailure(err) { + Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err })); + this.isContentLoading = false; }, }, }; @@ -33,23 +94,14 @@ export default { <div class="form-group file-editor"> <label>{{ s__('Snippets|File') }}</label> <div class="file-holder snippet"> - <blob-header-edit - :value="fileName" - data-qa-selector="file_name_field" - @input="$emit('name-change', $event)" - /> + <blob-header-edit v-model="filePath" data-qa-selector="file_name_field" /> <gl-loading-icon - v-if="isLoading" + v-if="isContentLoading" :label="__('Loading snippet')" size="lg" class="loading-animation prepend-top-20 append-bottom-20" /> - <blob-content-edit - v-else - :value="value" - :file-name="fileName" - @input="$emit('input', $event)" - /> + <blob-content-edit v-else v-model="content" :file-name="filePath" /> </div> </div> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index 7472aff3318..afd038eef58 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -1,6 +1,4 @@ <script> -import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; -import { SNIPPET_VISIBILITY_PUBLIC } from '../constants'; import BlobHeader from '~/blob/components/blob_header.vue'; import BlobContent from '~/blob/components/blob_content.vue'; import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; @@ -16,7 +14,6 @@ import { export default { components: { - BlobEmbeddable, BlobHeader, BlobContent, CloneDropdownButton, @@ -49,21 +46,19 @@ export default { type: Object, required: true, }, + blob: { + type: Object, + required: true, + }, }, data() { return { - blob: this.snippet.blob, blobContent: '', activeViewerType: - this.snippet.blob?.richViewer && !window.location.hash - ? RICH_BLOB_VIEWER - : SIMPLE_BLOB_VIEWER, + this.blob?.richViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER, }; }, computed: { - embeddable() { - return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC; - }, isContentLoading() { return this.$apollo.queries.blobContent.loading; }, @@ -92,33 +87,30 @@ export default { }; </script> <template> - <div> - <blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" /> - <article class="file-holder snippet-file-content"> - <blob-header - :blob="blob" - :active-viewer-type="viewer.type" - :has-render-error="hasRenderError" - @viewer-changed="switchViewer" - > - <template #actions> - <clone-dropdown-button - v-if="canBeCloned" - class="mr-2" - :ssh-link="snippet.sshUrlToRepo" - :http-link="snippet.httpUrlToRepo" - data-qa-selector="clone_button" - /> - </template> - </blob-header> - <blob-content - :loading="isContentLoading" - :content="blobContent" - :active-viewer="viewer" - :blob="blob" - @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery" - @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer" - /> - </article> - </div> + <article class="file-holder snippet-file-content"> + <blob-header + :blob="blob" + :active-viewer-type="viewer.type" + :has-render-error="hasRenderError" + @viewer-changed="switchViewer" + > + <template #actions> + <clone-dropdown-button + v-if="canBeCloned" + class="gl-mr-3" + :ssh-link="snippet.sshUrlToRepo" + :http-link="snippet.httpUrlToRepo" + data-qa-selector="clone_button" + /> + </template> + </blob-header> + <blob-content + :loading="isContentLoading" + :content="blobContent" + :active-viewer="viewer" + :blob="blob" + @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery" + @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer" + /> + </article> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 2a06296cb15..707e2b0ea30 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -65,14 +65,17 @@ export default { }; }, computed: { + snippetHasBinary() { + return Boolean(this.snippet.blobs.find(blob => blob.binary)); + }, personalSnippetActions() { return [ { condition: this.snippet.userPermissions.updateSnippet, text: __('Edit'), href: this.editLink, - disabled: this.snippet.blob.binary, - title: this.snippet.blob.binary + disabled: this.snippetHasBinary, + title: this.snippetHasBinary ? __('Snippets with non-text files can only be edited via Git.') : undefined, }, @@ -163,7 +166,7 @@ export default { <div class="detail-page-header"> <div class="detail-page-header-body"> <div - class="snippet-box has-tooltip d-flex align-items-center append-right-5 mb-1" + class="snippet-box has-tooltip d-flex align-items-center gl-mr-2 mb-1" data-qa-selector="snippet_container" :title="snippetVisibilityLevelDescription" data-container="body" |