diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_protection_rules.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_protection_rules.vue
index 2f9352fcef5d38158d13c8a4dedb66d14323fbd6..7e95207e9ffdf3be20eaa69c6d30fca7528f108e 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_protection_rules.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_protection_rules.vue
@@ -3,6 +3,7 @@ import {
GlAlert,
GlButton,
GlCard,
+ GlFormSelect,
GlKeysetPagination,
GlLoadingIcon,
GlModal,
@@ -15,6 +16,7 @@ import protectionRulesQuery from '~/packages_and_registries/settings/project/gra
import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
import ContainerProtectionRuleForm from '~/packages_and_registries/settings/project/components/container_protection_rule_form.vue';
import deleteContainerProtectionRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/delete_container_protection_rule.mutation.graphql';
+import updateContainerRegistryProtectionRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_container_registry_protection_rule.mutation.graphql';
import { s__, __ } from '~/locale';
const PAGINATION_DEFAULT_PER_PAGE = 10;
@@ -24,18 +26,13 @@ const I18N_MINIMUM_ACCESS_LEVEL_FOR_DELETE = s__(
'ContainerRegistry|Minimum access level for delete',
);
-const ACCESS_LEVEL_GRAPHQL_VALUE_TO_LABEL = {
- MAINTAINER: __('Maintainer'),
- OWNER: __('Owner'),
- ADMIN: __('Admin'),
-};
-
export default {
components: {
ContainerProtectionRuleForm,
GlAlert,
GlButton,
GlCard,
+ GlFormSelect,
GlKeysetPagination,
GlLoadingIcon,
GlModal,
@@ -62,6 +59,8 @@ export default {
'ContainerRegistry|Users with at least the Developer role for this project will be able to push and delete container images to this repository path.',
),
},
+ minimumAccessLevelForPush: I18N_MINIMUM_ACCESS_LEVEL_FOR_PUSH,
+ minimumAccessLevelForDelete: I18N_MINIMUM_ACCESS_LEVEL_FOR_DELETE,
},
apollo: {
protectionRulesQueryPayload: {
@@ -96,10 +95,8 @@ export default {
return this.protectionRulesQueryResult.map((protectionRule) => {
return {
id: protectionRule.id,
- minimumAccessLevelForDelete:
- ACCESS_LEVEL_GRAPHQL_VALUE_TO_LABEL[protectionRule.minimumAccessLevelForDelete],
- minimumAccessLevelForPush:
- ACCESS_LEVEL_GRAPHQL_VALUE_TO_LABEL[protectionRule.minimumAccessLevelForPush],
+ minimumAccessLevelForDelete: protectionRule.minimumAccessLevelForDelete,
+ minimumAccessLevelForPush: protectionRule.minimumAccessLevelForPush,
repositoryPathPattern: protectionRule.repositoryPathPattern,
};
});
@@ -135,6 +132,13 @@ export default {
text: __('Cancel'),
};
},
+ minimumAccessLevelOptions() {
+ return [
+ { value: 'MAINTAINER', text: __('Maintainer') },
+ { value: 'OWNER', text: __('Owner') },
+ { value: 'ADMIN', text: __('Admin') },
+ ];
+ },
},
methods: {
showProtectionRuleForm() {
@@ -195,10 +199,52 @@ export default {
return;
}
this.refetchProtectionRules();
- this.$toast.show(s__('ContainerRegistry|Protection rule deleted.'));
+ this.$toast.show(s__('ContainerRegistry|Container protection rule deleted.'));
})
- .catch((e) => {
- this.alertErrorMessage = e.message;
+ .catch((error) => {
+ this.alertErrorMessage = error.message;
+ })
+ .finally(() => {
+ this.resetProtectionRuleMutation();
+ });
+ },
+ updateProtectionRuleMinimumAccessLevelForPush(protectionRule) {
+ this.updateProtectionRule(protectionRule, {
+ minimumAccessLevelForPush: protectionRule.minimumAccessLevelForPush,
+ });
+ },
+ updateProtectionRuleMinimumAccessLevelForDelete(protectionRule) {
+ this.updateProtectionRule(protectionRule, {
+ minimumAccessLevelForDelete: protectionRule.minimumAccessLevelForDelete,
+ });
+ },
+ updateProtectionRule(protectionRule, updateData) {
+ this.clearAlertMessage();
+
+ this.protectionRuleMutationItem = protectionRule;
+ this.protectionRuleMutationInProgress = true;
+
+ return this.$apollo
+ .mutate({
+ mutation: updateContainerRegistryProtectionRuleMutation,
+ variables: {
+ input: {
+ id: protectionRule.id,
+ ...updateData,
+ },
+ },
+ })
+ .then(({ data }) => {
+ const [errorMessage] = data?.updateContainerRegistryProtectionRule?.errors ?? [];
+ if (errorMessage) {
+ this.alertErrorMessage = errorMessage;
+ return;
+ }
+
+ this.$toast.show(s__('ContainerRegistry|Container protection rule updated.'));
+ })
+ .catch((error) => {
+ this.alertErrorMessage = error.message;
})
.finally(() => {
this.resetProtectionRuleMutation();
@@ -289,6 +335,30 @@ export default {
+
+
+
+
+
+
+
+
{
deleteContainerProtectionRuleMutationResolver = jest
.fn()
.mockResolvedValue(deleteContainerProtectionRuleMutationPayload()),
+ updateContainerProtectionRuleMutationResolver = jest
+ .fn()
+ .mockResolvedValue(updateContainerProtectionRuleMutationPayload()),
config = {},
} = {}) => {
const requestHandlers = [
[ContainerProtectionRuleQuery, containerProtectionRuleQueryResolver],
[deleteContainerProtectionRuleMutation, deleteContainerProtectionRuleMutationResolver],
+ [updateContainerProtectionRuleMutation, updateContainerProtectionRuleMutationResolver],
];
fakeApollo = createMockApollo(requestHandlers);
@@ -89,7 +95,10 @@ describe('Container protection rules project settings', () => {
});
describe('table "container protection rules"', () => {
- const findTableRowCell = (i, j) => findTableRow(i).findAllByRole('cell').at(j);
+ const findTableRowCell = (i, j) => extendedWrapper(findTableRow(i).findAllByRole('cell').at(j));
+ const findTableRowCellCombobox = (i, j) => findTableRowCell(i, j).findByRole('combobox');
+ const findTableRowCellComboboxSelectedOption = (i, j) =>
+ findTableRowCellCombobox(i, j).element.selectedOptions.item(0);
it('renders table with container protection rules', async () => {
createComponent();
@@ -101,8 +110,8 @@ describe('Container protection rules project settings', () => {
containerProtectionRuleQueryPayload().data.project.containerRegistryProtectionRules.nodes.forEach(
(protectionRule, i) => {
expect(findTableRowCell(i, 0).text()).toBe(protectionRule.repositoryPathPattern);
- expect(findTableRowCell(i, 1).text()).toBe('Maintainer');
- expect(findTableRowCell(i, 2).text()).toBe('Maintainer');
+ expect(findTableRowCellComboboxSelectedOption(i, 1).text).toBe('Maintainer');
+ expect(findTableRowCellComboboxSelectedOption(i, 2).text).toBe('Maintainer');
},
);
});
@@ -269,6 +278,155 @@ describe('Container protection rules project settings', () => {
});
});
+ describe.each`
+ comboboxName | minimumAccessLevelAttribute
+ ${'Minimum access level for push'} | ${'minimumAccessLevelForPush'}
+ ${'Minimum access level for delete'} | ${'minimumAccessLevelForDelete'}
+ `(
+ 'column "$comboboxName" with selectbox (combobox)',
+ ({ comboboxName, minimumAccessLevelAttribute }) => {
+ const findComboboxInTableRow = (i) =>
+ extendedWrapper(findTableRow(i).findByRole('combobox', { name: comboboxName }));
+ const findAllComboboxesInTableRow = (i) =>
+ extendedWrapper(findTableRow(i).findAllByRole('combobox'));
+
+ it('contains correct access level as options', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findComboboxInTableRow(0).isVisible()).toBe(true);
+ expect(findComboboxInTableRow(0).attributes('disabled')).toBeUndefined();
+ expect(findComboboxInTableRow(0).element.value).toBe(
+ containerProtectionRulesData[0][minimumAccessLevelAttribute],
+ );
+
+ const accessLevelOptions = findComboboxInTableRow(0)
+ .findAllComponents('option')
+ .wrappers.map((w) => w.text());
+
+ expect(accessLevelOptions).toEqual(['Maintainer', 'Owner', 'Admin']);
+ });
+
+ describe('when value changes', () => {
+ const accessLevelValueOwner = 'OWNER';
+ const accessLevelValueMaintainer = 'MAINTAINER';
+
+ it('only changes the value of the selectbox in the same row', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findComboboxInTableRow(0).props('value')).toBe(accessLevelValueMaintainer);
+ expect(findComboboxInTableRow(1).props('value')).toBe(accessLevelValueMaintainer);
+
+ await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
+
+ expect(findComboboxInTableRow(0).props('value')).toBe(accessLevelValueOwner);
+ expect(findComboboxInTableRow(1).props('value')).toBe(accessLevelValueMaintainer);
+ });
+
+ it('sends graphql mutation', async () => {
+ const updateContainerProtectionRuleMutationResolver = jest
+ .fn()
+ .mockResolvedValue(updateContainerProtectionRuleMutationPayload());
+
+ createComponent({ updateContainerProtectionRuleMutationResolver });
+
+ await waitForPromises();
+
+ await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
+
+ expect(updateContainerProtectionRuleMutationResolver).toHaveBeenCalledTimes(1);
+ expect(updateContainerProtectionRuleMutationResolver).toHaveBeenCalledWith({
+ input: {
+ id: containerProtectionRulesData[0].id,
+ [minimumAccessLevelAttribute]: accessLevelValueOwner,
+ },
+ });
+ });
+
+ it('disables all fields in relevant row when graphql mutation is in progress', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
+
+ findAllComboboxesInTableRow(0).wrappers.forEach((combobox) =>
+ expect(combobox.props('disabled')).toBe(true),
+ );
+ expect(findTableRowButtonDelete(0).props('disabled')).toBe(true);
+ findAllComboboxesInTableRow(1).wrappers.forEach((combobox) =>
+ expect(combobox.props('disabled')).toBe(false),
+ );
+ expect(findTableRowButtonDelete(1).props('disabled')).toBe(false);
+
+ await waitForPromises();
+
+ findAllComboboxesInTableRow(0).wrappers.forEach((combobox) =>
+ expect(combobox.props('disabled')).toBe(false),
+ );
+ expect(findTableRowButtonDelete(0).props('disabled')).toBe(false);
+ findAllComboboxesInTableRow(1).wrappers.forEach((combobox) =>
+ expect(combobox.props('disabled')).toBe(false),
+ );
+ expect(findTableRowButtonDelete(1).props('disabled')).toBe(false);
+ });
+
+ it('handles erroneous graphql mutation', async () => {
+ const updateContainerProtectionRuleMutationResolver = jest
+ .fn()
+ .mockRejectedValue(new Error('error'));
+
+ createComponent({ updateContainerProtectionRuleMutationResolver });
+
+ await waitForPromises();
+
+ await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
+
+ await waitForPromises();
+
+ expect(findAlert().isVisible()).toBe(true);
+ expect(findAlert().text()).toBe('error');
+ });
+
+ it('handles graphql mutation with error response', async () => {
+ const serverErrorMessage = 'Server error message';
+ const updateContainerProtectionRuleMutationResolver = jest.fn().mockResolvedValue(
+ updateContainerProtectionRuleMutationPayload({
+ containerRegistryProtectionRule: null,
+ errors: [serverErrorMessage],
+ }),
+ );
+
+ createComponent({ updateContainerProtectionRuleMutationResolver });
+
+ await waitForPromises();
+
+ await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
+
+ await waitForPromises();
+
+ expect(findAlert().isVisible()).toBe(true);
+ expect(findAlert().text()).toBe(serverErrorMessage);
+ });
+
+ it('shows a toast with success message', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
+
+ await waitForPromises();
+
+ expect($toast.show).toHaveBeenCalledWith('Container protection rule updated.');
+ });
+ });
+ },
+ );
+
describe('column "rowActions"', () => {
describe('button "Delete"', () => {
it('exists in table', async () => {
@@ -417,7 +575,7 @@ describe('Container protection rules project settings', () => {
await waitForPromises();
- expect($toast.show).toHaveBeenCalledWith('Protection rule deleted.');
+ expect($toast.show).toHaveBeenCalledWith('Container protection rule deleted.');
});
});
});
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 fe792a2d44f5aa4ed4642624f9b332cc45744234..b2064e9b5008948d5c43ac83077ae09d5e66cb5e 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
@@ -236,3 +236,18 @@ export const deleteContainerProtectionRuleMutationPayload = ({
},
},
});
+
+export const updateContainerProtectionRuleMutationPayload = ({
+ containerRegistryProtectionRule = {
+ ...containerProtectionRulesData[0],
+ minimumAccessLevelForPush: 'OWNER',
+ },
+ errors = [],
+} = {}) => ({
+ data: {
+ updateContainerRegistryProtectionRule: {
+ containerRegistryProtectionRule,
+ errors,
+ },
+ },
+});