From 315450480e62676e213c3d395aa7e8070eb9b9e0 Mon Sep 17 00:00:00 2001 From: Chaoyue Zhao <czhao@gitlab.com> Date: Thu, 12 Dec 2024 18:18:36 +0000 Subject: [PATCH] Use commit changes modal for upload/replace blob - https://gitlab.com/gitlab-org/gitlab/-/merge_requests/174093 Changelog: changed --- .../projects/details/upload_button.vue | 8 + .../javascripts/projects/upload_file.js | 4 +- .../components/blob_button_group.vue | 37 +-- .../components/commit_changes_modal.vue | 83 +++--- .../components/delete_blob_modal.vue | 63 ++++ .../repository/components/header_area.vue | 2 + .../components/header_area/breadcrumbs.vue | 6 + .../components/upload_blob_modal.vue | 179 ++++-------- app/assets/javascripts/repository/index.js | 2 + .../javascripts/repository/init_header_app.js | 2 + app/helpers/tree_helper.rb | 1 + app/presenters/project_presenter.rb | 1 + ee/spec/frontend/repository/mock_data.js | 1 + locale/gitlab.pot | 12 +- scripts/frontend/quarantined_vue3_specs.txt | 1 - .../files/user_replaces_files_spec.rb | 13 +- spec/fixtures/sample.pdf | Bin 420 -> 18810 bytes .../components/blob_button_group_spec.js | 3 - .../components/commit_changes_modal_spec.js | 79 ++++-- .../components/delete_blob_modal_spec.js | 104 +++++++ .../header_area/breadcrumbs_spec.js | 15 +- .../components/upload_blob_modal_spec.js | 268 ++++++------------ spec/frontend/repository/mock_data.js | 1 + spec/helpers/tree_helper_spec.rb | 33 +++ spec/presenters/project_presenter_spec.rb | 1 + .../project_upload_files_shared_examples.rb | 24 +- 26 files changed, 525 insertions(+), 418 deletions(-) create mode 100644 app/assets/javascripts/repository/components/delete_blob_modal.vue create mode 100644 spec/frontend/repository/components/delete_blob_modal_spec.js diff --git a/app/assets/javascripts/projects/details/upload_button.vue b/app/assets/javascripts/projects/details/upload_button.vue index d2158c6d9d4b..7d037bd6fce2 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" /> </span> </template> diff --git a/app/assets/javascripts/projects/upload_file.js b/app/assets/javascripts/projects/upload_file.js index a1b19abee6be..794bf1dfd653 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 0a9df26306cc..cea445bc5cfb 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 @@ <script> import { GlButtonGroup, GlButton } from '@gitlab/ui'; import { uniqueId } from 'lodash'; -import axios from '~/lib/utils/axios_utils'; -import { visitUrl } from '~/lib/utils/url_utility'; import { sprintf, __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getRefMixin from '../mixins/get_ref'; -import CommitChangesModal from './commit_changes_modal.vue'; +import DeleteBlobModal from './delete_blob_modal.vue'; import UploadBlobModal from './upload_blob_modal.vue'; const REPLACE_BLOB_MODAL_ID = 'modal-replace-blob'; @@ -14,14 +12,13 @@ const REPLACE_BLOB_MODAL_ID = 'modal-replace-blob'; export default { i18n: { replace: __('Replace'), - replacePrimaryBtnText: __('Replace file'), delete: __('Delete'), }, components: { GlButtonGroup, GlButton, UploadBlobModal, - CommitChangesModal, + DeleteBlobModal, LockFileButton: () => import('ee_component/repository/components/lock_file_button.vue'), }, mixins: [getRefMixin, glFeatureFlagMixin()], @@ -85,12 +82,12 @@ export default { }, }, computed: { - replaceModalTitle() { - return sprintf(__('Replace %{name}'), { name: this.name }); - }, deleteModalId() { return uniqueId('delete-modal'); }, + replaceCommitMessage() { + return sprintf(__('Replace %{name}'), { name: this.name }); + }, deleteModalCommitMessage() { return sprintf(__('Delete %{name}'), { name: this.name }); }, @@ -107,15 +104,6 @@ export default { this.$refs[modalId].show(); }, - handleBlobDelete(formData) { - return axios({ - method: 'post', - url: this.deletePath, - data: formData, - }).then((response) => { - visitUrl(response.data.filePath); - }); - }, }, replaceBlobModalId: REPLACE_BLOB_MODAL_ID, }; @@ -143,17 +131,17 @@ export default { <upload-blob-modal :ref="$options.replaceBlobModalId" :modal-id="$options.replaceBlobModalId" - :modal-title="replaceModalTitle" - :commit-message="replaceModalTitle" + :commit-message="replaceCommitMessage" :target-branch="targetBranch || ref" :original-branch="originalBranch || ref" :can-push-code="canPushCode" + :can-push-to-branch="canPushToBranch" :path="path" :replace-path="replacePath" - :primary-btn-text="$options.i18n.replacePrimaryBtnText" /> - <commit-changes-modal + <delete-blob-modal :ref="deleteModalId" + :delete-path="deletePath" :modal-id="deleteModalId" :commit-message="deleteModalCommitMessage" :target-branch="targetBranch || ref" @@ -162,11 +150,6 @@ export default { :can-push-to-branch="canPushToBranch" :empty-repo="emptyRepo" :is-using-lfs="isUsingLfs" - :handle-form-submit="handleBlobDelete" - > - <template #form-fields> - <input type="hidden" name="_method" value="delete" /> - </template> - </commit-changes-modal> + /> </div> </template> diff --git a/app/assets/javascripts/repository/components/commit_changes_modal.vue b/app/assets/javascripts/repository/components/commit_changes_modal.vue index 90535115a0e7..074cfe1c7668 100644 --- a/app/assets/javascripts/repository/components/commit_changes_modal.vue +++ b/app/assets/javascripts/repository/components/commit_changes_modal.vue @@ -48,6 +48,9 @@ export default { COMMIT_IN_BRANCH_MESSAGE: __( 'Your changes can be committed to %{branchName} because a merge request is open.', ), + COMMIT_IN_DEFAULT_BRANCH: __( + 'GitLab will create a default branch, %{branchName}, and commit your changes.', + ), COMMIT_LABEL, COMMIT_MESSAGE_HINT: __( 'Try to keep the first line under 52 characters and the others under 72.', @@ -95,7 +98,8 @@ export default { }, emptyRepo: { type: Boolean, - required: true, + required: false, + default: false, }, isUsingLfs: { type: Boolean, @@ -107,9 +111,15 @@ export default { required: false, default: false, }, - handleFormSubmit: { - type: Function, - required: true, + valid: { + type: Boolean, + required: false, + default: true, + }, + loading: { + type: Boolean, + required: false, + default: false, }, }, data() { @@ -130,7 +140,6 @@ export default { }; return { lfsWarningDismissed: false, - loading: false, createNewBranch: false, createNewMr: true, form, @@ -143,7 +152,7 @@ export default { attributes: { variant: 'confirm', loading: this.loading, - disabled: this.loading || !this.form.state, + disabled: this.loading || !this.form.state || !this.valid, }, }; @@ -235,17 +244,12 @@ export default { return; } - this.loading = true; this.form.showValidation = false; const form = this.$refs.form.$el; const formData = new FormData(form); - try { - this.handleFormSubmit(formData); - } finally { - this.loading = false; - } + this.$emit('submit-form', formData); }, }, deleteLfsHelpPath: helpPagePath('topics/git/lfs', { @@ -273,7 +277,6 @@ export default { </template> </gl-sprintf> </p> - <p> <gl-sprintf :message="$options.i18n.LFS_WARNING_SECONDARY_CONTENT"> <template #link="{ content }"> @@ -286,33 +289,40 @@ export default { <gl-form ref="form" novalidate> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> <slot name="form-fields"></slot> + <gl-form-group + :label="$options.i18n.COMMIT_LABEL" + label-for="commit_message" + :invalid-feedback="form.fields['commit_message'].feedback" + > + <gl-form-textarea + id="commit_message" + ref="message" + v-model="form.fields['commit_message'].value" + v-validation:[form.showValidation] + name="commit_message" + no-resize + data-testid="commit-message-field" + :state="form.fields['commit_message'].state" + :disabled="loading" + required + /> + <p v-if="showHint" class="form-text gl-text-subtle" data-testid="hint"> + {{ $options.i18n.COMMIT_MESSAGE_HINT }} + </p> + </gl-form-group> <template v-if="emptyRepo"> <input type="hidden" name="branch_name" :value="originalBranch" class="js-branch-name" /> + <gl-alert v-if="emptyRepo" :dismissible="false" class="gl-my-3"> + <gl-sprintf :message="$options.i18n.COMMIT_IN_DEFAULT_BRANCH"> + <template #branchName + ><strong>{{ originalBranch }}</strong> + </template> + </gl-sprintf> + </gl-alert> </template> <template v-else> <input type="hidden" name="original_branch" :value="originalBranch" /> <input v-if="createNewMr" type="hidden" name="create_merge_request" value="1" /> - <gl-form-group - :label="$options.i18n.COMMIT_LABEL" - label-for="commit_message" - :invalid-feedback="form.fields['commit_message'].feedback" - > - <gl-form-textarea - id="commit_message" - ref="message" - v-model="form.fields['commit_message'].value" - v-validation:[form.showValidation] - name="commit_message" - no-resize - data-testid="commit-message-field" - :state="form.fields['commit_message'].state" - :disabled="loading" - required - /> - <p v-if="showHint" class="form-text gl-text-subtle" data-testid="hint"> - {{ $options.i18n.COMMIT_MESSAGE_HINT }} - </p> - </gl-form-group> <gl-form-group v-if="canPushCode" :label="$options.i18n.BRANCH" @@ -324,14 +334,14 @@ export default { name="branch_selection" :label="$options.i18n.BRANCH" > - <gl-form-radio :value="false"> + <gl-form-radio :value="false" :disabled="loading"> <gl-sprintf :message="$options.i18n.CURRENT_BRANCH_LABEL"> <template #branchName ><code>{{ originalBranch }}</code> </template> </gl-sprintf> </gl-form-radio> - <gl-form-radio :value="true"> + <gl-form-radio :value="true" :disabled="loading"> {{ $options.i18n.NEW_BRANCH_LABEl }} </gl-form-radio> </gl-form-radio-group> @@ -353,7 +363,6 @@ export default { </gl-form-checkbox> </div> </template> - <template v-else> <label for="branchNameInput"> {{ $options.i18n.NEW_BRANCH_LABEl }} diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue new file mode 100644 index 000000000000..7d106d7e3841 --- /dev/null +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -0,0 +1,63 @@ +<script> +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { logError } from '~/lib/logger'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import CommitChangesModal from './commit_changes_modal.vue'; + +export default { + components: { + CommitChangesModal, + }, + props: { + deletePath: { + type: String, + required: true, + }, + }, + data() { + return { + loading: false, + }; + }, + methods: { + show() { + this.$refs.modal.show(); + }, + handleBlobDelete(formData) { + this.loading = true; + + return axios({ + method: 'post', + url: this.deletePath, + data: formData, + }) + .then((response) => { + visitUrl(response.data.filePath); + }) + .catch((e) => { + // eslint-disable-next-line @gitlab/require-i18n-strings + logError('Failed to delete file. See exception details for more information.', e); + createAlert({ message: __('Failed to delete file! Please try again.'), error: e }); + }) + .finally(() => { + this.loading = false; + }); + }, + }, +}; +</script> +<template> + <commit-changes-modal + ref="modal" + :loading="loading" + v-bind="$attrs" + v-on="$listeners" + @submit-form="handleBlobDelete" + > + <template #form-fields> + <input type="hidden" name="_method" value="delete" /> + </template> + </commit-changes-modal> +</template> diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue index f62eea9583de..0d0898955275 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 1f1c2f24c7ac..2675cbc458ae 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" /> <new-directory-modal diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index d4b8fdcf584b..01089652eaea 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -1,68 +1,28 @@ <script> -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: [], }; </script> <template> - <gl-form> - <gl-modal - :ref="modalId" - :modal-id="modalId" - :title="modalTitle" - :action-primary="primaryOptions" - :action-cancel="cancelOptions" - @primary.prevent="submitForm" - > + <commit-changes-modal + :ref="modalId" + :modal-id="modalId" + :commit-message="commitMessage" + :target-branch="targetBranch" + :original-branch="originalBranch" + :can-push-code="canPushCode" + :can-push-to-branch="canPushToBranch" + :valid="isValid" + :loading="loading" + :empty-repo="emptyRepo" + data-testid="upload-blob-modal" + @submit-form="submitForm" + > + <template #body> <upload-dropzone class="gl-mb-6 gl-h-26" single-file-selection @@ -240,22 +187,6 @@ export default { > </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" no-resize /> - </gl-form-group> - <gl-form-group - v-if="canPushCode" - :label="$options.i18n.TARGET_BRANCH_LABEL" - label-for="branch_name" - > - <gl-form-input id="branch_name" v-model="target" :disabled="loading" name="branch_name" /> - </gl-form-group> - <gl-form-checkbox v-if="showCreateNewMrToggle" v-model="createNewMr" :disabled="loading"> - {{ $options.i18n.TOGGLE_CREATE_MR_LABEL }} - </gl-form-checkbox> - <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> + </commit-changes-modal> </template> diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 33127a165f8b..b3a368bd6e75 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 9d4082b7f255..58f0daf97bac 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 52b9872b7935..f46b5852adfe 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 59a83133f90f..a89cb2e1071e 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 8740d7dcc125..56131b1b4ab5 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 07df88836600..a16ae4fa902d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -23130,6 +23130,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 "" @@ -25063,6 +25066,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 "" @@ -46196,9 +46202,6 @@ msgstr "" msgid "Replace all labels" msgstr "" -msgid "Replace file" -msgstr "" - msgid "Replaced all labels with %{label_references} %{label_text}." msgstr "" @@ -59619,9 +59622,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 14c0ef9ed390..6f8732c041b2 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 148ccd9e3990..926efbd4c2cb 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 GIT binary patch literal 18810 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 literal 420 zcmY!laB<T$)HCK%eZPM%e#B*>V4#qnl*MIZqoD7TnwMUZp<oIW3R2K_%giZBEdtUx zi6yBnsmb{%sS0*>T*W0tsfoE<6^yA6llg#@0S|<2ceEGCaFBxVfFxH^B9Kf0Vn)MI zAQ=Y40ze#Z0VIGZ4~SE1A#Cn6kqD5Qgcvs<nPLPHg^=t(Dp*xV6i5Ze0txE~XCP?_ z64nl^1d<SxZI@_dXKd(X0_4hC!-Z2c6@5W&@T~_Dx<Kp%kpkHRL?8$G0daJh3@b#W z4nj$|0%=nS8%V~nv2p;ZsCZ_G5cj!7@<3KT5a;Lt39i(<6lmxGS)f1#g(QeJG&KcD Jf|y27ApnQC<e2~f diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js index 89f764372cb7..d9568dd61ef6 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 5b98fd51acbd..941ae8307c7d 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 000000000000..308c3d1674d6 --- /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 f9653521b508..533240518831 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 c43afaac1aca..d8c293deed20 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 272947fe54d6..e7ab1eb607b2 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 61d1c8039ff1..0acf6a88198a 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 652509f62dd0..9ce360fdc800 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 806ffdad2f14..63d77e9e0a02 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') -- GitLab