diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue index 01af0be20db50cfb002234cf3b9fc05c8afdc84f..152ce8474f24659c05cce9c6c7e5b6556c25932a 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue @@ -1,7 +1,17 @@ <script> -import { GlButton, GlCard, GlTable, GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui'; +import { + GlAlert, + GlButton, + GlCard, + GlTable, + GlLoadingIcon, + GlKeysetPagination, + GlModal, + GlModalDirective, +} from '@gitlab/ui'; import packagesProtectionRuleQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql'; import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils'; +import deletePackagesProtectionRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/delete_packages_protection_rule.mutation.graphql'; import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; import PackagesProtectionRuleForm from '~/packages_and_registries/settings/project/components/packages_protection_rule_form.vue'; import { s__, __ } from '~/locale'; @@ -19,17 +29,28 @@ export default { SettingsBlock, GlButton, GlCard, + GlAlert, GlTable, GlLoadingIcon, PackagesProtectionRuleForm, GlKeysetPagination, + GlModal, + }, + directives: { + GlModal: GlModalDirective, }, inject: ['projectPath'], i18n: { - settingBlockTitle: s__('PackageRegistry|Protected packages'), + settingBlockTitle: s__('PackageRegistry|Package protection rules'), settingBlockDescription: s__( 'PackageRegistry|When a package is protected then only certain user roles are able to update and delete the protected package. This helps to avoid tampering with the package.', ), + protectionRuleDeletionConfirmModal: { + title: s__('PackageRegistry|Are you sure you want to delete the package protection rule?'), + description: s__( + 'PackageRegistry|Users with at least the Developer role for this project will be able to publish, edit, and delete packages.', + ), + }, }, data() { return { @@ -38,12 +59,18 @@ export default { protectionRuleFormVisibility: false, packageProtectionRulesQueryPayload: { nodes: [], pageInfo: {} }, packageProtectionRulesQueryPaginationParams: { first: PAGINATION_DEFAULT_PER_PAGE }, + deleteInProgress: false, + deleteItem: null, + alertErrorMessage: '', + protectionRuleDeletionInProgress: false, + protectionRuleDeletionItem: null, }; }, computed: { tableItems() { return this.packageProtectionRulesQueryResult.map((packagesProtectionRule) => { return { + id: packagesProtectionRule.id, col_1_package_name_pattern: packagesProtectionRule.packageNamePattern, col_2_package_type: getPackageTypeLabel(packagesProtectionRule.packageType), col_3_push_protected_up_to_access_level: @@ -65,6 +92,19 @@ export default { isAddProtectionRuleButtonDisabled() { return this.protectionRuleFormVisibility; }, + modalActionPrimary() { + return { + text: __('Delete'), + attributes: { + variant: 'danger', + }, + }; + }, + modalActionCancel() { + return { + text: __('Cancel'), + }; + }, }, apollo: { packageProtectionRulesQueryPayload: { @@ -106,7 +146,44 @@ export default { last: PAGINATION_DEFAULT_PER_PAGE, }; }, + isButtonDisabled(item) { + return this.protectionRuleDeletionItem === item && this.protectionRuleDeletionInProgress; + }, + showProtectionRuleDeletionConfirmModal(protectionRule) { + this.protectionRuleDeletionItem = protectionRule; + }, + deleteProtectionRule(protectionRule) { + this.clearAlertMessage(); + + this.protectionRuleDeletionInProgress = true; + + return this.$apollo + .mutate({ + mutation: deletePackagesProtectionRuleMutation, + variables: { input: { id: protectionRule.id } }, + }) + .then(({ data }) => { + const [errorMessage] = data?.deletePackagesProtectionRule?.errors ?? []; + if (errorMessage) { + this.alertErrorMessage = errorMessage; + return; + } + this.refetchProtectionRules(); + this.$toast.show(s__('PackageRegistry|Package protection rule deleted.')); + }) + .catch((e) => { + this.alertErrorMessage = e.message; + }) + .finally(() => { + this.protectionRuleDeletionItem = null; + this.protectionRuleDeletionInProgress = false; + }); + }, + clearAlertMessage() { + this.alertErrorMessage = ''; + }, }, + table: {}, fields: [ { key: 'col_1_package_name_pattern', @@ -117,7 +194,14 @@ export default { key: 'col_3_push_protected_up_to_access_level', label: s__('PackageRegistry|Push protected up to access level'), }, + { + key: 'col_4_actions', + label: '', + thClass: 'gl-display-none', + tdClass: 'gl-w-15p', + }, ], + modal: { id: 'delete-package-protection-rule-confirmation-modal' }, }; </script> @@ -157,18 +241,38 @@ export default { @submit="refetchProtectionRules" /> + <gl-alert + v-if="alertErrorMessage" + class="gl-mb-5" + variant="danger" + @dismiss="clearAlertMessage" + > + {{ alertErrorMessage }} + </gl-alert> + <gl-table :items="tableItems" :fields="$options.fields" show-empty stacked="md" - class="gl-mb-5!" :aria-label="$options.i18n.settingBlockTitle" :busy="isLoadingPackageProtectionRules" > <template #table-busy> <gl-loading-icon size="sm" class="gl-my-5" /> </template> + + <template #cell(col_4_actions)="{ item }"> + <gl-button + v-gl-modal="$options.modal.id" + category="secondary" + variant="danger" + size="small" + :disabled="isButtonDisabled(item)" + @click="showProtectionRuleDeletionConfirmModal(item)" + >{{ __('Delete') }}</gl-button + > + </template> </gl-table> <div class="gl-display-flex gl-justify-content-center gl-mb-3"> @@ -182,6 +286,17 @@ export default { </div> </template> </gl-card> + + <gl-modal + :modal-id="$options.modal.id" + size="sm" + :title="$options.i18n.protectionRuleDeletionConfirmModal.title" + :action-primary="modalActionPrimary" + :action-cancel="modalActionCancel" + @primary="deleteProtectionRule(protectionRuleDeletionItem)" + > + <p>{{ $options.i18n.protectionRuleDeletionConfirmModal.description }}</p> + </gl-modal> </template> </settings-block> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/delete_packages_protection_rule.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/delete_packages_protection_rule.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..b5d6b69440d8f927649c0ed241435993a7e7946b --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/delete_packages_protection_rule.mutation.graphql @@ -0,0 +1,10 @@ +mutation deletePackagesProtectionRule($input: DeletePackagesProtectionRuleInput!) { + deletePackagesProtectionRule(input: $input) { + packageProtectionRule { + id + packageType + packageNamePattern + } + errors + } +} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7c97f3a7ed7d28cc3d68f9810fdc4be252ab3197..9b7a724c1f0b1ae75516e361858fa64c56c1b6ba 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -34937,6 +34937,9 @@ msgstr "" msgid "PackageRegistry|App name: %{name}" msgstr "" +msgid "PackageRegistry|Are you sure you want to delete the package protection rule?" +msgstr "" + msgid "PackageRegistry|Author email: %{authorEmail}" msgstr "" @@ -35218,6 +35221,12 @@ msgstr[1] "" msgid "PackageRegistry|Package name pattern" msgstr "" +msgid "PackageRegistry|Package protection rule deleted." +msgstr "" + +msgid "PackageRegistry|Package protection rules" +msgstr "" + msgid "PackageRegistry|Package type" msgstr "" @@ -35245,9 +35254,6 @@ msgstr "" msgid "PackageRegistry|Project-level" msgstr "" -msgid "PackageRegistry|Protected packages" -msgstr "" - msgid "PackageRegistry|Publish packages if their name or version matches this regex." msgstr "" @@ -35383,6 +35389,9 @@ msgstr "" msgid "PackageRegistry|Unable to load package" msgstr "" +msgid "PackageRegistry|Users with at least the Developer role for this project will be able to publish, edit, and delete packages." +msgstr "" + msgid "PackageRegistry|When a package is protected then only certain user roles are able to update and delete the protected package. This helps to avoid tampering with the package." msgstr "" diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js index 8a2a17176b0d74deb611f9a9ed4843c04d97c887..45088d49c8221bcaf5cd033e6dc16f30b0556aa1 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js @@ -1,16 +1,21 @@ -import { GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui'; +import { GlLoadingIcon, GlKeysetPagination, GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { getBinding } from 'helpers/vue_mock_directive'; import PackagesProtectionRules from '~/packages_and_registries/settings/project/components/packages_protection_rules.vue'; import PackagesProtectionRuleForm from '~/packages_and_registries/settings/project/components/packages_protection_rule_form.vue'; import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; import packagesProtectionRuleQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql'; - -import { packagesProtectionRuleQueryPayload, packagesProtectionRulesData } from '../mock_data'; +import deletePackagesProtectionRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/delete_packages_protection_rule.mutation.graphql'; +import { + packagesProtectionRuleQueryPayload, + packagesProtectionRulesData, + deletePackagesProtectionRuleMutationPayload, +} from '../mock_data'; Vue.use(VueApollo); @@ -21,17 +26,30 @@ describe('Packages protection rules project settings', () => { const defaultProvidedValues = { projectPath: 'path', }; + + const $toast = { show: jest.fn() }; + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); const findTable = () => extendedWrapper(wrapper.findByRole('table', /protected packages/i)); const findTableBody = () => extendedWrapper(findTable().findAllByRole('rowgroup').at(1)); const findTableRow = (i) => extendedWrapper(findTableBody().findAllByRole('row').at(i)); + const findTableRowButtonDelete = (i) => findTableRow(i).findByRole('button', { name: /delete/i }); const findTableLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findProtectionRuleForm = () => wrapper.findComponent(PackagesProtectionRuleForm); const findAddProtectionRuleButton = () => wrapper.findByRole('button', { name: /add package protection rule/i }); + const findAlert = () => wrapper.findByRole('alert'); + const findModal = () => wrapper.findComponent(GlModal); const mountComponent = (mountFn = shallowMount, provide = defaultProvidedValues, config) => { wrapper = mountFn(PackagesProtectionRules, { + stubs: { + SettingsBlock, + GlModal: true, + }, + mocks: { + $toast, + }, provide, ...config, }); @@ -40,14 +58,24 @@ describe('Packages protection rules project settings', () => { const createComponent = ({ mountFn = shallowMount, provide = defaultProvidedValues, - resolver = jest.fn().mockResolvedValue(packagesProtectionRuleQueryPayload()), + packagesProtectionRuleQueryResolver = jest + .fn() + .mockResolvedValue(packagesProtectionRuleQueryPayload()), + deletePackagesProtectionRuleMutationResolver = jest + .fn() + .mockResolvedValue(deletePackagesProtectionRuleMutationPayload()), + config = {}, } = {}) => { - const requestHandlers = [[packagesProtectionRuleQuery, resolver]]; + const requestHandlers = [ + [packagesProtectionRuleQuery, packagesProtectionRuleQueryResolver], + [deletePackagesProtectionRuleMutation, deletePackagesProtectionRuleMutationResolver], + ]; fakeApollo = createMockApollo(requestHandlers); mountComponent(mountFn, provide, { apolloProvider: fakeApollo, + ...config, }); }; @@ -92,10 +120,12 @@ describe('Packages protection rules project settings', () => { }); it('calls graphql api query', () => { - const resolver = jest.fn().mockResolvedValue(packagesProtectionRuleQueryPayload()); - createComponent({ resolver }); + const packagesProtectionRuleQueryResolver = jest + .fn() + .mockResolvedValue(packagesProtectionRuleQueryPayload()); + createComponent({ packagesProtectionRuleQueryResolver }); - expect(resolver).toHaveBeenCalledWith( + expect(packagesProtectionRuleQueryResolver).toHaveBeenCalledWith( expect.objectContaining({ projectPath: defaultProvidedValues.projectPath }), ); }); @@ -118,10 +148,12 @@ describe('Packages protection rules project settings', () => { }); it('calls initial graphql api query with pagination information', () => { - const resolver = jest.fn().mockResolvedValue(packagesProtectionRuleQueryPayload()); - createComponent({ resolver }); + const packagesProtectionRuleQueryResolver = jest + .fn() + .mockResolvedValue(packagesProtectionRuleQueryPayload()); + createComponent({ packagesProtectionRuleQueryResolver }); - expect(resolver).toHaveBeenCalledWith( + expect(packagesProtectionRuleQueryResolver).toHaveBeenCalledWith( expect.objectContaining({ projectPath: defaultProvidedValues.projectPath, first: 10, @@ -130,7 +162,7 @@ describe('Packages protection rules project settings', () => { }); describe('when button "Previous" is clicked', () => { - const resolver = jest + const packagesProtectionRuleQueryResolver = jest .fn() .mockResolvedValueOnce( packagesProtectionRuleQueryPayload({ @@ -149,7 +181,7 @@ describe('Packages protection rules project settings', () => { extendedWrapper(findPagination()).findByRole('button', { name: 'Previous' }); beforeEach(async () => { - createComponent({ mountFn: mountExtended, resolver }); + createComponent({ mountFn: mountExtended, packagesProtectionRuleQueryResolver }); await waitForPromises(); @@ -157,8 +189,8 @@ describe('Packages protection rules project settings', () => { }); it('sends a second graphql api query with new pagination params', () => { - expect(resolver).toHaveBeenCalledTimes(2); - expect(resolver).toHaveBeenLastCalledWith( + expect(packagesProtectionRuleQueryResolver).toHaveBeenCalledTimes(2); + expect(packagesProtectionRuleQueryResolver).toHaveBeenLastCalledWith( expect.objectContaining({ before: '10', last: 10, @@ -169,7 +201,7 @@ describe('Packages protection rules project settings', () => { }); describe('when button "Next" is clicked', () => { - const resolver = jest + const packagesProtectionRuleQueryResolver = jest .fn() .mockResolvedValueOnce(packagesProtectionRuleQueryPayload()) .mockResolvedValueOnce( @@ -188,7 +220,7 @@ describe('Packages protection rules project settings', () => { extendedWrapper(findPagination()).findByRole('button', { name: 'Next' }); beforeEach(async () => { - createComponent({ mountFn: mountExtended, resolver }); + createComponent({ mountFn: mountExtended, packagesProtectionRuleQueryResolver }); await waitForPromises(); @@ -196,8 +228,8 @@ describe('Packages protection rules project settings', () => { }); it('sends a second graphql api query with new pagination params', () => { - expect(resolver).toHaveBeenCalledTimes(2); - expect(resolver).toHaveBeenLastCalledWith( + expect(packagesProtectionRuleQueryResolver).toHaveBeenCalledTimes(2); + expect(packagesProtectionRuleQueryResolver).toHaveBeenLastCalledWith( expect.objectContaining({ after: '10', first: 10, @@ -207,6 +239,164 @@ describe('Packages protection rules project settings', () => { }); }); }); + + describe('table rows', () => { + describe('button "Delete"', () => { + it('exists in table', async () => { + createComponent({ mountFn: mountExtended }); + + await waitForPromises(); + + expect(findTableRowButtonDelete(0).exists()).toBe(true); + }); + + describe('when button is clicked', () => { + it('binds confirmation modal', async () => { + createComponent({ mountFn: mountExtended }); + + await waitForPromises(); + + const modalId = getBinding(findTableRowButtonDelete(0).element, 'gl-modal'); + + expect(findModal().props('modal-id')).toBe(modalId); + expect(findModal().props('title')).toBe( + 'Are you sure you want to delete the package protection rule?', + ); + expect(findModal().text()).toBe( + 'Users with at least the Developer role for this project will be able to publish, edit, and delete packages.', + ); + }); + }); + }); + }); + }); + + describe('modal "confirmation"', () => { + const createComponentAndClickButtonDeleteInTableRow = async ({ + tableRowIndex = 0, + deletePackagesProtectionRuleMutationResolver = jest + .fn() + .mockResolvedValue(deletePackagesProtectionRuleMutationPayload()), + } = {}) => { + createComponent({ + mountFn: mountExtended, + deletePackagesProtectionRuleMutationResolver, + }); + + await waitForPromises(); + + findTableRowButtonDelete(tableRowIndex).trigger('click'); + }; + + describe('when modal button "primary" clicked', () => { + const clickOnModalPrimaryBtn = () => findModal().vm.$emit('primary'); + + it('disables the button when graphql mutation is executed', async () => { + await createComponentAndClickButtonDeleteInTableRow(); + + await clickOnModalPrimaryBtn(); + + expect(findTableRowButtonDelete(0).props().disabled).toBe(true); + + expect(findTableRowButtonDelete(1).props().disabled).toBe(false); + }); + + it('sends graphql mutation', async () => { + const deletePackagesProtectionRuleMutationResolver = jest + .fn() + .mockResolvedValue(deletePackagesProtectionRuleMutationPayload()); + + await createComponentAndClickButtonDeleteInTableRow({ + deletePackagesProtectionRuleMutationResolver, + }); + + await clickOnModalPrimaryBtn(); + + expect(deletePackagesProtectionRuleMutationResolver).toHaveBeenCalledTimes(1); + expect(deletePackagesProtectionRuleMutationResolver).toHaveBeenCalledWith({ + input: { id: packagesProtectionRulesData[0].id }, + }); + }); + + it('handles erroneous graphql mutation', async () => { + const alertErrorMessage = 'Client error message'; + const deletePackagesProtectionRuleMutationResolver = jest + .fn() + .mockRejectedValue(new Error(alertErrorMessage)); + + await createComponentAndClickButtonDeleteInTableRow({ + deletePackagesProtectionRuleMutationResolver, + }); + + await clickOnModalPrimaryBtn(); + + await waitForPromises(); + + expect(findAlert().isVisible()).toBe(true); + expect(findAlert().text()).toBe(alertErrorMessage); + }); + + it('handles graphql mutation with error response', async () => { + const alertErrorMessage = 'Server error message'; + const deletePackagesProtectionRuleMutationResolver = jest.fn().mockResolvedValue({ + data: { + deletePackagesProtectionRule: { + packageProtectionRule: null, + errors: [alertErrorMessage], + }, + }, + }); + + await createComponentAndClickButtonDeleteInTableRow({ + deletePackagesProtectionRuleMutationResolver, + }); + + await clickOnModalPrimaryBtn(); + + await waitForPromises(); + + expect(findAlert().isVisible()).toBe(true); + expect(findAlert().text()).toBe(alertErrorMessage); + }); + + it('refetches package protection rules after successful graphql mutation', async () => { + const deletePackagesProtectionRuleMutationResolver = jest + .fn() + .mockResolvedValue(deletePackagesProtectionRuleMutationPayload()); + + const packagesProtectionRuleQueryResolver = jest + .fn() + .mockResolvedValue(packagesProtectionRuleQueryPayload()); + + createComponent({ + mountFn: mountExtended, + packagesProtectionRuleQueryResolver, + deletePackagesProtectionRuleMutationResolver, + }); + + await waitForPromises(); + + expect(packagesProtectionRuleQueryResolver).toHaveBeenCalledTimes(1); + + await findTableRowButtonDelete(0).trigger('click'); + + await clickOnModalPrimaryBtn(); + + await waitForPromises(); + + expect(packagesProtectionRuleQueryResolver).toHaveBeenCalledTimes(2); + }); + + it('shows a toast with success message', async () => { + await createComponentAndClickButtonDeleteInTableRow(); + + await clickOnModalPrimaryBtn(); + + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Package protection rule deleted.'); + }); + }); }); it('does not initially render package protection form', async () => { @@ -247,12 +437,14 @@ describe('Packages protection rules project settings', () => { }); describe('form "add protection rule"', () => { - let resolver; + let packagesProtectionRuleQueryResolver; beforeEach(async () => { - resolver = jest.fn().mockResolvedValue(packagesProtectionRuleQueryPayload()); + packagesProtectionRuleQueryResolver = jest + .fn() + .mockResolvedValue(packagesProtectionRuleQueryPayload()); - createComponent({ resolver, mountFn: mountExtended }); + createComponent({ packagesProtectionRuleQueryResolver, mountFn: mountExtended }); await waitForPromises(); @@ -262,7 +454,7 @@ describe('Packages protection rules project settings', () => { it('handles event "submit"', async () => { await findProtectionRuleForm().vm.$emit('submit'); - expect(resolver).toHaveBeenCalledTimes(2); + expect(packagesProtectionRuleQueryResolver).toHaveBeenCalledTimes(2); expect(findProtectionRuleForm().exists()).toBe(false); expect(findAddProtectionRuleButton().attributes('disabled')).not.toBeDefined(); @@ -271,10 +463,37 @@ describe('Packages protection rules project settings', () => { it('handles event "cancel"', async () => { await findProtectionRuleForm().vm.$emit('cancel'); - expect(resolver).toHaveBeenCalledTimes(1); + expect(packagesProtectionRuleQueryResolver).toHaveBeenCalledTimes(1); expect(findProtectionRuleForm().exists()).toBe(false); expect(findAddProtectionRuleButton().attributes()).not.toHaveProperty('disabled'); }); }); + + describe('alert "errorMessage"', () => { + const findAlertButtonDismiss = () => wrapper.findByRole('button', { name: /dismiss/i }); + + it('renders alert and dismisses it correctly', async () => { + const alertErrorMessage = 'Error message'; + createComponent({ + mountFn: mountExtended, + config: { + data() { + return { + alertErrorMessage, + }; + }, + }, + }); + + await waitForPromises(); + + expect(findAlert().isVisible()).toBe(true); + expect(findAlert().text()).toBe(alertErrorMessage); + + await findAlertButtonDismiss().trigger('click'); + + expect(findAlert().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js index e49bf8c6131e6e532d65fc7716f386911c31b346..23a1179011d0d6132457f75c79bb58e4d36071a2 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js @@ -138,3 +138,15 @@ export const createPackagesProtectionRuleMutationInput = { export const createPackagesProtectionRuleMutationPayloadErrors = [ 'Package name pattern has already been taken', ]; + +export const deletePackagesProtectionRuleMutationPayload = ({ + packageProtectionRule = { ...packagesProtectionRulesData[0] }, + errors = [], +} = {}) => ({ + data: { + deletePackagesProtectionRule: { + packageProtectionRule, + errors, + }, + }, +});