Commit ce1c426d authored by Ammar Alakkad's avatar Ammar Alakkad Committed by Kos Palchyk

Change file input on upload license page to a dropzone

Substitutes a Rails-rendered file input with a Vue dropzone component on upload
license page. Now the user can use file picker window or drag-n-drop a file
onto the form.

Because the dropzone is used inside a Ruby form (not inside a Vue form), some
changes were made to upload_dropzone component to support this: Allow
upload_dropzone to use dynamic input name and to modify its input files
property when files are droppped on it.

Changelog: changed
EE: true
parent d1fb9295
......@@ -41,6 +41,16 @@ export default {
required: false,
default: false,
},
inputFieldName: {
type: String,
required: false,
default: 'upload_file',
},
shouldUpdateInputOnFileDrop: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -84,6 +94,30 @@ export default {
return;
}
// NOTE: This is a temporary solution to integrate dropzone into a Rails
// form. On file drop if `shouldUpdateInputOnFileDrop` is true, the file
// input value is updated. So that when the form is submitted — the file
// value would be send together with the form data. This solution should
// be removed when License file upload page is fully migrated:
// https://gitlab.com/gitlab-org/gitlab/-/issues/352501
// NOTE: as per https://caniuse.com/mdn-api_htmlinputelement_files, IE11
// is not able to set input.files property, thought the user would still
// be able to use the file picker dialogue option, by clicking the
// "openFileUpload" button
if (this.shouldUpdateInputOnFileDrop) {
// Since FileList cannot be easily manipulated, to match requirement of
// singleFileSelection, we're throwing an error if multiple files were
// dropped on the dropzone
// NOTE: we can drop this logic together with
// `shouldUpdateInputOnFileDrop` flag
if (this.singleFileSelection && files.length > 1) {
this.$emit('error');
return;
}
this.$refs.fileUpload.files = files;
}
this.$emit('change', this.singleFileSelection ? files[0] : files);
},
ondragenter(e) {
......@@ -116,6 +150,7 @@ export default {
<slot>
<button
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"
type="button"
@click="openFileUpload"
>
<div
......@@ -147,7 +182,7 @@ export default {
<input
ref="fileUpload"
type="file"
name="upload_file"
:name="inputFieldName"
:accept="validFileMimetypes"
class="hide"
:multiple="!singleFileSelection"
......
......@@ -59,8 +59,10 @@ Otherwise, to upload your license:
1. On the left sidebar, select **Settings**.
1. In the **License file** area, select **Upload a license**.
1. Upload a license:
- For a file, select **Upload `.gitlab-license` file**, **Choose file**, and
select the license file from your local machine.
- For a file, either:
- Select **Upload `.gitlab-license` file**, then **Choose File** and
select the license file from your local machine.
- Drag and drop the license file to the **Drag your license file here** area.
- For plain text, select **Enter license key** and paste the contents in
**License key**.
1. Select the **Terms of Service** checkbox.
......
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import createFlash from '~/flash';
import {
DROPZONE_DESCRIPTION_TEXT,
FILE_UPLOAD_ERROR_MESSAGE,
FILE_DROP_ERROR_MESSAGE,
DROP_TO_START_MESSAGE,
} from '../constants';
const VALID_LICENSE_FILE_MIMETYPES = ['.gitlab_license', '.gitlab-license', '.txt'];
const FILE_EXTENSION_REGEX = /\.(gitlab[-_]license|txt)$/;
const isValidLicenseFile = ({ name }) => {
return FILE_EXTENSION_REGEX.test(name);
};
export default {
name: 'LicenseNewApp',
components: {
UploadDropzone,
GlLink,
GlSprintf,
},
VALID_LICENSE_FILE_MIMETYPES,
isValidLicenseFile,
i18n: {
DROPZONE_DESCRIPTION_TEXT,
FILE_UPLOAD_ERROR_MESSAGE,
FILE_DROP_ERROR_MESSAGE,
DROP_TO_START_MESSAGE,
},
data() {
return { fileName: null };
},
computed: {
dropzoneDescription() {
return this.fileName ?? this.$options.i18n.DROPZONE_DESCRIPTION_TEXT;
},
},
methods: {
onChange(file) {
this.fileName = file?.name;
},
onError() {
createFlash({ message: this.$options.i18n.FILE_UPLOAD_ERROR_MESSAGE });
},
},
};
</script>
<template>
<upload-dropzone
input-field-name="license[data_file]"
:is-file-valid="$options.isValidLicenseFile"
:valid-file-mimetypes="$options.VALID_LICENSE_FILE_MIMETYPES"
:should-update-input-on-file-drop="true"
:single-file-selection="true"
:enable-drag-behavior="false"
:drop-to-start-message="$options.i18n.DROP_TO_START_MESSAGE"
@change="onChange"
@error="onError"
>
<template #upload-text="{ openFileUpload }">
<gl-sprintf :message="dropzoneDescription">
<template #link="{ content }">
<gl-link @click.stop="openFileUpload">{{ content }}</gl-link>
</template>
</gl-sprintf>
</template>
<template #invalid-drag-data-slot>
{{ $options.i18n.FILE_DROP_ERROR_MESSAGE }}
</template>
</upload-dropzone>
</template>
import { s__ } from '~/locale';
export const DROPZONE_DESCRIPTION_TEXT = s__(
'Licenses|Drag your license file here or %{linkStart}click to upload%{linkEnd}.',
);
export const FILE_UPLOAD_ERROR_MESSAGE = s__('Licenses|The file could not be uploaded.');
export const FILE_DROP_ERROR_MESSAGE = s__(
'Licenses|Error: You are trying to upload something other than a file',
);
export const DROP_TO_START_MESSAGE = s__('Licenses|Drop your license file to start the upload.');
import Vue from 'vue';
import LicenseNewApp from 'ee/admin/licenses/new/components/license_new_app.vue';
const licenseFile = document.querySelector('.license-file');
const licenseKey = document.querySelector('.license-key');
const acceptEULACheckBox = document.querySelector('#accept_eula');
......@@ -15,6 +18,21 @@ const toggleUploadLicenseButton = () => {
uploadLicenseBtn.toggleAttribute('disabled', !acceptEULACheckBox.checked);
};
const initLicenseUploadDropzone = () => {
const el = document.getElementById('js-license-new-app');
return new Vue({
el,
components: {
LicenseNewApp,
},
render(createElement) {
return createElement(LicenseNewApp);
},
});
};
licenseType.forEach((el) => el.addEventListener('change', showLicenseType));
acceptEULACheckBox.addEventListener('change', toggleUploadLicenseButton);
showLicenseType();
initLicenseUploadDropzone();
......@@ -31,9 +31,10 @@
= label_tag :license_type_file, class: 'form-check-label' do
.option-title
= _('Upload %{file_name} file').html_safe % { file_name: '<code>.gitlab-license</code>'.html_safe }
.form-group.license-file.gl-mt-4
= f.label :data_file, _('License file'), class: 'gl-sr-only'
= f.file_field :data_file, accept: ".gitlab-license,.gitlab_license,.txt", class: "form-control"
#js-license-new-app
.form-check.gl-my-4
= radio_button_tag :license_type, :key, @license.data.present?, class: 'form-check-input', data: { qa_selector: 'license_type_key_radio' }
= label_tag :license_type_key, class: 'form-check-label' do
......
......@@ -113,7 +113,7 @@ RSpec.describe "Admin uploads license", :js do
private
def attach_and_upload(path)
attach_file("license_data_file", path)
attach_file("license[data_file]", path, make_visible: true)
check("accept_eula")
click_button("Upload License")
end
......
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import LicenseNewApp from 'ee/admin/licenses/new/components/license_new_app.vue';
import { FILE_UPLOAD_ERROR_MESSAGE } from 'ee/admin/licenses/new/constants';
import createFlash from '~/flash';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
jest.mock('~/flash');
describe('Upload dropzone component', () => {
let wrapper;
const findUploadDropzone = () => wrapper.find(UploadDropzone);
function createComponent() {
wrapper = shallowMount(LicenseNewApp, {
stubs: {
GlSprintf,
},
});
}
beforeEach(() => {
createFlash.mockClear();
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('displays an error when upload-dropzone emits an error', async () => {
findUploadDropzone().vm.$emit('error');
await nextTick();
expect(createFlash).toHaveBeenCalledWith({ message: FILE_UPLOAD_ERROR_MESSAGE });
});
it('displays filename when the file is set in upload-dropzone', async () => {
const uploadDropzone = findUploadDropzone();
uploadDropzone.vm.$emit('change', { name: 'test-license.txt' });
await nextTick();
expect(wrapper.text()).toEqual(expect.stringContaining('test-license.txt'));
});
it('properly resets filename when the file was unset by the upload-dropzone', async () => {
const uploadDropzone = findUploadDropzone();
uploadDropzone.vm.$emit('change', { name: 'test-license.txt' });
await nextTick();
uploadDropzone.vm.$emit('change', null);
await nextTick();
expect(wrapper.text()).not.toEqual(expect.stringContaining('test-license.txt'));
});
describe('allows only license file types for the dropzone', () => {
const properLicenseFileExtensions = ['.gitlab_license', '.gitlab-license', '.txt'];
let isFileValid;
let validFileMimetypes;
beforeEach(() => {
createComponent();
const uploadDropzone = findUploadDropzone();
isFileValid = uploadDropzone.props('isFileValid');
validFileMimetypes = uploadDropzone.props('validFileMimetypes');
});
it('should pass proper extension list for file picker dialogue', () => {
expect(validFileMimetypes).toEqual(properLicenseFileExtensions);
});
it.each(properLicenseFileExtensions)('allows %s file extension', (extension) => {
expect(isFileValid({ name: `license${extension}` })).toBe(true);
});
it.each(['.pdf', '.jpg', '.html'])('rejects %s file extension', (extension) => {
expect(isFileValid({ name: `license${extension}` })).toBe(false);
});
});
});
......@@ -21660,9 +21660,18 @@ msgstr ""
msgid "Licenses|Displays licenses detected in the project, based on the %{linkStart}latest successful%{linkEnd} scan"
msgstr ""
msgid "Licenses|Drag your license file here or %{linkStart}click to upload%{linkEnd}."
msgstr ""
msgid "Licenses|Drop your license file to start the upload."
msgstr ""
msgid "Licenses|Error fetching the license list. Please check your network connection and try again."
msgstr ""
msgid "Licenses|Error: You are trying to upload something other than a file"
msgstr ""
msgid "Licenses|License Compliance"
msgstr ""
......@@ -21681,6 +21690,9 @@ msgstr ""
msgid "Licenses|Specified policies in this project"
msgstr ""
msgid "Licenses|The file could not be uploaded."
msgstr ""
msgid "Licenses|The license list details information about the licenses used within your project."
msgstr ""
......
......@@ -6,6 +6,7 @@ exports[`Upload dropzone component correctly overrides description and drop mess
>
<button
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"
type="button"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
......@@ -86,6 +87,7 @@ exports[`Upload dropzone component when dragging renders correct template when d
>
<button
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"
type="button"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
......@@ -170,6 +172,7 @@ exports[`Upload dropzone component when dragging renders correct template when d
>
<button
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"
type="button"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
......@@ -254,6 +257,7 @@ exports[`Upload dropzone component when dragging renders correct template when d
>
<button
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"
type="button"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
......@@ -339,6 +343,7 @@ exports[`Upload dropzone component when dragging renders correct template when d
>
<button
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"
type="button"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
......@@ -424,6 +429,7 @@ exports[`Upload dropzone component when dragging renders correct template when d
>
<button
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"
type="button"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
......@@ -509,6 +515,7 @@ exports[`Upload dropzone component when no slot provided renders default dropzon
>
<button
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"
type="button"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
......
......@@ -16,6 +16,7 @@ describe('Upload dropzone component', () => {
const findDropzoneArea = () => wrapper.find('[data-testid="dropzone-area"]');
const findIcon = () => wrapper.find(GlIcon);
const findUploadText = () => wrapper.find('[data-testid="upload-text"]').text();
const findFileInput = () => wrapper.find('input[type="file"]');
function createComponent({ slots = {}, data = {}, props = {} } = {}) {
wrapper = shallowMount(UploadDropzone, {
......@@ -197,4 +198,60 @@ describe('Upload dropzone component', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('file input form name', () => {
it('applies inputFieldName as file input name', () => {
createComponent({ props: { inputFieldName: 'test_field_name' } });
expect(findFileInput().attributes('name')).toBe('test_field_name');
});
it('uses default file input name if no inputFieldName provided', () => {
createComponent();
expect(findFileInput().attributes('name')).toBe('upload_file');
});
});
describe('updates file input files value', () => {
// NOTE: the component assigns dropped files from the drop event to the
// input.files property. There's a restriction that nothing but a FileList
// can be assigned to this property. While FileList can't be created
// manually: it has no constructor. And currently there's no good workaround
// for jsdom. So we have to stub the file input in vm.$refs to ensure that
// the files property is updated. This enforces following tests to know a
// bit too much about the SUT internals See this thread for more details on
// FileList in jsdom: https://github.com/jsdom/jsdom/issues/1272
function stubFileInputOnWrapper() {
const fakeFileInput = { files: [] };
wrapper.vm.$refs.fileUpload = fakeFileInput;
}
it('assigns dragged files to the input files property', async () => {
const mockFile = { name: 'test', type: 'image/jpg' };
const mockEvent = mockDragEvent({ files: [mockFile] });
createComponent({ props: { shouldUpdateInputOnFileDrop: true } });
stubFileInputOnWrapper();
wrapper.trigger('dragenter', mockEvent);
await nextTick();
wrapper.trigger('drop', mockEvent);
await nextTick();
expect(wrapper.vm.$refs.fileUpload.files).toEqual([mockFile]);
});
it('throws an error when multiple files are dropped on a single file input dropzone', async () => {
const mockFile = { name: 'test', type: 'image/jpg' };
const mockEvent = mockDragEvent({ files: [mockFile, mockFile] });
createComponent({ props: { shouldUpdateInputOnFileDrop: true, singleFileSelection: true } });
stubFileInputOnWrapper();
wrapper.trigger('dragenter', mockEvent);
await nextTick();
wrapper.trigger('drop', mockEvent);
await nextTick();
expect(wrapper.vm.$refs.fileUpload.files).toEqual([]);
expect(wrapper.emitted('error')).toHaveLength(1);
});
});
});
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