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