Commit 141da19f authored by Martin Wortschack's avatar Martin Wortschack

Merge branch...

Merge branch '254280-replace-bootstrap-modal-trigger-in-app-assets-javascripts-blob-blob_file_dropzone-js' into 'master'

Replace bootstrap modal trigger in blob_file_dropzone.js part 1

See merge request gitlab-org/gitlab!53623
parents 9a0caa18 f8b8393d
......@@ -5,6 +5,7 @@ import {
GlDropdownSectionHeader,
GlDropdownItem,
GlIcon,
GlModalDirective,
} from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
......@@ -12,12 +13,15 @@ import { __ } from '../../locale';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import projectShortPathQuery from '../queries/project_short_path.query.graphql';
import UploadBlobModal from './upload_blob_modal.vue';
const ROW_TYPES = {
header: 'header',
divider: 'divider',
};
const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob';
export default {
components: {
GlDropdown,
......@@ -25,6 +29,7 @@ export default {
GlDropdownSectionHeader,
GlDropdownItem,
GlIcon,
UploadBlobModal,
},
apollo: {
projectShortPath: {
......@@ -46,6 +51,9 @@ export default {
},
},
},
directives: {
GlModal: GlModalDirective,
},
mixins: [getRefMixin],
props: {
currentPath: {
......@@ -63,6 +71,21 @@ export default {
required: false,
default: false,
},
canPushCode: {
type: Boolean,
required: false,
default: false,
},
selectedBranch: {
type: String,
required: false,
default: '',
},
origionalBranch: {
type: String,
required: false,
default: '',
},
newBranchPath: {
type: String,
required: false,
......@@ -93,7 +116,13 @@ export default {
required: false,
default: null,
},
uploadPath: {
type: String,
required: false,
default: '',
},
},
uploadBlobModalId: UPLOAD_BLOB_MODAL_ID,
data() {
return {
projectShortPath: '',
......@@ -126,7 +155,10 @@ export default {
);
},
canCreateMrFromFork() {
return this.userPermissions.forkProject && this.userPermissions.createMergeRequestIn;
return this.userPermissions?.forkProject && this.userPermissions?.createMergeRequestIn;
},
showUploadModal() {
return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
},
dropdownItems() {
const items = [];
......@@ -149,10 +181,9 @@ export default {
{
attrs: {
href: '#modal-upload-blob',
'data-target': '#modal-upload-blob',
'data-toggle': 'modal',
},
text: __('Upload file'),
modalId: UPLOAD_BLOB_MODAL_ID,
},
{
attrs: {
......@@ -253,12 +284,26 @@ export default {
<gl-icon name="chevron-down" :size="16" class="float-left" />
</template>
<template v-for="(item, i) in dropdownItems">
<component :is="getComponent(item.type)" :key="i" v-bind="item.attrs">
<component
:is="getComponent(item.type)"
:key="i"
v-bind="item.attrs"
v-gl-modal="item.modalId || null"
>
{{ item.text }}
</component>
</template>
</gl-dropdown>
</li>
</ol>
<upload-blob-modal
v-if="showUploadModal"
:modal-id="$options.uploadBlobModalId"
:commit-message="__('Upload New File')"
:target-branch="selectedBranch"
:origional-branch="origionalBranch"
:can-push-code="canPushCode"
:path="uploadPath"
/>
</nav>
</template>
<script>
import {
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlToggle,
GlButton,
GlAlert,
} from '@gitlab/ui';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
const PRIMARY_OPTIONS_TEXT = __('Upload file');
const SECONDARY_OPTIONS_TEXT = __('Cancel');
const MODAL_TITLE = __('Upload New File');
const COMMIT_LABEL = __('Commit message');
const TARGET_BRANCH_LABEL = __('Target branch');
const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes');
const REMOVE_FILE_TEXT = __('Remove file');
const NEW_BRANCH_IN_FORK = __(
'A new branch will be created in your fork and a new merge request will be started.',
);
const ERROR_MESSAGE = __('Error uploading file. Please try again.');
export default {
components: {
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlToggle,
GlButton,
UploadDropzone,
GlAlert,
},
i18n: {
MODAL_TITLE,
COMMIT_LABEL,
TARGET_BRANCH_LABEL,
TOGGLE_CREATE_MR_LABEL,
REMOVE_FILE_TEXT,
NEW_BRANCH_IN_FORK,
},
props: {
modalId: {
type: String,
required: true,
},
commitMessage: {
type: String,
required: true,
},
targetBranch: {
type: String,
required: true,
},
origionalBranch: {
type: String,
required: true,
},
canPushCode: {
type: Boolean,
required: true,
},
path: {
type: String,
required: true,
},
},
data() {
return {
commit: this.commitMessage,
target: this.targetBranch,
createNewMr: true,
file: null,
filePreviewURL: null,
fileBinary: null,
loading: false,
};
},
computed: {
primaryOptions() {
return {
text: PRIMARY_OPTIONS_TEXT,
attributes: [
{
variant: 'success',
loading: this.loading,
disabled: !this.formCompleted || this.loading,
},
],
};
},
cancelOptions() {
return {
text: SECONDARY_OPTIONS_TEXT,
attributes: [
{
disabled: this.loading,
},
],
};
},
formattedFileSize() {
return numberToHumanSize(this.file.size);
},
showCreateNewMrToggle() {
return this.canPushCode && this.target !== this.origionalBranch;
},
formCompleted() {
return this.file && this.commit && this.target;
},
},
methods: {
setFile(file) {
this.file = file;
const fileUurlReader = new FileReader();
fileUurlReader.readAsDataURL(this.file);
fileUurlReader.onload = (e) => {
this.filePreviewURL = e.target?.result;
};
},
removeFile() {
this.file = null;
this.filePreviewURL = null;
},
uploadFile() {
this.loading = true;
const {
$route: {
params: { path },
},
} = 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) => {
visitUrl(response.data.filePath);
})
.catch(() => {
this.loading = false;
createFlash(ERROR_MESSAGE);
});
},
},
};
</script>
<template>
<gl-form>
<gl-modal
:modal-id="modalId"
:title="$options.i18n.MODAL_TITLE"
:action-primary="primaryOptions"
:action-cancel="cancelOptions"
@primary.prevent="uploadFile"
>
<upload-dropzone class="gl-h-200! gl-mb-4" single-file-selection @change="setFile">
<div
v-if="file"
class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
>
<img v-if="filePreviewURL" :src="filePreviewURL" class="gl-h-11" />
<div>{{ formattedFileSize }}</div>
<div>{{ file.name }}</div>
<gl-button
category="tertiary"
variant="confirm"
:disabled="loading"
@click="removeFile"
>{{ $options.i18n.REMOVE_FILE_TEXT }}</gl-button
>
</div>
</upload-dropzone>
<gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message">
<gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" />
</gl-form-group>
<gl-form-group
v-if="canPushCode"
:label="$options.i18n.TARGET_BRANCH_LABEL"
label-for="branch_name"
>
<gl-form-input v-model="target" :disabled="loading" name="branch_name" />
</gl-form-group>
<gl-toggle
v-if="showCreateNewMrToggle"
v-model="createNewMr"
:disabled="loading"
:label="$options.i18n.TOGGLE_CREATE_MR_LABEL"
/>
<gl-alert v-if="!canPushCode" variant="info" :dismissible="false" class="gl-mt-3">
{{ $options.i18n.NEW_BRANCH_IN_FORK }}
</gl-alert>
</gl-modal>
</gl-form>
</template>
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import { escapeFileUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import { parseBoolean } from '../lib/utils/common_utils';
import { escapeFileUrl } from '../lib/utils/url_utility';
import { __ } from '../locale';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
......@@ -55,6 +55,8 @@ export default function setupVueRepositoryList() {
const {
canCollaborate,
canEditTree,
canPushCode,
selectedBranch,
newBranchPath,
newTagPath,
newBlobPath,
......@@ -65,8 +67,7 @@ export default function setupVueRepositoryList() {
newDirPath,
} = breadcrumbEl.dataset;
router.afterEach(({ params: { path = '/' } }) => {
updateFormAction('.js-upload-blob-form', uploadPath, path);
router.afterEach(({ params: { path } }) => {
updateFormAction('.js-create-dir-form', newDirPath, path);
});
......@@ -81,12 +82,16 @@ export default function setupVueRepositoryList() {
currentPath: this.$route.params.path,
canCollaborate: parseBoolean(canCollaborate),
canEditTree: parseBoolean(canEditTree),
canPushCode: parseBoolean(canPushCode),
origionalBranch: ref,
selectedBranch,
newBranchPath,
newTagPath,
newBlobPath,
forkNewBlobPath,
forkNewDirectoryPath,
forkUploadBlobPath,
uploadPath,
},
});
},
......
......@@ -131,6 +131,8 @@ module TreeHelper
def breadcrumb_data_attributes
attrs = {
selected_branch: selected_branch,
can_push_code: can?(current_user, :push_code, @project).to_s,
can_collaborate: can_collaborate_with_project?(@project).to_s,
new_blob_path: project_new_blob_path(@project, @ref),
upload_path: project_create_blob_path(@project, @ref),
......
......@@ -21,5 +21,4 @@
#js-tree-list{ data: vue_file_list_data(project, ref) }
- if can_edit_tree?
= render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post
= render 'projects/blob/new_dir'
---
title: Migrate bootstrap modal to GlModal for repo single file uploads
merge_request: 53623
author:
type: changed
......@@ -11966,6 +11966,9 @@ msgstr ""
msgid "Error uploading file"
msgstr ""
msgid "Error uploading file. Please try again."
msgstr ""
msgid "Error uploading file: %{stripped}"
msgstr ""
......@@ -24762,6 +24765,9 @@ msgstr ""
msgid "Remove due date"
msgstr ""
msgid "Remove file"
msgstr ""
msgid "Remove fork relationship"
msgstr ""
......@@ -28118,6 +28124,9 @@ msgstr ""
msgid "Start a new merge request"
msgstr ""
msgid "Start a new merge request with these changes"
msgstr ""
msgid "Start a review"
msgstr ""
......
......@@ -3,8 +3,6 @@
require 'spec_helper'
RSpec.describe 'Projects > Files > User uploads files' do
include DropzoneHelper
let(:user) { create(:user) }
let(:project) { create(:project, :repository, name: 'Shop', creator: user) }
let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') }
......@@ -17,36 +15,15 @@ RSpec.describe 'Projects > Files > User uploads files' do
context 'when a user has write access' do
before do
visit(project_tree_path(project))
wait_for_requests
end
include_examples 'it uploads and commit a new text file'
include_examples 'it uploads and commit a new image file'
it 'uploads a file to a sub-directory', :js do
click_link 'files'
page.within('.repo-breadcrumb') do
expect(page).to have_content('files')
end
find('.add-to-tree').click
click_link('Upload file')
drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
end
click_button('Upload file')
expect(page).to have_content('New commit message')
page.within('.repo-breadcrumb') do
expect(page).to have_content('files')
expect(page).to have_content('doc_sample.txt')
end
end
include_examples 'it uploads a file to a sub-directory'
end
context 'when a user does not have write access' do
......
......@@ -17,11 +17,15 @@ RSpec.describe 'Projects > Show > User uploads files' do
context 'when a user has write access' do
before do
visit(project_path(project))
wait_for_requests
end
include_examples 'it uploads and commit a new text file'
include_examples 'it uploads and commit a new image file'
include_examples 'it uploads a file to a sub-directory'
end
context 'when a user does not have write access' do
......
import { GlDropdown } from '@gitlab/ui';
import { shallowMount, RouterLinkStub } from '@vue/test-utils';
import Breadcrumbs from '~/repository/components/breadcrumbs.vue';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
let vm;
describe('Repository breadcrumbs component', () => {
let wrapper;
function factory(currentPath, extraProps = {}) {
vm = shallowMount(Breadcrumbs, {
const factory = (currentPath, extraProps = {}) => {
const $apollo = {
queries: {
userPermissions: {
loading: true,
},
},
};
wrapper = shallowMount(Breadcrumbs, {
propsData: {
currentPath,
...extraProps,
......@@ -13,12 +23,14 @@ function factory(currentPath, extraProps = {}) {
stubs: {
RouterLink: RouterLinkStub,
},
mocks: { $apollo },
});
}
};
const findUploadBlobModal = () => wrapper.find(UploadBlobModal);
describe('Repository breadcrumbs component', () => {
afterEach(() => {
vm.destroy();
wrapper.destroy();
});
it.each`
......@@ -30,13 +42,13 @@ describe('Repository breadcrumbs component', () => {
`('renders $linkCount links for path $path', ({ path, linkCount }) => {
factory(path);
expect(vm.findAll(RouterLinkStub).length).toEqual(linkCount);
expect(wrapper.findAll(RouterLinkStub).length).toEqual(linkCount);
});
it('escapes hash in directory path', () => {
factory('app/assets/javascripts#');
expect(vm.findAll(RouterLinkStub).at(3).props('to')).toEqual(
expect(wrapper.findAll(RouterLinkStub).at(3).props('to')).toEqual(
'/-/tree/app/assets/javascripts%23',
);
});
......@@ -44,26 +56,44 @@ describe('Repository breadcrumbs component', () => {
it('renders last link as active', () => {
factory('app/assets');
expect(vm.findAll(RouterLinkStub).at(2).attributes('aria-current')).toEqual('page');
expect(wrapper.findAll(RouterLinkStub).at(2).attributes('aria-current')).toEqual('page');
});
it('does not render add to tree dropdown when permissions are false', () => {
it('does not render add to tree dropdown when permissions are false', async () => {
factory('/', { canCollaborate: false });
vm.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } });
wrapper.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } });
return vm.vm.$nextTick(() => {
expect(vm.find(GlDropdown).exists()).toBe(false);
});
await wrapper.vm.$nextTick();
expect(wrapper.find(GlDropdown).exists()).toBe(false);
});
it('renders add to tree dropdown when permissions are true', () => {
it('renders add to tree dropdown when permissions are true', async () => {
factory('/', { canCollaborate: true });
vm.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } });
wrapper.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } });
await wrapper.vm.$nextTick();
expect(wrapper.find(GlDropdown).exists()).toBe(true);
});
describe('renders the upload blob modal', () => {
beforeEach(() => {
factory('/', { canEditTree: true });
});
it('does not render the modal while loading', () => {
expect(findUploadBlobModal().exists()).toBe(false);
});
it('renders the modal once loaded', async () => {
wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } });
await wrapper.vm.$nextTick();
return vm.vm.$nextTick(() => {
expect(vm.find(GlDropdown).exists()).toBe(true);
expect(findUploadBlobModal().exists()).toBe(true);
});
});
});
import { GlModal, GlFormInput, GlFormTextarea, GlToggle, GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import { visitUrl } from '~/lib/utils/url_utility';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
joinPaths: () => '/new_upload',
}));
const initialProps = {
modalId: 'upload-blob',
commitMessage: 'Upload New File',
targetBranch: 'master',
origionalBranch: 'master',
canPushCode: true,
path: 'new_upload',
};
describe('UploadBlobModal', () => {
let wrapper;
let mock;
const mockEvent = { preventDefault: jest.fn() };
const createComponent = (props) => {
wrapper = shallowMount(UploadBlobModal, {
propsData: {
...initialProps,
...props,
},
mocks: {
$route: {
params: {
path: '',
},
},
},
});
};
const findModal = () => wrapper.find(GlModal);
const findAlert = () => wrapper.find(GlAlert);
const findCommitMessage = () => wrapper.find(GlFormTextarea);
const findBranchName = () => wrapper.find(GlFormInput);
const findMrToggle = () => wrapper.find(GlToggle);
const findUploadDropzone = () => wrapper.find(UploadDropzone);
const actionButtonDisabledState = () => findModal().props('actionPrimary').attributes[0].disabled;
const cancelButtonDisabledState = () => findModal().props('actionCancel').attributes[0].disabled;
const actionButtonLoadingState = () => findModal().props('actionPrimary').attributes[0].loading;
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe.each`
canPushCode | displayBranchName | displayForkedBranchMessage
${true} | ${true} | ${false}
${false} | ${false} | ${true}
`(
'canPushCode = $canPushCode',
({ canPushCode, displayBranchName, displayForkedBranchMessage }) => {
beforeEach(() => {
createComponent({ canPushCode });
});
it('displays the modal', () => {
expect(findModal().exists()).toBe(true);
});
it('includes the upload dropzone', () => {
expect(findUploadDropzone().exists()).toBe(true);
});
it('includes the commit message', () => {
expect(findCommitMessage().exists()).toBe(true);
});
it('displays the disabled upload button', () => {
expect(actionButtonDisabledState()).toBe(true);
});
it('displays the enabled cancel button', () => {
expect(cancelButtonDisabledState()).toBe(false);
});
it('does not display the MR toggle', () => {
expect(findMrToggle().exists()).toBe(false);
});
it(`${
displayForkedBranchMessage ? 'displays' : 'does not display'
} the forked branch message`, () => {
expect(findAlert().exists()).toBe(displayForkedBranchMessage);
});
it(`${displayBranchName ? 'displays' : 'does not display'} the branch name`, () => {
expect(findBranchName().exists()).toBe(displayBranchName);
});
if (canPushCode) {
describe('when changing the branch name', () => {
it('displays the MR toggle', async () => {
wrapper.setData({ target: 'Not master' });
await wrapper.vm.$nextTick();
expect(findMrToggle().exists()).toBe(true);
});
});
}
describe('completed form', () => {
beforeEach(() => {
wrapper.setData({
file: { type: 'jpg' },
filePreviewURL: 'http://file.com?format=jpg',
});
});
it('enables the upload button when the form is completed', () => {
expect(actionButtonDisabledState()).toBe(false);
});
describe('form submission', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
findModal().vm.$emit('primary', mockEvent);
});
afterEach(() => {
mock.restore();
});
it('disables the upload button', () => {
expect(actionButtonDisabledState()).toBe(true);
});
it('sets the upload button to loading', () => {
expect(actionButtonLoadingState()).toBe(true);
});
});
describe('successful response', () => {
beforeEach(async () => {
mock = new MockAdapter(axios);
mock.onPost(initialProps.path).reply(httpStatusCodes.OK, { filePath: 'blah' });
findModal().vm.$emit('primary', mockEvent);
await waitForPromises();
});
it('redirects to the uploaded file', () => {
expect(visitUrl).toHaveBeenCalled();
});
afterEach(() => {
mock.restore();
});
});
describe('error response', () => {
beforeEach(async () => {
mock = new MockAdapter(axios);
mock.onPost(initialProps.path).timeout();
findModal().vm.$emit('primary', mockEvent);
await waitForPromises();
});
it('creates a flash error', () => {
expect(createFlash).toHaveBeenCalledWith('Error uploading file. Please try again.');
});
afterEach(() => {
mock.restore();
});
});
});
},
);
});
......@@ -10,7 +10,7 @@ RSpec.shared_examples 'it uploads and commit a new text file' do
wait_for_requests
end
drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true)
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
......@@ -42,7 +42,7 @@ RSpec.shared_examples 'it uploads and commit a new image file' do
wait_for_requests
end
drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg'))
attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg'), make_visible: true)
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
......@@ -70,9 +70,11 @@ RSpec.shared_examples 'it uploads and commit a new file to a forked project' do
expect(page).to have_content(fork_message)
wait_for_all_requests
find('.add-to-tree').click
click_link('Upload file')
drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true)
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
......@@ -94,3 +96,30 @@ RSpec.shared_examples 'it uploads and commit a new file to a forked project' do
expect(page).to have_content('Sed ut perspiciatis unde omnis')
end
end
RSpec.shared_examples 'it uploads a file to a sub-directory' do
it 'uploads a file to a sub-directory', :js do
click_link 'files'
page.within('.repo-breadcrumb') do
expect(page).to have_content('files')
end
find('.add-to-tree').click
click_link('Upload file')
attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true)
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
end
click_button('Upload file')
expect(page).to have_content('New commit message')
page.within('.repo-breadcrumb') do
expect(page).to have_content('files')
expect(page).to have_content('doc_sample.txt')
end
end
end
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