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';
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: {
......
......@@ -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"
/>
<blob-replace v-if="isLoggedIn" :name="blobInfo.name" :path="path" />
</template>
</blob-header>
<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 {
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 {
<gl-form>
<gl-modal
:modal-id="modalId"
:title="$options.i18n.MODAL_TITLE"
:title="modalTitle"
:action-primary="primaryOptions"
:action-cancel="cancelOptions"
@primary.prevent="uploadFile"
@primary.prevent="submitForm"
>
<upload-dropzone
class="gl-h-200! gl-mb-4"
......
= 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
.well-segment
......@@ -12,7 +14,14 @@
- 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) } }
- 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
= loading_icon(size: 'md')
- else
......
......@@ -27411,9 +27411,15 @@ msgstr ""
msgid "Replace"
msgstr ""
msgid "Replace %{name}"
msgstr ""
msgid "Replace all label(s)"
msgstr ""
msgid "Replace file"
msgstr ""
msgid "Replaced all labels with %{label_references} %{label_text}."
msgstr ""
......
......@@ -5,6 +5,7 @@ import BlobContent from '~/blob/components/blob_content.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import BlobHeaderEdit from '~/repository/components/blob_header_edit.vue';
import BlobReplace from '~/repository/components/blob_replace.vue';
let wrapper;
const simpleMockData = {
......@@ -75,10 +76,11 @@ const factory = createFactory(shallowMount);
const fullFactory = createFactory(mount);
describe('Blob content viewer component', () => {
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);
});
});
});
});
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', () => {
});
},
);
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