diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue index 0a7a22ed3a878e08f9e158d7ede0e3ab01167a4f..62de76e46b52e525c3fef7757dba724c61b2026f 100644 --- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue +++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue @@ -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" diff --git a/doc/user/admin_area/license.md b/doc/user/admin_area/license.md index 7d2d134bf45b6cc0a4e910f007d59f59d28b727f..22133e30aa0383ef52ae4b2134c7188d0bc9c484 100644 --- a/doc/user/admin_area/license.md +++ b/doc/user/admin_area/license.md @@ -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. diff --git a/ee/app/assets/javascripts/admin/licenses/new/components/license_new_app.vue b/ee/app/assets/javascripts/admin/licenses/new/components/license_new_app.vue new file mode 100644 index 0000000000000000000000000000000000000000..d55c10a94ba5ff46ce37eee45600c6e3d546a330 --- /dev/null +++ b/ee/app/assets/javascripts/admin/licenses/new/components/license_new_app.vue @@ -0,0 +1,76 @@ +<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> diff --git a/ee/app/assets/javascripts/admin/licenses/new/constants.js b/ee/app/assets/javascripts/admin/licenses/new/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..64fbe6cfb5af95f0b43d49da888a7bf61ab447e2 --- /dev/null +++ b/ee/app/assets/javascripts/admin/licenses/new/constants.js @@ -0,0 +1,10 @@ +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.'); diff --git a/ee/app/assets/javascripts/pages/admin/licenses/new/index.js b/ee/app/assets/javascripts/pages/admin/licenses/new/index.js index b5ea6c2764f93d6b456f5939e9f660147c487d0d..b0a846b5fb2f8f21c5409ca71326aa1a19497a46 100644 --- a/ee/app/assets/javascripts/pages/admin/licenses/new/index.js +++ b/ee/app/assets/javascripts/pages/admin/licenses/new/index.js @@ -1,3 +1,6 @@ +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(); diff --git a/ee/app/views/admin/licenses/new.html.haml b/ee/app/views/admin/licenses/new.html.haml index a92936e54cfcb60b78642c4d9d8bc02bc2607985..ceceac31b20b77b5f7cb9e9c8a7ef26a30423b68 100644 --- a/ee/app/views/admin/licenses/new.html.haml +++ b/ee/app/views/admin/licenses/new.html.haml @@ -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 diff --git a/ee/spec/features/admin/licenses/admin_uploads_license_spec.rb b/ee/spec/features/admin/licenses/admin_uploads_license_spec.rb index 4e9f4803b111a95d7d18c9ed3f8bc12054be0cf2..9c7a5b9f68ca7b50257443ee45fb45d99658901c 100644 --- a/ee/spec/features/admin/licenses/admin_uploads_license_spec.rb +++ b/ee/spec/features/admin/licenses/admin_uploads_license_spec.rb @@ -113,7 +113,7 @@ 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 diff --git a/ee/spec/frontend/admin/licenses/new/components/license_new_app_spec.js b/ee/spec/frontend/admin/licenses/new/components/license_new_app_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e2a7cd9366c7dd66766631924b93b025b521f1d3 --- /dev/null +++ b/ee/spec/frontend/admin/licenses/new/components/license_new_app_spec.js @@ -0,0 +1,84 @@ +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); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ef2825c9a26ac13edeaed3278b59052a681d1990..b95143580dcf6a938c80896552525b4193951b87 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap index 0f1e118d44c7c70ce7e5ddd3ca15ea91c13bef31..a613b325462494b771977b638ac14b064f5fd4ef 100644 --- a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap +++ b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap @@ -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" diff --git a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js index 2f5afeec1fc1f4f9f7e7d9952eb72fa3067884e0..21e9b401215cd36c4467e07f93895dd73721c744 100644 --- a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js +++ b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js @@ -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); + }); + }); });