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 @@
@@ -157,18 +241,38 @@ export default {
@submit="refetchProtectionRules"
/>
+
+ {{ alertErrorMessage }}
+
+
+
+
+ {{ __('Delete') }}
+
@@ -182,6 +286,17 @@ export default {
+
+
+ {{ $options.i18n.protectionRuleDeletionConfirmModal.description }}
+
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 2c7c67788db2389f6f48b201a7a9cd2e3d4b9bc4..ca448a45f80fba87d36f677edc6d3242f1e57a37 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -35000,6 +35000,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 ""
@@ -35281,6 +35284,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 ""
@@ -35308,9 +35317,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 ""
@@ -35446,6 +35452,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,
+ },
+ },
+});