diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 6179586e56cb29467a8e0b7dcfeb9e1ad45344e9..6cc0095f5a57fe3761fcfcff7555f7f1b9d89c58 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -20,12 +20,16 @@ const apolloProvider = new VueApollo({ const viewBlobEl = document.querySelector('#js-view-blob-app'); if (viewBlobEl) { - const { blobPath, projectPath } = viewBlobEl.dataset; + const { blobPath, projectPath, targetBranch, originalBranch } = viewBlobEl.dataset; // eslint-disable-next-line no-new new Vue({ el: viewBlobEl, apolloProvider, + provide: { + targetBranch, + originalBranch, + }, render(createElement) { return createElement(BlobContentViewer, { props: { diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 4a2f516e5cb67c6875469e7cd8ed685b74827eab..7fbf331d58599bc0c801986eb217d115693e98cd 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -8,11 +8,13 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; import blobInfoQuery from '../queries/blob_info.query.graphql'; import BlobHeaderEdit from './blob_header_edit.vue'; +import BlobReplace from './blob_replace.vue'; export default { components: { BlobHeader, BlobHeaderEdit, + BlobReplace, BlobContent, GlLoadingIcon, }, @@ -87,6 +89,9 @@ export default { }; }, computed: { + isLoggedIn() { + return Boolean(gon.current_user_id); + }, isLoading() { return this.$apollo.queries.project.loading; }, @@ -130,6 +135,13 @@ export default { :edit-path="blobInfo.editBlobPath" :web-ide-path="blobInfo.ideEditPath" /> + +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { sprintf, __ } from '~/locale'; +import getRefMixin from '../mixins/get_ref'; +import UploadBlobModal from './upload_blob_modal.vue'; + +export default { + i18n: { + replace: __('Replace'), + replacePrimaryBtnText: __('Replace file'), + }, + components: { + GlButton, + UploadBlobModal, + }, + directives: { + GlModal: GlModalDirective, + }, + mixins: [getRefMixin], + inject: { + targetBranch: { + default: '', + }, + originalBranch: { + default: '', + }, + }, + props: { + name: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + replacePath: { + type: String, + required: true, + }, + canPushCode: { + type: Boolean, + required: true, + }, + }, + computed: { + replaceModalId() { + return uniqueId('replace-modal'); + }, + title() { + return sprintf(__('Replace %{name}'), { name: this.name }); + }, + }, +}; + + + diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index aa087d4c631c3861e83f568fc9e73125785b362f..7f065dbdf6da19e8c5897fff1caa7dc59dbed6f3 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -43,7 +43,6 @@ export default { GlAlert, }, i18n: { - MODAL_TITLE, COMMIT_LABEL, TARGET_BRANCH_LABEL, TOGGLE_CREATE_MR_LABEL, @@ -51,6 +50,16 @@ export default { NEW_BRANCH_IN_FORK, }, props: { + modalTitle: { + type: String, + default: MODAL_TITLE, + required: false, + }, + primaryBtnText: { + type: String, + default: PRIMARY_OPTIONS_TEXT, + required: false, + }, modalId: { type: String, required: true, @@ -75,6 +84,11 @@ export default { type: String, required: true, }, + replacePath: { + type: String, + default: null, + required: false, + }, }, data() { return { @@ -90,7 +104,7 @@ export default { computed: { primaryOptions() { return { - text: PRIMARY_OPTIONS_TEXT, + text: this.primaryBtnText, attributes: [ { variant: 'confirm', @@ -136,6 +150,45 @@ export default { this.file = null; this.filePreviewURL = null; }, + submitForm() { + return this.replacePath ? this.replaceFile() : this.uploadFile(); + }, + submitRequest(method, url) { + return axios({ + method, + url, + data: this.formData(), + headers: { + ...ContentTypeMultipartFormData, + }, + }) + .then((response) => { + if (!this.replacePath) { + trackFileUploadEvent('click_upload_modal_form_submit'); + } + visitUrl(response.data.filePath); + }) + .catch(() => { + this.loading = false; + createFlash(ERROR_MESSAGE); + }); + }, + formData() { + const formData = new FormData(); + formData.append('branch_name', this.target); + formData.append('create_merge_request', this.createNewMr); + formData.append('commit_message', this.commit); + formData.append('file', this.file); + + return formData; + }, + replaceFile() { + this.loading = true; + + // The PUT path can be geneated from $route (similar to "uploadFile") once router is connected + // Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/332736 + return this.submitRequest('put', this.replacePath); + }, uploadFile() { this.loading = true; @@ -146,26 +199,7 @@ export default { } = this; const uploadPath = joinPaths(this.path, path); - const formData = new FormData(); - formData.append('branch_name', this.target); - formData.append('create_merge_request', this.createNewMr); - formData.append('commit_message', this.commit); - formData.append('file', this.file); - - return axios - .post(uploadPath, formData, { - headers: { - ...ContentTypeMultipartFormData, - }, - }) - .then((response) => { - trackFileUploadEvent('click_upload_modal_form_submit'); - visitUrl(response.data.filePath); - }) - .catch(() => { - this.loading = false; - createFlash(ERROR_MESSAGE); - }); + return this.submitRequest('post', uploadPath); }, }, validFileMimetypes: [], @@ -175,10 +209,10 @@ export default { { - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findBlobHeader = () => wrapper.find(BlobHeader); - const findBlobHeaderEdit = () => wrapper.find(BlobHeaderEdit); - const findBlobContent = () => wrapper.find(BlobContent); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findBlobHeader = () => wrapper.findComponent(BlobHeader); + const findBlobHeaderEdit = () => wrapper.findComponent(BlobHeaderEdit); + const findBlobContent = () => wrapper.findComponent(BlobContent); + const findBlobReplace = () => wrapper.findComponent(BlobReplace); afterEach(() => { wrapper.destroy(); @@ -169,6 +171,7 @@ describe('Blob content viewer component', () => { mockData: { blobInfo: simpleMockData }, stubs: { BlobContent: true, + BlobReplace: true, }, }); @@ -185,6 +188,7 @@ describe('Blob content viewer component', () => { mockData: { blobInfo: richMockData }, stubs: { BlobContent: true, + BlobReplace: true, }, }); @@ -195,5 +199,44 @@ describe('Blob content viewer component', () => { webIdePath: ideEditPath, }); }); + + describe('BlobReplace', () => { + const { name, path } = simpleMockData; + + it('renders component', async () => { + window.gon.current_user_id = 1; + + fullFactory({ + mockData: { blobInfo: simpleMockData }, + stubs: { + BlobContent: true, + BlobReplace: true, + }, + }); + + await nextTick(); + + expect(findBlobReplace().props()).toMatchObject({ + name, + path, + }); + }); + + it('does not render if not logged in', async () => { + window.gon.current_user_id = null; + + fullFactory({ + mockData: { blobInfo: simpleMockData }, + stubs: { + BlobContent: true, + BlobReplace: true, + }, + }); + + await nextTick(); + + expect(findBlobReplace().exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/repository/components/blob_replace_spec.js b/spec/frontend/repository/components/blob_replace_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4a6f147da221bfe637795d5ad1db5f06131d5925 --- /dev/null +++ b/spec/frontend/repository/components/blob_replace_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import BlobReplace from '~/repository/components/blob_replace.vue'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; + +const DEFAULT_PROPS = { + name: 'some name', + path: 'some/path', + canPushCode: true, + replacePath: 'some/replace/path', +}; + +const DEFAULT_INJECT = { + targetBranch: 'master', + originalBranch: 'master', +}; + +describe('BlobReplace component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(BlobReplace, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + provide: { + ...DEFAULT_INJECT, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); + + it('renders component', () => { + createComponent(); + + const { name, path } = DEFAULT_PROPS; + + expect(wrapper.props()).toMatchObject({ + name, + path, + }); + }); + + it('renders UploadBlobModal', () => { + createComponent(); + + const { targetBranch, originalBranch } = DEFAULT_INJECT; + const { name, path, canPushCode, replacePath } = DEFAULT_PROPS; + const title = `Replace ${name}`; + + expect(findUploadBlobModal().props()).toMatchObject({ + modalTitle: title, + commitMessage: title, + targetBranch, + originalBranch, + canPushCode, + path, + replacePath, + primaryBtnText: 'Replace file', + }); + }); +}); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js index ec85d5666fb8963a8e8f06454c73ed185c1dfd4a..d93b1d7e5f1e4e7a3b951ee422b8290d9a6f439f 100644 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -200,4 +200,84 @@ describe('UploadBlobModal', () => { }); }, ); + + describe('blob file submission type', () => { + const submitForm = async () => { + wrapper.vm.uploadFile = jest.fn(); + wrapper.vm.replaceFile = jest.fn(); + wrapper.vm.submitForm(); + await wrapper.vm.$nextTick(); + }; + + const submitRequest = async () => { + mock = new MockAdapter(axios); + findModal().vm.$emit('primary', mockEvent); + await waitForPromises(); + }; + + describe('upload blob file', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays the default "Upload New File" modal title ', () => { + expect(findModal().props('title')).toBe('Upload New File'); + }); + + it('display the defaul primary button text', () => { + expect(findModal().props('actionPrimary').text).toBe('Upload file'); + }); + + it('calls the default uploadFile when the form submit', async () => { + await submitForm(); + + expect(wrapper.vm.uploadFile).toHaveBeenCalled(); + expect(wrapper.vm.replaceFile).not.toHaveBeenCalled(); + }); + + it('makes a POST request', async () => { + await submitRequest(); + + expect(mock.history.put).toHaveLength(0); + expect(mock.history.post).toHaveLength(1); + }); + }); + + describe('replace blob file', () => { + const modalTitle = 'Replace foo.js'; + const replacePath = 'replace-path'; + const primaryBtnText = 'Replace file'; + + beforeEach(() => { + createComponent({ + modalTitle, + replacePath, + primaryBtnText, + }); + }); + + it('displays the passed modal title', () => { + expect(findModal().props('title')).toBe(modalTitle); + }); + + it('display the passed primary button text', () => { + expect(findModal().props('actionPrimary').text).toBe(primaryBtnText); + }); + + it('calls the replaceFile when the form submit', async () => { + await submitForm(); + + expect(wrapper.vm.replaceFile).toHaveBeenCalled(); + expect(wrapper.vm.uploadFile).not.toHaveBeenCalled(); + }); + + it('makes a PUT request', async () => { + await submitRequest(); + + expect(mock.history.put).toHaveLength(1); + expect(mock.history.post).toHaveLength(0); + expect(mock.history.put[0].url).toBe(replacePath); + }); + }); + }); });