From d164e450181c314680231811c2e2944b211940dc Mon Sep 17 00:00:00 2001 From: Samantha Ming Date: Thu, 3 Jun 2021 02:17:18 -0700 Subject: [PATCH 1/2] Add replace button to repo blob header Part of an issue that convert Repo Blob from HAML to Vue: https://gitlab.com/gitlab-org/gitlab/-/issues/323210 --- .../pages/projects/blob/show/index.js | 16 +++- .../components/blob_content_viewer.vue | 6 ++ .../repository/components/blob_replace.vue | 71 ++++++++++++++++ .../components/upload_blob_modal.vue | 82 +++++++++++++------ app/views/projects/blob/_blob.html.haml | 11 ++- locale/gitlab.pot | 6 ++ .../components/blob_content_viewer_spec.js | 51 +++++++++++- .../components/blob_replace_spec.js | 67 +++++++++++++++ .../components/upload_blob_modal_spec.js | 80 ++++++++++++++++++ 9 files changed, 360 insertions(+), 30 deletions(-) create mode 100644 app/assets/javascripts/repository/components/blob_replace.vue create mode 100644 spec/frontend/repository/components/blob_replace_spec.js diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 6179586e56cb29..85c11be496f255 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -5,6 +5,7 @@ import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; import BlobViewer from '~/blob/viewer/index'; import GpgBadges from '~/gpg_badges'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import initBlob from '~/pages/projects/init_blob'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; @@ -20,12 +21,25 @@ const apolloProvider = new VueApollo({ const viewBlobEl = document.querySelector('#js-view-blob-app'); if (viewBlobEl) { - const { blobPath, projectPath } = viewBlobEl.dataset; + const { + blobPath, + projectPath, + targetBranch, + originalBranch, + canPushCode, + replacePath, + } = viewBlobEl.dataset; // eslint-disable-next-line no-new new Vue({ el: viewBlobEl, apolloProvider, + provide: { + targetBranch, + originalBranch, + canPushCode: parseBoolean(canPushCode), + replacePath, + }, 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 4a2f516e5cb67c..3e61b1765f2409 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,7 @@ 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 UploadBlobModal from './upload_blob_modal.vue'; + +export default { + i18n: { + replace: __('Replace'), + replacePrimaryBtnText: __('Replace file'), + }, + components: { + GlButton, + UploadBlobModal, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: { + targetBranch: { + default: '', + }, + originalBranch: { + default: '', + }, + canPushCode: { + default: false, + }, + replacePath: { + default: null, + }, + }, + props: { + name: { + type: String, + required: true, + }, + path: { + type: String, + 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 aa087d4c631c38..7f065dbdf6da19 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 00000000000000..fab379c230610c --- /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', +}; + +const DEFAULT_INJECT = { + targetBranch: 'master', + originalBranch: 'master', + canPushCode: true, + replacePath: 'some/replace/path', +}; + +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, canPushCode, replacePath } = DEFAULT_INJECT; + const { name, path } = 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 ec85d5666fb896..d93b1d7e5f1e4e 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); + }); + }); + }); }); -- GitLab From 39bf8b82657a2d6052f955c57cbec27d597ba199 Mon Sep 17 00:00:00 2001 From: Samantha Ming Date: Fri, 11 Jun 2021 23:48:39 -0700 Subject: [PATCH 2/2] Switch to use more GraphQL blob info --- .../pages/projects/blob/show/index.js | 12 +---------- .../components/blob_content_viewer.vue | 8 +++++++- .../repository/components/blob_replace.vue | 20 +++++++++++-------- .../javascripts/repository/mixins/get_ref.js | 2 +- .../queries/blob_info.query.graphql | 1 + app/views/projects/blob/_blob.html.haml | 4 +--- .../components/blob_replace_spec.js | 8 ++++---- 7 files changed, 27 insertions(+), 28 deletions(-) diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 85c11be496f255..6cc0095f5a57fe 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -5,7 +5,6 @@ import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; import BlobViewer from '~/blob/viewer/index'; import GpgBadges from '~/gpg_badges'; import createDefaultClient from '~/lib/graphql'; -import { parseBoolean } from '~/lib/utils/common_utils'; import initBlob from '~/pages/projects/init_blob'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; @@ -21,14 +20,7 @@ const apolloProvider = new VueApollo({ const viewBlobEl = document.querySelector('#js-view-blob-app'); if (viewBlobEl) { - const { - blobPath, - projectPath, - targetBranch, - originalBranch, - canPushCode, - replacePath, - } = viewBlobEl.dataset; + const { blobPath, projectPath, targetBranch, originalBranch } = viewBlobEl.dataset; // eslint-disable-next-line no-new new Vue({ @@ -37,8 +29,6 @@ if (viewBlobEl) { provide: { targetBranch, originalBranch, - canPushCode: parseBoolean(canPushCode), - replacePath, }, render(createElement) { return createElement(BlobContentViewer, { diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 3e61b1765f2409..7fbf331d58599b 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -135,7 +135,13 @@ export default { :edit-path="blobInfo.editBlobPath" :web-ide-path="blobInfo.ideEditPath" /> - + { @@ -49,8 +49,8 @@ describe('BlobReplace component', () => { it('renders UploadBlobModal', () => { createComponent(); - const { targetBranch, originalBranch, canPushCode, replacePath } = DEFAULT_INJECT; - const { name, path } = DEFAULT_PROPS; + const { targetBranch, originalBranch } = DEFAULT_INJECT; + const { name, path, canPushCode, replacePath } = DEFAULT_PROPS; const title = `Replace ${name}`; expect(findUploadBlobModal().props()).toMatchObject({ -- GitLab