Commit d164e450 authored by Samantha Ming's avatar Samantha Ming

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
parent 78096600
...@@ -5,6 +5,7 @@ import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; ...@@ -5,6 +5,7 @@ import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue';
import BlobViewer from '~/blob/viewer/index'; import BlobViewer from '~/blob/viewer/index';
import GpgBadges from '~/gpg_badges'; import GpgBadges from '~/gpg_badges';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import initBlob from '~/pages/projects/init_blob'; import initBlob from '~/pages/projects/init_blob';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
...@@ -20,12 +21,25 @@ const apolloProvider = new VueApollo({ ...@@ -20,12 +21,25 @@ const apolloProvider = new VueApollo({
const viewBlobEl = document.querySelector('#js-view-blob-app'); const viewBlobEl = document.querySelector('#js-view-blob-app');
if (viewBlobEl) { if (viewBlobEl) {
const { blobPath, projectPath } = viewBlobEl.dataset; const {
blobPath,
projectPath,
targetBranch,
originalBranch,
canPushCode,
replacePath,
} = viewBlobEl.dataset;
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: viewBlobEl, el: viewBlobEl,
apolloProvider, apolloProvider,
provide: {
targetBranch,
originalBranch,
canPushCode: parseBoolean(canPushCode),
replacePath,
},
render(createElement) { render(createElement) {
return createElement(BlobContentViewer, { return createElement(BlobContentViewer, {
props: { props: {
......
...@@ -8,11 +8,13 @@ import createFlash from '~/flash'; ...@@ -8,11 +8,13 @@ import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import blobInfoQuery from '../queries/blob_info.query.graphql'; import blobInfoQuery from '../queries/blob_info.query.graphql';
import BlobHeaderEdit from './blob_header_edit.vue'; import BlobHeaderEdit from './blob_header_edit.vue';
import BlobReplace from './blob_replace.vue';
export default { export default {
components: { components: {
BlobHeader, BlobHeader,
BlobHeaderEdit, BlobHeaderEdit,
BlobReplace,
BlobContent, BlobContent,
GlLoadingIcon, GlLoadingIcon,
}, },
...@@ -87,6 +89,9 @@ export default { ...@@ -87,6 +89,9 @@ export default {
}; };
}, },
computed: { computed: {
isLoggedIn() {
return Boolean(gon.current_user_id);
},
isLoading() { isLoading() {
return this.$apollo.queries.project.loading; return this.$apollo.queries.project.loading;
}, },
...@@ -130,6 +135,7 @@ export default { ...@@ -130,6 +135,7 @@ export default {
:edit-path="blobInfo.editBlobPath" :edit-path="blobInfo.editBlobPath"
:web-ide-path="blobInfo.ideEditPath" :web-ide-path="blobInfo.ideEditPath"
/> />
<blob-replace v-if="isLoggedIn" :name="blobInfo.name" :path="path" />
</template> </template>
</blob-header> </blob-header>
<blob-content <blob-content
......
<script>
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 });
},
},
};
</script>
<template>
<div class="gl-mr-3">
<gl-button v-gl-modal="replaceModalId">
{{ $options.i18n.replace }}
</gl-button>
<upload-blob-modal
:modal-id="replaceModalId"
:modal-title="title"
:commit-message="title"
:target-branch="targetBranch"
:original-branch="originalBranch"
:can-push-code="canPushCode"
:path="path"
:replace-path="replacePath"
:primary-btn-text="$options.i18n.replacePrimaryBtnText"
/>
</div>
</template>
...@@ -43,7 +43,6 @@ export default { ...@@ -43,7 +43,6 @@ export default {
GlAlert, GlAlert,
}, },
i18n: { i18n: {
MODAL_TITLE,
COMMIT_LABEL, COMMIT_LABEL,
TARGET_BRANCH_LABEL, TARGET_BRANCH_LABEL,
TOGGLE_CREATE_MR_LABEL, TOGGLE_CREATE_MR_LABEL,
...@@ -51,6 +50,16 @@ export default { ...@@ -51,6 +50,16 @@ export default {
NEW_BRANCH_IN_FORK, NEW_BRANCH_IN_FORK,
}, },
props: { props: {
modalTitle: {
type: String,
default: MODAL_TITLE,
required: false,
},
primaryBtnText: {
type: String,
default: PRIMARY_OPTIONS_TEXT,
required: false,
},
modalId: { modalId: {
type: String, type: String,
required: true, required: true,
...@@ -75,6 +84,11 @@ export default { ...@@ -75,6 +84,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
replacePath: {
type: String,
default: null,
required: false,
},
}, },
data() { data() {
return { return {
...@@ -90,7 +104,7 @@ export default { ...@@ -90,7 +104,7 @@ export default {
computed: { computed: {
primaryOptions() { primaryOptions() {
return { return {
text: PRIMARY_OPTIONS_TEXT, text: this.primaryBtnText,
attributes: [ attributes: [
{ {
variant: 'confirm', variant: 'confirm',
...@@ -136,6 +150,45 @@ export default { ...@@ -136,6 +150,45 @@ export default {
this.file = null; this.file = null;
this.filePreviewURL = 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() { uploadFile() {
this.loading = true; this.loading = true;
...@@ -146,26 +199,7 @@ export default { ...@@ -146,26 +199,7 @@ export default {
} = this; } = this;
const uploadPath = joinPaths(this.path, path); const uploadPath = joinPaths(this.path, path);
const formData = new FormData(); return this.submitRequest('post', uploadPath);
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);
});
}, },
}, },
validFileMimetypes: [], validFileMimetypes: [],
...@@ -175,10 +209,10 @@ export default { ...@@ -175,10 +209,10 @@ export default {
<gl-form> <gl-form>
<gl-modal <gl-modal
:modal-id="modalId" :modal-id="modalId"
:title="$options.i18n.MODAL_TITLE" :title="modalTitle"
:action-primary="primaryOptions" :action-primary="primaryOptions"
:action-cancel="cancelOptions" :action-cancel="cancelOptions"
@primary.prevent="uploadFile" @primary.prevent="submitForm"
> >
<upload-dropzone <upload-dropzone
class="gl-h-200! gl-mb-4" class="gl-h-200! gl-mb-4"
......
= render "projects/blob/breadcrumb", blob: blob = render "projects/blob/breadcrumb", blob: blob
- project = @project.present(current_user: current_user)
- ref = local_assigns[:ref] || @ref
.info-well.d-none.d-sm-block .info-well.d-none.d-sm-block
.well-segment .well-segment
...@@ -12,7 +14,14 @@ ...@@ -12,7 +14,14 @@
- if @code_navigation_path - if @code_navigation_path
#js-code-navigation{ data: { code_navigation_path: @code_navigation_path, blob_path: blob.path, definition_path_prefix: project_blob_path(@project, @ref) } } #js-code-navigation{ data: { code_navigation_path: @code_navigation_path, blob_path: blob.path, definition_path_prefix: project_blob_path(@project, @ref) } }
- if Feature.enabled?(:refactor_blob_viewer, @project, default_enabled: :yaml) - if Feature.enabled?(:refactor_blob_viewer, @project, default_enabled: :yaml)
#js-view-blob-app{ data: { blob_path: blob.path, project_path: @project.full_path } } -# Data info will be removed once we migrate this to use GraphQL
-# Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/330406
#js-view-blob-app{ data: { blob_path: blob.path,
project_path: @project.full_path,
target_branch: project.empty_repo? ? ref : @ref,
original_branch: @ref,
can_push_code: can?(current_user, :push_code, @project).to_s,
replace_path: project_update_blob_path(@project, @id) } }
.gl-spinner-container .gl-spinner-container
= loading_icon(size: 'md') = loading_icon(size: 'md')
- else - else
......
...@@ -27411,9 +27411,15 @@ msgstr "" ...@@ -27411,9 +27411,15 @@ msgstr ""
msgid "Replace" msgid "Replace"
msgstr "" msgstr ""
msgid "Replace %{name}"
msgstr ""
msgid "Replace all label(s)" msgid "Replace all label(s)"
msgstr "" msgstr ""
msgid "Replace file"
msgstr ""
msgid "Replaced all labels with %{label_references} %{label_text}." msgid "Replaced all labels with %{label_references} %{label_text}."
msgstr "" msgstr ""
......
...@@ -5,6 +5,7 @@ import BlobContent from '~/blob/components/blob_content.vue'; ...@@ -5,6 +5,7 @@ import BlobContent from '~/blob/components/blob_content.vue';
import BlobHeader from '~/blob/components/blob_header.vue'; import BlobHeader from '~/blob/components/blob_header.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import BlobHeaderEdit from '~/repository/components/blob_header_edit.vue'; import BlobHeaderEdit from '~/repository/components/blob_header_edit.vue';
import BlobReplace from '~/repository/components/blob_replace.vue';
let wrapper; let wrapper;
const simpleMockData = { const simpleMockData = {
...@@ -75,10 +76,11 @@ const factory = createFactory(shallowMount); ...@@ -75,10 +76,11 @@ const factory = createFactory(shallowMount);
const fullFactory = createFactory(mount); const fullFactory = createFactory(mount);
describe('Blob content viewer component', () => { describe('Blob content viewer component', () => {
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findBlobHeader = () => wrapper.find(BlobHeader); const findBlobHeader = () => wrapper.findComponent(BlobHeader);
const findBlobHeaderEdit = () => wrapper.find(BlobHeaderEdit); const findBlobHeaderEdit = () => wrapper.findComponent(BlobHeaderEdit);
const findBlobContent = () => wrapper.find(BlobContent); const findBlobContent = () => wrapper.findComponent(BlobContent);
const findBlobReplace = () => wrapper.findComponent(BlobReplace);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -169,6 +171,7 @@ describe('Blob content viewer component', () => { ...@@ -169,6 +171,7 @@ describe('Blob content viewer component', () => {
mockData: { blobInfo: simpleMockData }, mockData: { blobInfo: simpleMockData },
stubs: { stubs: {
BlobContent: true, BlobContent: true,
BlobReplace: true,
}, },
}); });
...@@ -185,6 +188,7 @@ describe('Blob content viewer component', () => { ...@@ -185,6 +188,7 @@ describe('Blob content viewer component', () => {
mockData: { blobInfo: richMockData }, mockData: { blobInfo: richMockData },
stubs: { stubs: {
BlobContent: true, BlobContent: true,
BlobReplace: true,
}, },
}); });
...@@ -195,5 +199,44 @@ describe('Blob content viewer component', () => { ...@@ -195,5 +199,44 @@ describe('Blob content viewer component', () => {
webIdePath: ideEditPath, 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);
});
});
}); });
}); });
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',
});
});
});
...@@ -200,4 +200,84 @@ describe('UploadBlobModal', () => { ...@@ -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);
});
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment