From 28bc02a5f77ba51c36bcd6e385c70cc2deba5a44 Mon Sep 17 00:00:00 2001
From: Jacques Erasmus <jerasmus@gitlab.com>
Date: Wed, 29 May 2024 09:09:21 +0000
Subject: [PATCH] Remove blobs backend/frontend integration + docs

Add GraphQL mutation for removing blobs + docs
---
 .../mutations/remove_blobs.mutation.graphql   |   5 +
 .../mount_repository_maintenance.js           |   8 ++
 .../repository/maintenance/remove_blobs.vue   | 112 ++++++++++++++++--
 .../maintenance/_remove_blobs.html.haml       |   4 +-
 .../projects/maintenance/_show.html.haml      |   4 +-
 locale/gitlab.pot                             |  19 ++-
 .../repository/maintenance/mock_data.js       |  21 ++++
 .../maintenance/remove_blobs_spec.js          | 102 ++++++++++++++--
 8 files changed, 244 insertions(+), 31 deletions(-)
 create mode 100644 app/assets/javascripts/projects/settings/repository/maintenance/graphql/mutations/remove_blobs.mutation.graphql
 create mode 100644 spec/frontend/projects/settings/repository/maintenance/mock_data.js

diff --git a/app/assets/javascripts/projects/settings/repository/maintenance/graphql/mutations/remove_blobs.mutation.graphql b/app/assets/javascripts/projects/settings/repository/maintenance/graphql/mutations/remove_blobs.mutation.graphql
new file mode 100644
index 0000000000000..fa54b590efce5
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/repository/maintenance/graphql/mutations/remove_blobs.mutation.graphql
@@ -0,0 +1,5 @@
+mutation removeBlobs($projectPath: ID!, $blobOids: [String!]!) {
+  projectBlobsRemove(input: { projectPath: $projectPath, blobOids: $blobOids }) {
+    errors
+  }
+}
diff --git a/app/assets/javascripts/projects/settings/repository/maintenance/mount_repository_maintenance.js b/app/assets/javascripts/projects/settings/repository/maintenance/mount_repository_maintenance.js
index 006c02e3c4426..409d6b7d73741 100644
--- a/app/assets/javascripts/projects/settings/repository/maintenance/mount_repository_maintenance.js
+++ b/app/assets/javascripts/projects/settings/repository/maintenance/mount_repository_maintenance.js
@@ -1,12 +1,20 @@
 import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
 import RemoveBlobs from '~/projects/settings/repository/maintenance/remove_blobs.vue';
 
 export default function mountRepositoryMaintenance() {
   const removeBlobsEl = document.querySelector('.js-maintenance-remove-blobs');
   if (!removeBlobsEl) return false;
 
+  const { projectPath, housekeepingPath } = removeBlobsEl.dataset;
+
   return new Vue({
     el: removeBlobsEl,
+    apolloProvider: new VueApollo({
+      defaultClient: createDefaultClient(),
+    }),
+    provide: { projectPath, housekeepingPath },
     render(createElement) {
       return createElement(RemoveBlobs);
     },
diff --git a/app/assets/javascripts/projects/settings/repository/maintenance/remove_blobs.vue b/app/assets/javascripts/projects/settings/repository/maintenance/remove_blobs.vue
index 9a372bfe891c1..60076bf2cfa71 100644
--- a/app/assets/javascripts/projects/settings/repository/maintenance/remove_blobs.vue
+++ b/app/assets/javascripts/projects/settings/repository/maintenance/remove_blobs.vue
@@ -1,9 +1,14 @@
 <script>
-import { GlButton, GlDrawer, GlLink, GlFormTextarea, GlModal } from '@gitlab/ui';
+import { GlButton, GlDrawer, GlLink, GlFormTextarea, GlModal, GlFormInput } from '@gitlab/ui';
+import { visitUrl } from '~/lib/utils/url_utility';
 import { helpPagePath } from '~/helpers/help_page_helper';
 import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
 import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
 import { s__ } from '~/locale';
+import { createAlert, VARIANT_WARNING } from '~/alert';
+import removeBlobsMutation from './graphql/mutations/remove_blobs.mutation.graphql';
+
+export const BLOB_OID_LENGTH = 40;
 
 const i18n = {
   removeBlobs: s__('ProjectMaintenance|Remove blobs'),
@@ -18,22 +23,52 @@ const i18n = {
   modalContent: s__(
     'ProjectMaintenance|Removing blobs by ID cannot be undone. Are you sure you want to continue?',
   ),
+  modalConfirm: s__('ProjectMaintenance|Enter the following to confirm:'),
+  removeBlobsError: s__('ProjectMaintenance|Something went wrong while removing blobs.'),
+  successAlertTitle: s__('ProjectMaintenance|Blobs removed'),
+  successAlertContent: s__(
+    'ProjectMaintenance|Run housekeeping to remove old versions from repository.',
+  ),
+  successAlertButtonText: s__('ProjectMaintenance|Go to housekeeping'),
 };
 
 export default {
   i18n,
   DRAWER_Z_INDEX,
-  removeBlobsHelpLink: helpPagePath('/user/project/repository/reducing_the_repo_size_using_git'),
+  removeBlobsHelpLink: helpPagePath('/user/project/repository/reducing_the_repo_size_using_git', {
+    anchor: 'repository-cleanup',
+  }),
   modalCancel: { text: i18n.modalCancelText },
-  modalPrimary: { text: i18n.modalPrimaryText, attributes: { variant: 'danger' } },
-  components: { GlButton, GlDrawer, GlLink, GlFormTextarea, GlModal },
+  components: { GlButton, GlDrawer, GlLink, GlFormTextarea, GlModal, GlFormInput },
+  inject: { projectPath: { default: '' }, housekeepingPath: { default: '' } },
   data() {
-    return { isDrawerOpen: false, blobIDs: null, showConfirmationModal: false };
+    return {
+      isDrawerOpen: false,
+      blobIDs: null,
+      showConfirmationModal: false,
+      confirmInput: null,
+      isLoading: false,
+    };
   },
   computed: {
     getDrawerHeaderHeight() {
       return getContentWrapperHeight();
     },
+    blobOids() {
+      return this.blobIDs?.split('\n') || [];
+    },
+    isValid() {
+      return this.blobOids.length && this.blobOids.every((s) => s.length >= BLOB_OID_LENGTH);
+    },
+    modalPrimary() {
+      return {
+        text: i18n.modalPrimaryText,
+        attributes: { variant: 'danger', disabled: !this.isConfirmEnabled },
+      };
+    },
+    isConfirmEnabled() {
+      return this.confirmInput === this.projectPath;
+    },
   },
   methods: {
     openDrawer() {
@@ -47,8 +82,47 @@ export default {
       this.showConfirmationModal = true;
     },
     removeBlobsConfirm() {
-      // TODO (follow-up MR): submit mutation + show alert/toast...
-      this.closeDrawer();
+      this.isLoading = true;
+      this.$apollo
+        .mutate({
+          mutation: removeBlobsMutation,
+          variables: {
+            blobOids: this.blobOids,
+            projectPath: this.projectPath,
+          },
+        })
+        .then(({ data: { projectBlobsRemove: { errors } = {} } = {} }) => {
+          this.isLoading = false;
+
+          if (errors?.length) {
+            this.handleError();
+            return;
+          }
+
+          this.closeDrawer();
+          this.generateSuccessAlert();
+        })
+        .catch(() => {
+          this.isLoading = false;
+          this.handleError();
+        });
+    },
+    generateSuccessAlert() {
+      createAlert({
+        title: i18n.successAlertTitle,
+        message: i18n.successAlertContent,
+        variant: VARIANT_WARNING,
+        primaryButton: {
+          text: i18n.successAlertButtonText,
+          clickHandler: () => this.navigateToHousekeeping(),
+        },
+      });
+    },
+    navigateToHousekeeping() {
+      visitUrl(this.housekeepingPath);
+    },
+    handleError() {
+      createAlert({ message: i18n.removeBlobsError, captureError: true });
     },
   },
 };
@@ -56,9 +130,14 @@ export default {
 
 <template>
   <div>
-    <gl-button class="gl-mb-6" data-testid="drawer-trigger" @click="openDrawer">{{
-      $options.i18n.removeBlobs
-    }}</gl-button>
+    <gl-button
+      class="gl-mb-6"
+      category="secondary"
+      variant="danger"
+      data-testid="drawer-trigger"
+      @click="openDrawer"
+      >{{ $options.i18n.removeBlobs }}</gl-button
+    >
 
     <gl-drawer
       :header-height="getDrawerHeaderHeight"
@@ -82,6 +161,7 @@ export default {
           id="blobs"
           v-model.trim="blobIDs"
           class="!gl-font-monospace gl-mb-3"
+          :disabled="isLoading"
           autofocus
         />
 
@@ -90,7 +170,8 @@ export default {
         <gl-button
           data-testid="remove-blobs"
           variant="danger"
-          :disabled="!blobIDs"
+          :disabled="!isValid"
+          :loading="isLoading"
           @click="removeBlobs"
           >{{ $options.i18n.removeBlobs }}</gl-button
         >
@@ -102,10 +183,15 @@ export default {
       :title="$options.i18n.removeBlobs"
       modal-id="remove-blobs-confirmation-modal"
       :action-cancel="$options.modalCancel"
-      :action-primary="$options.modalPrimary"
+      :action-primary="modalPrimary"
       @primary="removeBlobsConfirm"
     >
-      {{ $options.i18n.modalContent }}
+      <p>{{ $options.i18n.modalContent }}</p>
+
+      <p class="gl-mb-0">{{ $options.i18n.modalConfirm }}</p>
+      <code>{{ projectPath }}</code>
+
+      <gl-form-input v-model="confirmInput" class="gl-mt-2 gl-max-w-34" />
     </gl-modal>
   </div>
 </template>
diff --git a/app/views/projects/maintenance/_remove_blobs.html.haml b/app/views/projects/maintenance/_remove_blobs.html.haml
index c57944b106e06..367e275efb644 100644
--- a/app/views/projects/maintenance/_remove_blobs.html.haml
+++ b/app/views/projects/maintenance/_remove_blobs.html.haml
@@ -1,8 +1,8 @@
 %h5.gl-heading-4= s_('ProjectMaintenance|Remove blobs')
 %p.gl-text-secondary
   = s_('ProjectMaintenance|Provide a list of blob object IDs to be removed.')
-  = link_to s_('ProjectMaintenance|How do I get a list of object IDs?'), help_page_path('user/project/repository/reducing_the_repo_size_using_git')
+  = link_to s_('ProjectMaintenance|How do I get a list of object IDs?'), help_page_path('user/project/repository/reducing_the_repo_size_using_git', anchor: 'repository-cleanup')
 %p.gl-text-secondary
   = s_('ProjectMaintenance|Housekeeping will need to be triggered manually afterwards to remove old versions of the file.')
 
-.js-maintenance-remove-blobs
+.js-maintenance-remove-blobs{ data: { project_path: @project.full_path, housekeeping_path: edit_project_path(@project, anchor: 'js-project-advanced-settings') } }
diff --git a/app/views/projects/maintenance/_show.html.haml b/app/views/projects/maintenance/_show.html.haml
index a1a148c4ffd3e..b73cd5cfdc9fe 100644
--- a/app/views/projects/maintenance/_show.html.haml
+++ b/app/views/projects/maintenance/_show.html.haml
@@ -8,12 +8,12 @@
     %p.gl-text-secondary.gl-pb-3
       = s_('ProjectMaintenance|Manage repository storage and cleanup.')
   .settings-content
-    = render Pajamas::AlertComponent.new(variant: :default, alert_options: { class: 'gl-mb-5' }, dismissible: false) do |c|
+    = render Pajamas::AlertComponent.new(variant: :danger, alert_options: { class: 'gl-mb-5' }, dismissible: false) do |c|
       - c.with_body do
         - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
         - docs_link_start = link_start % { url: help_page_path('user/project/settings/import_export') }
         - link_end = '</a>'.html_safe
-        = s_('ProjectMaintenance| To ensure that a full backup is available in case changes need to be restored, you should make an %{docs_link_start}export of the project%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end }
+        = s_('ProjectMaintenance|To ensure that a full backup is available in case changes need to be restored, you should make an %{docs_link_start}export of the project%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end }
 
     - if current_user.can?(:owner_access, @project) && Feature.enabled?(:rewrite_history_ui, @project)
       = render "projects/maintenance/remove_blobs"
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index e6276fbc7519b..957982cd1ea7f 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -40515,10 +40515,10 @@ msgstr ""
 msgid "ProjectList|Yours"
 msgstr ""
 
-msgid "ProjectMaintenance| To ensure that a full backup is available in case changes need to be restored, you should make an %{docs_link_start}export of the project%{docs_link_end}."
+msgid "ProjectMaintenance|Blob IDs to remove"
 msgstr ""
 
-msgid "ProjectMaintenance|Blob IDs to remove"
+msgid "ProjectMaintenance|Blobs removed"
 msgstr ""
 
 msgid "ProjectMaintenance|Cancel"
@@ -40530,6 +40530,12 @@ msgstr ""
 msgid "ProjectMaintenance|Enter multiple entries on separate lines."
 msgstr ""
 
+msgid "ProjectMaintenance|Enter the following to confirm:"
+msgstr ""
+
+msgid "ProjectMaintenance|Go to housekeeping"
+msgstr ""
+
 msgid "ProjectMaintenance|Housekeeping will need to be triggered manually afterwards to remove old versions of the file."
 msgstr ""
 
@@ -40548,6 +40554,15 @@ msgstr ""
 msgid "ProjectMaintenance|Removing blobs by ID cannot be undone. Are you sure you want to continue?"
 msgstr ""
 
+msgid "ProjectMaintenance|Run housekeeping to remove old versions from repository."
+msgstr ""
+
+msgid "ProjectMaintenance|Something went wrong while removing blobs."
+msgstr ""
+
+msgid "ProjectMaintenance|To ensure that a full backup is available in case changes need to be restored, you should make an %{docs_link_start}export of the project%{docs_link_end}."
+msgstr ""
+
 msgid "ProjectMaintenance|Yes, remove blobs"
 msgstr ""
 
diff --git a/spec/frontend/projects/settings/repository/maintenance/mock_data.js b/spec/frontend/projects/settings/repository/maintenance/mock_data.js
new file mode 100644
index 0000000000000..47363caa797ab
--- /dev/null
+++ b/spec/frontend/projects/settings/repository/maintenance/mock_data.js
@@ -0,0 +1,21 @@
+export const TEST_HEADER_HEIGHT = '123px';
+export const TEST_PROJECT_PATH = 'project/path';
+export const TEST_BLOB_ID = '96803162678c7c1ff1e130424b95f28f84ec99cf';
+
+export const REMOVE_MUTATION_SUCCESS = {
+  data: {
+    projectBlobsRemove: {
+      errors: [],
+      __typename: 'projectBlobsRemovePayload',
+    },
+  },
+};
+
+export const REMOVE_MUTATION_FAIL = {
+  data: {
+    projectBlobsRemove: {
+      errors: ['Some error'],
+      __typename: 'projectBlobsRemovePayload',
+    },
+  },
+};
diff --git a/spec/frontend/projects/settings/repository/maintenance/remove_blobs_spec.js b/spec/frontend/projects/settings/repository/maintenance/remove_blobs_spec.js
index 8385efb1631db..cd11167b02c05 100644
--- a/spec/frontend/projects/settings/repository/maintenance/remove_blobs_spec.js
+++ b/spec/frontend/projects/settings/repository/maintenance/remove_blobs_spec.js
@@ -1,20 +1,44 @@
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
 import { GlDrawer, GlFormTextarea, GlModal } from '@gitlab/ui';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
 import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
 import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
+import { createAlert, VARIANT_WARNING } from '~/alert';
 import RemoveBlobs from '~/projects/settings/repository/maintenance/remove_blobs.vue';
+import removeBlobsMutation from '~/projects/settings/repository/maintenance/graphql/mutations/remove_blobs.mutation.graphql';
+import {
+  TEST_HEADER_HEIGHT,
+  TEST_PROJECT_PATH,
+  TEST_BLOB_ID,
+  REMOVE_MUTATION_SUCCESS,
+  REMOVE_MUTATION_FAIL,
+} from './mock_data';
 
-jest.mock('~/lib/utils/dom_utils');
+Vue.use(VueApollo);
 
-const TEST_HEADER_HEIGHT = '123px';
+jest.mock('~/lib/utils/dom_utils');
+jest.mock('~/alert');
 
 describe('Remove blobs', () => {
   let wrapper;
+  let mutationMock;
 
-  const createComponent = () => {
+  const createMockApolloProvider = (resolverMock) => {
+    return createMockApollo([[removeBlobsMutation, resolverMock]]);
+  };
+
+  const createComponent = (mutationResponse = REMOVE_MUTATION_SUCCESS) => {
+    mutationMock = jest.fn().mockResolvedValue(mutationResponse);
     getContentWrapperHeight.mockReturnValue(TEST_HEADER_HEIGHT);
-    wrapper = shallowMountExtended(RemoveBlobs);
+    wrapper = shallowMountExtended(RemoveBlobs, {
+      apolloProvider: createMockApolloProvider(mutationMock),
+      provide: {
+        projectPath: TEST_PROJECT_PATH,
+      },
+    });
   };
 
   const findDrawerTrigger = () => wrapper.findByTestId('drawer-trigger');
@@ -52,7 +76,7 @@ describe('Remove blobs', () => {
       });
 
       expect(findModal().text()).toBe(
-        'Removing blobs by ID cannot be undone. Are you sure you want to continue?',
+        'Removing blobs by ID cannot be undone. Are you sure you want to continue? Enter the following to confirm: project/path',
       );
     });
   });
@@ -73,12 +97,19 @@ describe('Remove blobs', () => {
     });
 
     describe('adding blob IDs', () => {
-      beforeEach(() => findTextarea().vm.$emit('input', '1234'));
+      beforeEach(() => findTextarea().vm.$emit('input', TEST_BLOB_ID));
 
-      it('enables the primary action when blob IDs are added', () => {
+      it('enables the primary action when valid blob IDs are added', () => {
         expect(removeBlobsButton().props('disabled')).toBe(false);
       });
 
+      it('disables the primary action when invalid blob IDs are added', async () => {
+        findTextarea().vm.$emit('input', 'invalid');
+        await nextTick();
+
+        expect(removeBlobsButton().props('disabled')).toBe(true);
+      });
+
       describe('confirmation modal', () => {
         beforeEach(() => removeBlobsButton().vm.$emit('click'));
 
@@ -86,11 +117,58 @@ describe('Remove blobs', () => {
           expect(findModal().props('visible')).toBe(true);
         });
 
-        it('closes the drawer when removal is confirmed', async () => {
-          findModal().vm.$emit('primary');
-          await nextTick();
+        describe('removal confirmed (success)', () => {
+          beforeEach(() => findModal().vm.$emit('primary'));
+
+          it('disables user input while loading', () => {
+            expect(findTextarea().attributes('disabled')).toBe('true');
+            expect(removeBlobsButton().props('loading')).toBe(true);
+          });
+
+          it('calls the remove mutation', () => {
+            expect(mutationMock).toHaveBeenCalledWith({
+              blobOids: [TEST_BLOB_ID],
+              projectPath: TEST_PROJECT_PATH,
+            });
+          });
+
+          it('closes the drawer when removal is confirmed', async () => {
+            await waitForPromises();
+
+            expect(findDrawer().props('open')).toBe(false);
+          });
+
+          it('generates a housekeeping alert', async () => {
+            await waitForPromises();
+
+            expect(createAlert).toHaveBeenCalledWith({
+              message: 'Run housekeeping to remove old versions from repository.',
+              primaryButton: { clickHandler: expect.any(Function), text: 'Go to housekeeping' },
+              title: 'Blobs removed',
+              variant: VARIANT_WARNING,
+            });
+          });
+        });
 
-          expect(findDrawer().props('open')).toBe(false);
+        describe('removal confirmed (fail)', () => {
+          beforeEach(async () => {
+            createComponent(REMOVE_MUTATION_FAIL);
+
+            // Simulates the workflow (open drawer → add blobId → click remove → confirm remove)
+            findDrawerTrigger().vm.$emit('click');
+            findTextarea().vm.$emit('input', TEST_BLOB_ID);
+            removeBlobsButton().vm.$emit('click');
+            findModal().vm.$emit('primary');
+
+            await waitForPromises();
+          });
+
+          it('generates an error alert upon failed mutation', () => {
+            expect(createAlert).toHaveBeenCalledWith({
+              message: 'Something went wrong while removing blobs.',
+              captureError: true,
+            });
+          });
         });
       });
     });
-- 
GitLab