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