diff --git a/app/assets/javascripts/projects/details/upload_button.vue b/app/assets/javascripts/projects/details/upload_button.vue index d2158c6d9d4b375be671a2e02a18fdea5e8a06a0..7d037bd6fce2776778f2fc8f66c6b5c4997dfa5a 100644 --- a/app/assets/javascripts/projects/details/upload_button.vue +++ b/app/assets/javascripts/projects/details/upload_button.vue @@ -22,12 +22,18 @@ export default { canPushCode: { default: false, }, + canPushToBranch: { + default: false, + }, path: { default: '', }, projectPath: { default: '', }, + emptyRepo: { + default: false, + }, }, uploadBlobModalId: UPLOAD_BLOB_MODAL_ID, }; @@ -49,7 +55,9 @@ export default { :target-branch="targetBranch" :original-branch="originalBranch" :can-push-code="canPushCode" + :can-push-to-branch="canPushToBranch" :path="path" + :empty-repo="emptyRepo" /> diff --git a/app/assets/javascripts/projects/upload_file.js b/app/assets/javascripts/projects/upload_file.js index a1b19abee6bed7e9e735fc34c99c54d501385fbb..794bf1dfd653be106fcbdf8641c84c52dc904caa 100644 --- a/app/assets/javascripts/projects/upload_file.js +++ b/app/assets/javascripts/projects/upload_file.js @@ -8,7 +8,7 @@ export const initUploadFileTrigger = () => { if (!uploadFileTriggerEl) return false; - const { targetBranch, originalBranch, canPushCode, path, projectPath } = + const { targetBranch, originalBranch, canPushCode, canPushToBranch, path, projectPath } = uploadFileTriggerEl.dataset; return new Vue({ @@ -18,8 +18,10 @@ export const initUploadFileTrigger = () => { targetBranch, originalBranch, canPushCode: parseBoolean(canPushCode), + canPushToBranch: parseBoolean(canPushToBranch), path, projectPath, + emptyRepo: true, }, render(h) { return h(UploadButton); diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue index 0a9df26306cc28351b3c315207d098ba052aff0f..cea445bc5cfb7d4f68f6333b82b7e1b8e50311d3 100644 --- a/app/assets/javascripts/repository/components/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -1,12 +1,10 @@ + diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue index f62eea9583de875f3cd0d3f068209057bf36e54f..0d0898955275594c00ef69a65ca775704a817c2c 100644 --- a/app/assets/javascripts/repository/components/header_area.vue +++ b/app/assets/javascripts/repository/components/header_area.vue @@ -42,6 +42,7 @@ export default { 'canCollaborate', 'canEditTree', 'canPushCode', + 'canPushToBranch', 'originalBranch', 'selectedBranch', 'newBranchPath', @@ -179,6 +180,7 @@ export default { :can-collaborate="canCollaborate" :can-edit-tree="canEditTree" :can-push-code="canPushCode" + :can-push-to-branch="canPushToBranch" :original-branch="originalBranch" :selected-branch="selectedBranch" :new-branch-path="newBranchPath" diff --git a/app/assets/javascripts/repository/components/header_area/breadcrumbs.vue b/app/assets/javascripts/repository/components/header_area/breadcrumbs.vue index 1f1c2f24c7aca0d4c795a67cfd6c68852591970f..2675cbc458aed216bedd691e9cc7edc5b66dccd3 100644 --- a/app/assets/javascripts/repository/components/header_area/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/header_area/breadcrumbs.vue @@ -79,6 +79,11 @@ export default { required: false, default: false, }, + canPushToBranch: { + type: Boolean, + required: false, + default: false, + }, selectedBranch: { type: String, required: false, @@ -332,6 +337,7 @@ export default { :target-branch="selectedBranch" :original-branch="originalBranch" :can-push-code="canPushCode" + :can-push-to-branch="canPushToBranch" :path="uploadPath" /> -import { - GlModal, - GlForm, - GlFormGroup, - GlFormInput, - GlFormTextarea, - GlButton, - GlAlert, - GlFormCheckbox, -} from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; +import { logError } from '~/lib/logger'; 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'; -import { - SECONDARY_OPTIONS_TEXT, - COMMIT_LABEL, - TARGET_BRANCH_LABEL, - TOGGLE_CREATE_MR_LABEL, -} from '../constants'; - -const PRIMARY_OPTIONS_TEXT = __('Upload file'); -const MODAL_TITLE = __('Upload new file'); -const REMOVE_FILE_TEXT = __('Remove file'); -const NEW_BRANCH_IN_FORK = __( - 'GitLab will create a branch in your fork and start a merge request.', -); -const ERROR_MESSAGE = __('Error uploading file. Please try again.'); +import CommitChangesModal from '~/repository/components/commit_changes_modal.vue'; export default { components: { - GlModal, - GlForm, - GlFormGroup, - GlFormInput, - GlFormTextarea, GlButton, UploadDropzone, - GlAlert, FileIcon, - GlFormCheckbox, + CommitChangesModal, }, i18n: { - COMMIT_LABEL, - TARGET_BRANCH_LABEL, - TOGGLE_CREATE_MR_LABEL, - REMOVE_FILE_TEXT, - NEW_BRANCH_IN_FORK, + REMOVE_FILE_TEXT: __('Remove file'), + ERROR_MESSAGE: __('Error uploading file. Please try again.'), }, props: { - modalTitle: { - type: String, - default: MODAL_TITLE, - required: false, - }, - primaryBtnText: { - type: String, - default: PRIMARY_OPTIONS_TEXT, - required: false, - }, modalId: { type: String, required: true, @@ -83,6 +43,10 @@ export default { type: Boolean, required: true, }, + canPushToBranch: { + type: Boolean, + required: true, + }, path: { type: String, required: true, @@ -92,45 +56,25 @@ export default { default: null, required: false, }, + emptyRepo: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { - commit: this.commitMessage, - target: this.targetBranch, - createNewMr: true, file: null, filePreviewURL: null, - fileBinary: null, loading: false, }; }, computed: { - primaryOptions() { - return { - text: this.primaryBtnText, - attributes: { - variant: 'confirm', - 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.originalBranch; - }, - formCompleted() { - return this.file && this.commit && this.target; + isValid() { + return Boolean(this.file); }, }, methods: { @@ -152,14 +96,18 @@ export default { this.file = null; this.filePreviewURL = null; }, - submitForm() { - return this.replacePath ? this.replaceFile() : this.uploadFile(); + submitForm(formData) { + return this.replacePath ? this.replaceFile(formData) : this.uploadFile(formData); }, - submitRequest(method, url) { + submitRequest(method, url, formData) { + this.loading = true; + + formData.append('file', this.file); + return axios({ method, url, - data: this.formData(), + data: formData, headers: { ...contentTypeMultipartFormData, }, @@ -167,30 +115,23 @@ export default { .then((response) => { visitUrl(response.data.filePath); }) - .catch(() => { + .catch((e) => { + logError( + `Failed to ${this.replacePath ? 'replace' : 'upload'} file. See exception details for more information.`, + e, + ); + createAlert({ message: this.$options.i18n.ERROR_MESSAGE }); + }) + .finally(() => { this.loading = false; - createAlert({ message: 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 + replaceFile(formData) { + // The PUT path can be generated 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); + return this.submitRequest('put', this.replacePath, formData); }, - uploadFile() { - this.loading = true; - + uploadFile(formData) { const { $route: { params: { path }, @@ -198,22 +139,28 @@ export default { } = this; const uploadPath = joinPaths(this.path, path); - return this.submitRequest('post', uploadPath); + return this.submitRequest('post', uploadPath, formData); }, }, validFileMimetypes: [], }; diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 33127a165f8bf497e48091782011d3d262d307ca..b3a368bd6e7572296ed49a7ba557d7d6287fcee4 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -223,6 +223,7 @@ export default function setupVueRepositoryList() { canCollaborate, canEditTree, canPushCode, + canPushToBranch, selectedBranch, newBranchPath, newTagPath, @@ -249,6 +250,7 @@ export default function setupVueRepositoryList() { currentPath: this.$route.params.path, refType: this.$route.query.ref_type, canCollaborate: parseBoolean(canCollaborate), + canPushToBranch: parseBoolean(canPushToBranch), canEditTree: parseBoolean(canEditTree), canPushCode: parseBoolean(canPushCode), originalBranch: ref, diff --git a/app/assets/javascripts/repository/init_header_app.js b/app/assets/javascripts/repository/init_header_app.js index 9d4082b7f2554be514fb48a9116d875aa5ee352c..58f0daf97bac77885b70e895a56155d011a30540 100644 --- a/app/assets/javascripts/repository/init_header_app.js +++ b/app/assets/javascripts/repository/init_header_app.js @@ -40,6 +40,7 @@ export default function initHeaderApp({ router, isReadmeView = false, isBlobView breadcrumbsCanCollaborate, breadcrumbsCanEditTree, breadcrumbsCanPushCode, + breadcrumbsCanPushToBranch, breadcrumbsSelectedBranch, breadcrumbsNewBranchPath, breadcrumbsNewTagPath, @@ -88,6 +89,7 @@ export default function initHeaderApp({ router, isReadmeView = false, isBlobView canCollaborate: parseBoolean(breadcrumbsCanCollaborate), canEditTree: parseBoolean(breadcrumbsCanEditTree), canPushCode: parseBoolean(breadcrumbsCanPushCode), + canPushToBranch: parseBoolean(breadcrumbsCanPushToBranch), originalBranch: ref, selectedBranch: breadcrumbsSelectedBranch, newBranchPath: breadcrumbsNewBranchPath, diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 52b9872b7935206c16615a17821da3f52f66a1bc..f46b5852adfe62bf42f0751b396d8e54cbc3bfc6 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -115,6 +115,7 @@ def breadcrumb_data_attributes attrs = { selected_branch: selected_branch, can_push_code: can?(current_user, :push_code, @project).to_s, + can_push_to_branch: user_access(@project).can_push_to_branch?(@ref).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), diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 59a83133f90fccc1935a2a5ec9b1bcadf01f8cb8..a89cb2e1071e4ceb6629c5d071631b5eb1b103d8 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -266,6 +266,7 @@ def upload_anchor_data 'target_branch' => default_branch_or_main, 'original_branch' => default_branch_or_main, 'can_push_code' => 'true', + 'can_push_to_branch' => 'true', 'path' => project_create_blob_path(project, default_branch_or_main), 'project_path' => project.full_path } diff --git a/ee/spec/frontend/repository/mock_data.js b/ee/spec/frontend/repository/mock_data.js index 8740d7dcc125512d511113c3488d8d9eb85cefe3..56131b1b4ab520d900f3fb54b994a16e1b6a20f8 100644 --- a/ee/spec/frontend/repository/mock_data.js +++ b/ee/spec/frontend/repository/mock_data.js @@ -24,6 +24,7 @@ export const headerAppInjected = { canCollaborate: true, canEditTree: true, canPushCode: true, + canPushToBranch: true, originalBranch: 'main', selectedBranch: 'feature/new-ui', newBranchPath: '/project/new-branch', diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6cc5ed9ebc4c26e8c91639a4f39204473df787fa..d706652d1f529a5782079a44da250abdc25206ad 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -23115,6 +23115,9 @@ msgstr "" msgid "Failed to delete custom emoji. Please try again." msgstr "" +msgid "Failed to delete file! Please try again." +msgstr "" + msgid "Failed to deploy to" msgstr "" @@ -25048,6 +25051,9 @@ msgstr "" msgid "GitLab will create a branch in your fork and start a merge request." msgstr "" +msgid "GitLab will create a default branch, %{branchName}, and commit your changes." +msgstr "" + msgid "GitLab.com (SaaS)" msgstr "" @@ -46175,9 +46181,6 @@ msgstr "" msgid "Replace all labels" msgstr "" -msgid "Replace file" -msgstr "" - msgid "Replaced all labels with %{label_references} %{label_text}." msgstr "" @@ -59589,9 +59592,6 @@ msgstr "" msgid "Upload file" msgstr "" -msgid "Upload new file" -msgstr "" - msgid "Uploaded date" msgstr "" diff --git a/scripts/frontend/quarantined_vue3_specs.txt b/scripts/frontend/quarantined_vue3_specs.txt index 14c0ef9ed3907658b8cd22b807ddab56674059df..6f8732c041b299eea5d305e2b7bf66fb70ca26a3 100644 --- a/scripts/frontend/quarantined_vue3_specs.txt +++ b/scripts/frontend/quarantined_vue3_specs.txt @@ -282,7 +282,6 @@ spec/frontend/releases/components/tag_create_spec.js spec/frontend/releases/components/tag_field_exsting_spec.js spec/frontend/releases/components/tag_search_spec.js spec/frontend/repository/components/header_area/blob_controls_spec.js -spec/frontend/repository/components/header_area/breadcrumbs_spec.js spec/frontend/repository/components/table/index_spec.js spec/frontend/repository/components/table/row_spec.js spec/frontend/repository/router_spec.js diff --git a/spec/features/projects/files/user_replaces_files_spec.rb b/spec/features/projects/files/user_replaces_files_spec.rb index 148ccd9e399021e048b0ecbdd1bb4381a579045b..926efbd4c2cb8d66db9cdf8f2c368777e8fe19d0 100644 --- a/spec/features/projects/files/user_replaces_files_spec.rb +++ b/spec/features/projects/files/user_replaces_files_spec.rb @@ -48,12 +48,11 @@ click_on('Replace') find(".upload-dropzone-card").drop(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')) - page.within('#modal-replace-blob') do + within_testid('upload-blob-modal') do fill_in(:commit_message, with: 'Replacement file commit message') + click_button('Commit changes') end - click_button('Replace file') - expect(page).to have_content('Lorem ipsum dolor sit amet') expect(page).to have_content('Sed ut perspiciatis unde omnis') expect(page).to have_content('Replacement file commit message') @@ -89,10 +88,9 @@ page.within('#modal-replace-blob') do fill_in(:commit_message, with: 'Replacement file commit message') + click_button('Commit changes') end - click_button('Replace file') - expect(page).to have_content('Replacement file commit message') fork = user.fork_of(project2.reload) @@ -124,14 +122,13 @@ click_on('Replace') epoch = Time.zone.now.strftime('%s%L').last(5) - expect(find_field(_('Target branch')).value).to eq "#{user.username}-protected-branch-patch-#{epoch}" + expect(find_field(_('Commit to a new branch')).value).to eq "#{user.username}-protected-branch-patch-#{epoch}" find(".upload-dropzone-card").drop(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')) page.within('#modal-replace-blob') do fill_in(:commit_message, with: 'Replacement file commit message') + click_button('Commit changes') end - click_button('Replace file') - expect(page).to have_content('Replacement file commit message') expect(page).to have_current_path(project_new_merge_request_path(project3), ignore_query: true) diff --git a/spec/fixtures/sample.pdf b/spec/fixtures/sample.pdf index 81ea09d7d12267293c04bc0ad1bd938a70bf4187..c01805e89c1684e79130151abc23bb80401583a9 100644 Binary files a/spec/fixtures/sample.pdf and b/spec/fixtures/sample.pdf differ diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js index 89f764372cb717dda52ab9dbbab02df40e1ed0b9..d9568dd61ef6c09e286842d37b02c543899d4942 100644 --- a/spec/frontend/repository/components/blob_button_group_spec.js +++ b/spec/frontend/repository/components/blob_button_group_spec.js @@ -132,14 +132,12 @@ describe('BlobButtonGroup component', () => { const title = `Replace ${name}`; expect(findUploadBlobModal().props()).toMatchObject({ - modalTitle: title, commitMessage: title, targetBranch, originalBranch, canPushCode, path, replacePath, - primaryBtnText: 'Replace file', }); }); @@ -157,7 +155,6 @@ describe('BlobButtonGroup component', () => { canPushCode, emptyRepo, isUsingLfs, - handleFormSubmit: expect.any(Function), }); }); }); diff --git a/spec/frontend/repository/components/commit_changes_modal_spec.js b/spec/frontend/repository/components/commit_changes_modal_spec.js index 5b98fd51acbdb89660a8aff22c3ce69ad667a380..941ae8307c7d0a004fe0baef9aa4113662a1c001 100644 --- a/spec/frontend/repository/components/commit_changes_modal_spec.js +++ b/spec/frontend/repository/components/commit_changes_modal_spec.js @@ -139,6 +139,26 @@ describe('CommitChangesModal', () => { }); expect(findSlot().text()).toBe('test form fields slot'); }); + + it('disables actionable while loading', () => { + createComponent({ props: { loading: true } }); + + expect(findModal().props('actionPrimary').attributes).toEqual( + expect.objectContaining({ disabled: true }), + ); + expect(findModal().props('actionCancel').attributes).toEqual( + expect.objectContaining({ disabled: true }), + ); + expect(findCommitTextarea().attributes()).toEqual( + expect.objectContaining({ disabled: 'true' }), + ); + expect(findCurrentBranchRadioOption().attributes()).toEqual( + expect.objectContaining({ disabled: 'true' }), + ); + expect(findNewBranchRadioOption().attributes()).toEqual( + expect.objectContaining({ disabled: 'true' }), + ); + }); }); describe('form', () => { @@ -147,6 +167,15 @@ describe('CommitChangesModal', () => { expect(findForm().attributes('action')).toBe(initialProps.actionPath); }); + it('shows the correct form fields when repo is empty', () => { + createComponent({ props: { emptyRepo: true } }); + expect(findCommitTextarea().exists()).toBe(true); + expect(findRadioGroup().exists()).toBe(false); + expect(findModal().text()).toContain( + 'GitLab will create a default branch, main, and commit your changes.', + ); + }); + it('shows the correct form fields when commit to current branch', () => { createComponent(); expect(findCommitTextarea().exists()).toBe(true); @@ -176,12 +205,8 @@ describe('CommitChangesModal', () => { }); describe('when `canPushToCode` is `false`', () => { - const commitInBranchMessage = sprintf( - 'Your changes can be committed to %{branchName} because a merge request is open.', - { - branchName: 'main', - }, - ); + const commitInBranchMessage = + 'Your changes can be committed to main because a merge request is open.'; it('shows the correct form fields when `branchAllowsCollaboration` is `true`', () => { createComponent({ props: { canPushCode: false, branchAllowsCollaboration: true } }); @@ -292,17 +317,11 @@ describe('CommitChangesModal', () => { }); describe('form submission', () => { - const handleFormSubmitSpy = jest.fn(); - beforeEach(async () => { - createFullComponent({ props: { handleFormSubmit: handleFormSubmitSpy } }); + createFullComponent(); await nextTick(); }); - afterEach(() => { - handleFormSubmitSpy.mockRestore(); - }); - describe('invalid form', () => { beforeEach(async () => { findFormRadioGroup().vm.$emit('input', true); @@ -319,7 +338,24 @@ describe('CommitChangesModal', () => { it('does not submit form', () => { findModal().vm.$emit('primary', { preventDefault: () => {} }); - expect(handleFormSubmitSpy).not.toHaveBeenCalled(); + expect(wrapper.emitted('submit-form')).toBeUndefined(); + }); + }); + + describe('invalid prop is passed in', () => { + beforeEach(() => { + createComponent({ props: { isValid: false } }); + }); + + it('disables submit button', () => { + expect(findModal().props('actionPrimary').attributes).toEqual( + expect.objectContaining({ disabled: true }), + ); + }); + + it('does not submit form', () => { + findModal().vm.$emit('primary', { preventDefault: () => {} }); + expect(wrapper.emitted('submit-form')).toBeUndefined(); }); }); @@ -339,9 +375,18 @@ describe('CommitChangesModal', () => { ); }); - it('submits form', () => { - findModal().vm.$emit('primary', { preventDefault: () => {} }); - expect(handleFormSubmitSpy).toHaveBeenCalled(); + it('submits form', async () => { + await findModal().vm.$emit('primary', { preventDefault: jest.fn() }); + await nextTick(); + const submission = wrapper.emitted('submit-form')[0][0]; + expect(Object.fromEntries(submission)).toStrictEqual({ + authenticity_token: 'mock-csrf-token', + branch_name: 'some valid target branch', + branch_selection: 'true', + commit_message: 'some valid commit message', + create_merge_request: '1', + original_branch: 'main', + }); }); }); }); diff --git a/spec/frontend/repository/components/delete_blob_modal_spec.js b/spec/frontend/repository/components/delete_blob_modal_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..308c3d1674d6ffe3e2c2e2d92787f450c3b23524 --- /dev/null +++ b/spec/frontend/repository/components/delete_blob_modal_spec.js @@ -0,0 +1,104 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMount } from '@vue/test-utils'; +import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue'; +import CommitChangesModal from '~/repository/components/commit_changes_modal.vue'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import * as urlUtility from '~/lib/utils/url_utility'; +import { createAlert } from '~/alert'; +import { logError } from '~/lib/logger'; + +jest.mock('~/alert'); +jest.mock('~/lib/logger'); + +describe('DeleteBlobModal', () => { + let wrapper; + let mock; + let visitUrlSpy; + + const initialProps = { + deletePath: '/delete/blob', + modalId: 'Delete-blob', + commitMessage: 'Delete File', + targetBranch: 'some-target-branch', + originalBranch: 'main', + canPushCode: true, + canPushToBranch: true, + emptyRepo: false, + isUsingLfs: false, + }; + + const createComponent = () => { + wrapper = shallowMount(DeleteBlobModal, { + propsData: { + ...initialProps, + }, + stubs: { + CommitChangesModal, + }, + }); + }; + + const findCommitChangesModal = () => wrapper.findComponent(CommitChangesModal); + const submitForm = async () => { + findCommitChangesModal().vm.$emit('submit-form', new FormData()); + + await axios.waitForAll(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl'); + + createComponent(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('renders commit change modal with correct props', () => { + expect(findCommitChangesModal().props()).toStrictEqual({ + branchAllowsCollaboration: false, + canPushCode: true, + canPushToBranch: true, + commitMessage: 'Delete File', + emptyRepo: false, + isUsingLfs: false, + loading: false, + modalId: 'Delete-blob', + originalBranch: 'main', + targetBranch: 'some-target-branch', + valid: true, + }); + }); + + describe('form submission', () => { + it('handles successful request', async () => { + mock.onPost(initialProps.deletePath).reply(HTTP_STATUS_OK, { filePath: 'blah' }); + + await submitForm(); + + expect(visitUrlSpy).toHaveBeenCalledWith('blah'); + }); + + it('handles failed request', async () => { + mock = new MockAdapter(axios); + mock.onPost(initialProps.deletePath).timeout(); + + await submitForm(); + + const mockError = new Error('timeout of 0ms exceeded'); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Failed to delete file! Please try again.', + error: mockError, + }); + expect(logError).toHaveBeenCalledWith( + 'Failed to delete file. See exception details for more information.', + mockError, + ); + }); + }); +}); diff --git a/spec/frontend/repository/components/header_area/breadcrumbs_spec.js b/spec/frontend/repository/components/header_area/breadcrumbs_spec.js index f9653521b50895adccd6d2ff6a889219d0479ac8..53324051883141f22e7db966f7ec66c501831bac 100644 --- a/spec/frontend/repository/components/header_area/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/header_area/breadcrumbs_spec.js @@ -196,9 +196,20 @@ describe('Repository breadcrumbs component', () => { }); it('renders the modal once loaded', async () => { - await nextTick(); + await waitForPromises(); expect(findUploadBlobModal().exists()).toBe(true); + expect(findUploadBlobModal().props()).toStrictEqual({ + canPushCode: false, + canPushToBranch: false, + commitMessage: 'Upload New File', + emptyRepo: false, + modalId: 'modal-upload-blob', + originalBranch: '', + path: '', + replacePath: null, + targetBranch: '', + }); }); }); @@ -211,7 +222,7 @@ describe('Repository breadcrumbs component', () => { }); it('renders the modal once loaded', async () => { - await nextTick(); + await waitForPromises(); expect(findNewDirectoryModal().exists()).toBe(true); expect(findNewDirectoryModal().props('path')).toBe('root/master/some_dir'); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js index c43afaac1acaff448a6ce1b7797f6531845caa5a..d8c293deed20984429894ac4aeac6d59eadec4d2 100644 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -1,21 +1,23 @@ -import { GlModal, GlFormInput, GlFormTextarea, GlFormCheckbox, GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; -import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { visitUrl } from '~/lib/utils/url_utility'; +import * as urlUtility 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'; +import CommitChangesModal from '~/repository/components/commit_changes_modal.vue'; +import { logError } from '~/lib/logger'; jest.mock('~/alert'); -jest.mock('~/lib/utils/url_utility', () => ({ - visitUrl: jest.fn(), - joinPaths: () => '/new_upload', -})); +jest.mock('~/lib/logger'); + +const NEW_PATH = '/new-upload'; +const REPLACE_PATH = '/replace-path'; +const ERROR_UPLOAD = 'Failed to upload file. See exception details for more information.'; +const ERROR_REPLACE = 'Failed to replace file. See exception details for more information.'; const initialProps = { modalId: 'upload-blob', @@ -23,14 +25,14 @@ const initialProps = { targetBranch: 'main', originalBranch: 'main', canPushCode: true, - path: 'new_upload', + canPushToBranch: true, + path: NEW_PATH, }; describe('UploadBlobModal', () => { let wrapper; let mock; - - const mockEvent = { preventDefault: jest.fn() }; + let visitUrlSpy; const createComponent = (props) => { wrapper = shallowMount(UploadBlobModal, { @@ -38,6 +40,9 @@ describe('UploadBlobModal', () => { ...initialProps, ...props, }, + stubs: { + CommitChangesModal, + }, mocks: { $route: { params: { @@ -48,209 +53,110 @@ describe('UploadBlobModal', () => { }); }; - const findModal = () => wrapper.findComponent(GlModal); - const findAlert = () => wrapper.findComponent(GlAlert); - const findCommitMessage = () => wrapper.findComponent(GlFormTextarea); - const findBranchName = () => wrapper.findComponent(GlFormInput); - const findMrCheckbox = () => wrapper.findComponent(GlFormCheckbox); - const findUploadDropzone = () => wrapper.findComponent(UploadDropzone); - const actionButtonDisabledState = () => findModal().props('actionPrimary').attributes.disabled; - const cancelButtonDisabledState = () => findModal().props('actionCancel').attributes.disabled; - const actionButtonLoadingState = () => findModal().props('actionPrimary').attributes.loading; - const findFileIcon = () => wrapper.findComponent(FileIcon); + beforeEach(() => { + visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl'); + mock = new MockAdapter(axios); - describe.each` - canPushCode | displayBranchName | displayForkedBranchMessage - ${true} | ${true} | ${false} - ${false} | ${false} | ${true} - `( - 'canPushCode = $canPushCode', - ({ canPushCode, displayBranchName, displayForkedBranchMessage }) => { - beforeEach(() => { - createComponent({ canPushCode }); - }); + mock.onPut(REPLACE_PATH).replyOnce(HTTP_STATUS_OK, { filePath: '/replace_file' }); + }); - it('displays the modal', () => { - expect(findModal().exists()).toBe(true); - }); + afterEach(() => { + mock.restore(); + }); - it('includes the upload dropzone', () => { - expect(findUploadDropzone().exists()).toBe(true); - }); + const setupUploadMock = () => { + mock.onPost(NEW_PATH).replyOnce(HTTP_STATUS_OK, { filePath: '/new_file' }); + }; + const setupUploadMockAsError = () => { + mock.onPost(NEW_PATH).timeout(); + }; + const setupReplaceMock = () => { + mock.onPut(REPLACE_PATH).replyOnce(HTTP_STATUS_OK, { filePath: '/replace_file' }); + }; + const setupReplaceMockAsError = () => { + mock.onPut(REPLACE_PATH).timeout(); + }; - it('includes the commit message', () => { - expect(findCommitMessage().exists()).toBe(true); - }); + const findCommitChangesModal = () => wrapper.findComponent(CommitChangesModal); + const findUploadDropzone = () => wrapper.findComponent(UploadDropzone); + const findFileIcon = () => wrapper.findComponent(FileIcon); + const submitForm = async () => { + findCommitChangesModal().vm.$emit('submit-form', new FormData()); - it('displays the disabled upload button', () => { - expect(actionButtonDisabledState()).toBe(true); - }); + await axios.waitForAll(); + }; - it('displays the enabled cancel button', () => { - expect(cancelButtonDisabledState()).toBe(false); - }); + describe('default', () => { + beforeEach(() => { + createComponent(); + }); - it('does not display the MR checkbox', () => { - expect(findMrCheckbox().exists()).toBe(false); + it('renders commit changes modal', () => { + expect(findCommitChangesModal().props()).toMatchObject({ + modalId: 'upload-blob', + commitMessage: 'Upload New File', + targetBranch: 'main', + originalBranch: 'main', + canPushCode: true, + canPushToBranch: true, + valid: false, + loading: false, + emptyRepo: false, }); + }); - it(`${ - displayForkedBranchMessage ? 'displays' : 'does not display' - } the forked branch message`, () => { - expect(findAlert().exists()).toBe(displayForkedBranchMessage); - }); + it('includes the upload dropzone', () => { + expect(findUploadDropzone().exists()).toBe(true); + }); + }); - it(`${displayBranchName ? 'displays' : 'does not display'} the branch name`, () => { - expect(findBranchName().exists()).toBe(displayBranchName); + describe.each` + props | setupMock | setupMockAsError | expectedVisitUrl | expectedError + ${{}} | ${setupUploadMock} | ${setupUploadMockAsError} | ${'/new_file'} | ${ERROR_UPLOAD} + ${{ replacePath: REPLACE_PATH }} | ${setupReplaceMock} | ${setupReplaceMockAsError} | ${'/replace_file'} | ${ERROR_REPLACE} + `( + 'with props=$props', + ({ props, setupMock, setupMockAsError, expectedVisitUrl, expectedError }) => { + beforeEach(async () => { + setupMock(); + createComponent(props); + await nextTick(); }); - if (canPushCode) { - describe('when changing the branch name', () => { - it('displays the MR checkbox', async () => { - createComponent({ targetBranch: 'Not main' }); - - await nextTick(); - - expect(findMrCheckbox().exists()).toBe(true); - }); - }); - } - describe('completed form', () => { beforeEach(() => { findUploadDropzone().vm.$emit( 'change', - new File(['http://file.com?format=jpg'], 'file.jpg'), + new File(['http://gitlab.com/-/uploads/file.jpg'], 'file.jpg'), ); }); it('enables the upload button when the form is completed', () => { - expect(actionButtonDisabledState()).toBe(false); + expect(findCommitChangesModal().props('valid')).toBe(true); }); - 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); - }); + it('displays the correct file type icon', () => { + expect(findFileIcon().props('fileName')).toBe('file.jpg'); }); - describe('successful response', () => { - beforeEach(async () => { - mock = new MockAdapter(axios); - mock.onPost(initialProps.path).reply(HTTP_STATUS_OK, { filePath: 'blah' }); - - findModal().vm.$emit('primary', mockEvent); - - await waitForPromises(); - }); - - it('displays the correct file type icon', () => { - expect(findFileIcon().props('fileName')).toBe('file.jpg'); - }); - - it('redirects to the uploaded file', () => { - expect(visitUrl).toHaveBeenCalled(); - }); + it('on submit, redirects to the uploaded file', async () => { + await submitForm(); - afterEach(() => { - mock.restore(); - }); + expect(visitUrlSpy).toHaveBeenCalledWith(expectedVisitUrl); }); - describe('error response', () => { - beforeEach(async () => { - mock = new MockAdapter(axios); - mock.onPost(initialProps.path).timeout(); - - findModal().vm.$emit('primary', mockEvent); + it('on error, creates an alert error', async () => { + setupMockAsError(); + await submitForm(); - await waitForPromises(); - }); - - it('creates an alert error', () => { - expect(createAlert).toHaveBeenCalledWith({ - message: 'Error uploading file. Please try again.', - }); - }); + const mockError = new Error('timeout of 0ms exceeded'); - afterEach(() => { - mock.restore(); + expect(createAlert).toHaveBeenCalledWith({ + message: 'Error uploading file. Please try again.', }); + expect(logError).toHaveBeenCalledWith(expectedError, mockError); }); }); }, ); - - describe('blob file submission type', () => { - 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('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('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); - }); - }); - }); }); diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index 272947fe54d63407d0ca4f300121ab78e9039dcf..e7ab1eb607b295f041b4f5ef0f9c7602e3655b15 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -191,6 +191,7 @@ export const headerAppInjected = { canCollaborate: true, canEditTree: true, canPushCode: true, + canPushToBranch: true, originalBranch: 'main', selectedBranch: 'feature/new-ui', newBranchPath: '/project/new-branch', diff --git a/spec/helpers/tree_helper_spec.rb b/spec/helpers/tree_helper_spec.rb index 61d1c8039ff15779c92d87c3407ae8e3d44b3158..0acf6a88198a30996da4a320d7c38bd76dfb252b 100644 --- a/spec/helpers/tree_helper_spec.rb +++ b/spec/helpers/tree_helper_spec.rb @@ -19,6 +19,39 @@ end end + describe '#breadcrumb_data_attributes' do + let(:ref) { 'main' } + let(:base_attributes) do + { + selected_branch: ref, + can_push_code: 'false', + can_push_to_branch: 'false', + can_collaborate: 'false', + new_blob_path: project_new_blob_path(project, ref), + upload_path: project_create_blob_path(project, ref), + new_dir_path: project_create_dir_path(project, ref), + new_branch_path: new_project_branch_path(project), + new_tag_path: new_project_tag_path(project), + can_edit_tree: 'false' + } + end + + before do + helper.instance_variable_set(:@project, project) + helper.instance_variable_set(:@ref, ref) + allow(helper).to receive(:selected_branch).and_return(ref) + allow(helper).to receive(:current_user).and_return(user) + allow(helper).to receive(:can?).and_return(false) + allow(helper).to receive(:user_access).and_return(instance_double(Gitlab::UserAccess, can_push_to_branch?: false)) + allow(helper).to receive(:can_collaborate_with_project?).and_return(false) + allow(helper).to receive(:can_edit_tree?).and_return(false) + end + + it 'returns a list of breadcrumb attributes' do + expect(helper.breadcrumb_data_attributes).to eq(base_attributes) + end + end + describe '#vue_file_list_data' do it 'returns a list of attributes related to the project' do helper.instance_variable_set(:@ref_type, 'heads') diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb index 652509f62dd01328866b8608978082dbc7c5eee1..9ce360fdc8000b67535db989f2a4aab21165229f 100644 --- a/spec/presenters/project_presenter_spec.rb +++ b/spec/presenters/project_presenter_spec.rb @@ -674,6 +674,7 @@ label: a_string_including('Upload file'), data: { "can_push_code" => "true", + "can_push_to_branch" => "true", "original_branch" => "master", "path" => "/#{project.full_path}/-/create/master", "project_path" => project.full_path, diff --git a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb index 806ffdad2f14ce63a8464e36cfe17f58ca439497..63d77e9e0a02866eae5371ad63a33e4a72cd1c6b 100644 --- a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb +++ b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb @@ -18,11 +18,11 @@ page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') + choose(option: true) + fill_in(:branch_name, with: 'upload_text', visible: true) + click_button('Commit changes') end - fill_in(:branch_name, with: 'upload_text', visible: true) - click_button('Upload file') - expect(page).to have_content('New commit message') expect(page).to have_current_path(project_new_merge_request_path(project), ignore_query: true) @@ -54,8 +54,9 @@ page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') + choose(option: true) fill_in(:branch_name, with: 'upload_image', visible: true) - click_button('Upload file') + click_button('Commit changes') end wait_for_all_requests @@ -84,15 +85,19 @@ page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') + choose(option: true) fill_in(:branch_name, with: 'upload_image', visible: true) - click_button('Upload file') + click_button('Commit changes') end wait_for_all_requests visit(project_blob_path(project, 'upload_image/sample.pdf')) + wait_for_all_requests + expect(page).to have_css('.js-pdf-viewer') + expect(page).not_to have_content('An error occurred while loading the file. Please try again later.') end end @@ -121,10 +126,9 @@ page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') + click_button('Commit changes') end - click_button('Upload file') - expect(page).to have_content('New commit message') fork = user.fork_of(project2.reload) @@ -159,10 +163,9 @@ page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') + click_button('Commit changes') end - click_button('Upload file') - expect(page).to have_content('New commit message') page.within('.repo-breadcrumb') do @@ -184,10 +187,9 @@ page.within('#details-modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') + click_button('Commit changes') end - click_button('Upload file') - expect(page).to have_content('New commit message') expect(page).to have_content('Lorem ipsum dolor sit amet') expect(page).to have_content('Sed ut perspiciatis unde omnis')