Skip to content
Snippets Groups Projects
Verified Commit 01edc95c authored by Ezekiel Kigbo's avatar Ezekiel Kigbo :two: Committed by GitLab
Browse files

Merge branch...

Merge branch '441345-gerardo-navarro-protected-contaioners-project-settings-ui-create' into 'master' 

Protected containers: Create protection rules in project settings

See merge request !146523



Merged-by: default avatarEzekiel Kigbo <3397881-ekigbo@users.noreply.gitlab.com>
Approved-by: default avatarAnnabel Dunstone Gray <annabel.dunstone@gmail.com>
Approved-by: default avatarEzekiel Kigbo <3397881-ekigbo@users.noreply.gitlab.com>
Reviewed-by: default avatarAnnabel Dunstone Gray <annabel.dunstone@gmail.com>
Reviewed-by: default avatarEzekiel Kigbo <3397881-ekigbo@users.noreply.gitlab.com>
Reviewed-by: Gerardo Navarro's avatarGerardo Navarro <gerardo@b310.de>
Co-authored-by: Gerardo Navarro's avatarGerardo <gerardo@b310.de>
parents e381e75d aef987b9
No related branches found
No related tags found
1 merge request!146523Protected containers: Create protection rules in project settings
Pipeline #1247949344 passed
<script>
import { GlAlert, GlButton, GlFormGroup, GlForm, GlFormInput, GlFormSelect } from '@gitlab/ui';
import createProtectionRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/create_container_protection_rule.mutation.graphql';
import { s__, __ } from '~/locale';
const GRAPHQL_ACCESS_LEVEL_VALUE_MAINTAINER = 'MAINTAINER';
const GRAPHQL_ACCESS_LEVEL_VALUE_DEVELOPER = 'DEVELOPER';
const GRAPHQL_ACCESS_LEVEL_VALUE_OWNER = 'OWNER';
export default {
components: {
GlAlert,
GlButton,
GlForm,
GlFormGroup,
GlFormInput,
GlFormSelect,
},
inject: ['projectPath'],
i18n: {
protectionRuleSavedErrorMessage: s__(
'ContainerRegistry|Something went wrong while saving the protection rule.',
),
},
data() {
return {
protectionRuleFormData: {
repositoryPathPattern: '',
pushProtectedUpToAccessLevel: GRAPHQL_ACCESS_LEVEL_VALUE_DEVELOPER,
deleteProtectedUpToAccessLevel: GRAPHQL_ACCESS_LEVEL_VALUE_DEVELOPER,
},
updateInProgress: false,
alertErrorMessage: '',
};
},
computed: {
showLoadingIcon() {
return this.updateInProgress;
},
isEmptyRepositoryPathPattern() {
return !this.protectionRuleFormData.repositoryPathPattern;
},
isSubmitButtonDisabled() {
return this.isEmptyRepositoryPathPattern || this.showLoadingIcon;
},
isFieldDisabled() {
return this.showLoadingIcon;
},
createProtectionRuleMutationInput() {
return {
projectPath: this.projectPath,
repositoryPathPattern: this.protectionRuleFormData.repositoryPathPattern,
pushProtectedUpToAccessLevel: this.protectionRuleFormData.pushProtectedUpToAccessLevel,
deleteProtectedUpToAccessLevel: this.protectionRuleFormData.deleteProtectedUpToAccessLevel,
};
},
protectedUpToAccessLevelOptions() {
return [
{ value: GRAPHQL_ACCESS_LEVEL_VALUE_DEVELOPER, text: __('Developer') },
{ value: GRAPHQL_ACCESS_LEVEL_VALUE_MAINTAINER, text: __('Maintainer') },
{ value: GRAPHQL_ACCESS_LEVEL_VALUE_OWNER, text: __('Owner') },
];
},
},
methods: {
submit() {
this.clearAlertErrorMessage();
this.updateInProgress = true;
return this.$apollo
.mutate({
mutation: createProtectionRuleMutation,
variables: {
input: this.createProtectionRuleMutationInput,
},
})
.then(({ data }) => {
const [errorMessage] = data?.createContainerRegistryProtectionRule?.errors ?? [];
if (errorMessage) {
this.alertErrorMessage = errorMessage;
return;
}
this.$emit(
'submit',
data.createContainerRegistryProtectionRule.containerRegistryProtectionRule,
);
})
.catch(() => {
this.alertErrorMessage = this.$options.i18n.protectionRuleSavedErrorMessage;
})
.finally(() => {
this.updateInProgress = false;
});
},
clearAlertErrorMessage() {
this.alertErrorMessage = null;
},
cancelForm() {
this.clearAlertErrorMessage();
this.$emit('cancel');
},
},
};
</script>
<template>
<div class="gl-new-card-add-form gl-m-3">
<gl-form @submit.prevent="submit" @reset="cancelForm">
<gl-alert
v-if="alertErrorMessage"
class="gl-mb-5"
variant="danger"
@dismiss="clearAlertErrorMessage"
>
{{ alertErrorMessage }}
</gl-alert>
<gl-form-group
:label="s__('ContainerRegistry|Repository path pattern')"
label-for="input-repository-path-pattern"
>
<gl-form-input
id="input-repository-path-pattern"
v-model.trim="protectionRuleFormData.repositoryPathPattern"
type="text"
required
:disabled="isFieldDisabled"
/>
</gl-form-group>
<gl-form-group
:label="s__('ContainerRegistry|Maximum access level prevented from pushing')"
label-for="input-push-protected-up-to-access-level"
:disabled="isFieldDisabled"
>
<gl-form-select
id="input-push-protected-up-to-access-level"
v-model="protectionRuleFormData.pushProtectedUpToAccessLevel"
:options="protectedUpToAccessLevelOptions"
:disabled="isFieldDisabled"
required
/>
</gl-form-group>
<gl-form-group
:label="s__('ContainerRegistry|Maximum access level prevented from deleting')"
label-for="input-delete-protected-up-to-access-level"
:disabled="isFieldDisabled"
>
<gl-form-select
id="input-delete-protected-up-to-access-level"
v-model="protectionRuleFormData.deleteProtectedUpToAccessLevel"
:options="protectedUpToAccessLevelOptions"
:disabled="isFieldDisabled"
required
/>
</gl-form-group>
<div class="gl-display-flex gl-justify-content-start">
<gl-button
variant="confirm"
type="submit"
:disabled="isSubmitButtonDisabled"
:loading="showLoadingIcon"
>{{ s__('ContainerRegistry|Add rule') }}</gl-button
>
<gl-button class="gl-ml-3" type="reset">{{ __('Cancel') }}</gl-button>
</div>
</gl-form>
</div>
</template>
<script>
import {
GlAlert,
GlButton,
GlCard,
GlTable,
GlLoadingIcon,
GlKeysetPagination,
GlLoadingIcon,
GlModalDirective,
GlTable,
} from '@gitlab/ui';
import protectionRulesQuery from '~/packages_and_registries/settings/project/graphql/queries/get_container_protection_rules.query.graphql';
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 { s__, __ } from '~/locale';
const PAGINATION_DEFAULT_PER_PAGE = 10;
......@@ -28,7 +30,9 @@ const ACCESS_LEVEL_GRAPHQL_VALUE_TO_LABEL = {
export default {
components: {
ContainerProtectionRuleForm,
GlAlert,
GlButton,
GlCard,
GlKeysetPagination,
GlLoadingIcon,
......@@ -100,6 +104,9 @@ export default {
this.protectionRulesQueryPageInfo.hasNextPage
);
},
isAddProtectionRuleButtonDisabled() {
return this.protectionRuleFormVisibility;
},
},
methods: {
showProtectionRuleForm() {
......@@ -181,10 +188,25 @@ export default {
<template #header>
<div class="gl-new-card-title-wrapper gl-justify-content-space-between">
<h3 class="gl-new-card-title">{{ $options.i18n.settingBlockTitle }}</h3>
<div class="gl-new-card-actions">
<gl-button
size="small"
:disabled="isAddProtectionRuleButtonDisabled"
@click="showProtectionRuleForm"
>
{{ s__('ContainerRegistry|Add protection rule') }}
</gl-button>
</div>
</div>
</template>
<template #default>
<container-protection-rule-form
v-if="protectionRuleFormVisibility"
@cancel="hideProtectionRuleForm"
@submit="refetchProtectionRules"
/>
<gl-alert
v-if="alertErrorMessage"
class="gl-mb-5"
......
mutation createContainerProtectionRule($input: CreateContainerRegistryProtectionRuleInput!) {
createContainerRegistryProtectionRule(input: $input) {
containerRegistryProtectionRule {
id
repositoryPathPattern
pushProtectedUpToAccessLevel
deleteProtectedUpToAccessLevel
}
errors
}
}
......@@ -13805,6 +13805,12 @@ msgstr ""
msgid "ContainerRegistry|-- tags"
msgstr ""
 
msgid "ContainerRegistry|Add protection rule"
msgstr ""
msgid "ContainerRegistry|Add rule"
msgstr ""
msgid "ContainerRegistry|Build an image"
msgstr ""
 
......@@ -13952,6 +13958,12 @@ msgstr ""
msgid "ContainerRegistry|Manifest digest: %{digest}"
msgstr ""
 
msgid "ContainerRegistry|Maximum access level prevented from deleting"
msgstr ""
msgid "ContainerRegistry|Maximum access level prevented from pushing"
msgstr ""
msgid "ContainerRegistry|Missing or insufficient permission, delete button disabled"
msgstr ""
 
......@@ -14047,6 +14059,9 @@ msgstr ""
msgid "ContainerRegistry|Something went wrong while marking the tags for deletion."
msgstr ""
 
msgid "ContainerRegistry|Something went wrong while saving the protection rule."
msgstr ""
msgid "ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again."
msgstr ""
 
import VueApollo from 'vue-apollo';
import Vue from 'vue';
import { GlForm } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ContainerProtectionRuleForm from '~/packages_and_registries/settings/project/components/container_protection_rule_form.vue';
import createContainerProtectionRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/create_container_protection_rule.mutation.graphql';
import {
createContainerProtectionRuleMutationPayload,
createContainerProtectionRuleMutationInput,
createContainerProtectionRuleMutationPayloadErrors,
} from '../mock_data';
Vue.use(VueApollo);
describe('container Protection Rule Form', () => {
let wrapper;
let fakeApollo;
const defaultProvidedValues = {
projectPath: 'path',
};
const findForm = () => wrapper.findComponent(GlForm);
const findRepositoryPathPatternInput = () =>
wrapper.findByRole('textbox', { name: /repository path pattern/i });
const findPushProtectedUpToAccessLevelSelect = () =>
wrapper.findByRole('combobox', { name: /maximum access level prevented from pushing/i });
const findDeleteProtectedUpToAccessLevelSelect = () =>
wrapper.findByRole('combobox', { name: /maximum access level prevented from deleting/i });
const findSubmitButton = () => wrapper.findByRole('button', { name: /add rule/i });
const mountComponent = ({ config, provide = defaultProvidedValues } = {}) => {
wrapper = mountExtended(ContainerProtectionRuleForm, {
provide,
...config,
});
};
const mountComponentWithApollo = ({ provide = defaultProvidedValues, mutationResolver } = {}) => {
const requestHandlers = [[createContainerProtectionRuleMutation, mutationResolver]];
fakeApollo = createMockApollo(requestHandlers);
mountComponent({
provide,
config: {
apolloProvider: fakeApollo,
},
});
};
describe('form fields', () => {
describe('form field "pushProtectedUpToAccessLevelSelect"', () => {
const pushProtectedUpToAccessLevelSelectOptions = () =>
findPushProtectedUpToAccessLevelSelect()
.findAll('option')
.wrappers.map((option) => option.element.value);
it.each(['DEVELOPER', 'MAINTAINER', 'OWNER'])(
'includes the %s access level',
(accessLevel) => {
mountComponent();
expect(findPushProtectedUpToAccessLevelSelect().exists()).toBe(true);
expect(pushProtectedUpToAccessLevelSelectOptions()).toContain(accessLevel);
},
);
});
describe('when graphql mutation is in progress', () => {
beforeEach(() => {
mountComponentWithApollo();
findForm().trigger('submit');
});
it('disables all form fields', () => {
expect(findSubmitButton().props('disabled')).toBe(true);
expect(findRepositoryPathPatternInput().attributes('disabled')).toBe('disabled');
expect(findPushProtectedUpToAccessLevelSelect().attributes('disabled')).toBe('disabled');
expect(findDeleteProtectedUpToAccessLevelSelect().attributes('disabled')).toBe('disabled');
});
it('displays a loading spinner', () => {
expect(findSubmitButton().props('loading')).toBe(true);
});
});
});
describe('form actions', () => {
describe('button "Protect"', () => {
it.each`
repositoryPathPattern | submitButtonDisabled
${''} | ${true}
${' '} | ${true}
${createContainerProtectionRuleMutationInput.repositoryPathPattern} | ${false}
`(
'when repositoryPathPattern is "$repositoryPathPattern" then the disabled state of the submit button is $submitButtonDisabled',
async ({ repositoryPathPattern, submitButtonDisabled }) => {
mountComponent();
expect(findSubmitButton().props('disabled')).toBe(true);
await findRepositoryPathPatternInput().setValue(repositoryPathPattern);
expect(findSubmitButton().props('disabled')).toBe(submitButtonDisabled);
},
);
});
});
describe('form events', () => {
describe('reset', () => {
const mutationResolver = jest
.fn()
.mockResolvedValue(createContainerProtectionRuleMutationPayload());
beforeEach(() => {
mountComponentWithApollo({ mutationResolver });
findForm().trigger('reset');
});
it('emits custom event "cancel"', () => {
expect(mutationResolver).not.toHaveBeenCalled();
expect(wrapper.emitted('cancel')).toBeDefined();
expect(wrapper.emitted('cancel')[0]).toEqual([]);
});
it('does not dispatch apollo mutation request', () => {
expect(mutationResolver).not.toHaveBeenCalled();
});
it('does not emit custom event "submit"', () => {
expect(wrapper.emitted()).not.toHaveProperty('submit');
});
});
describe('submit', () => {
const findAlert = () => wrapper.findByRole('alert');
const submitForm = () => {
findForm().trigger('submit');
return waitForPromises();
};
it('dispatches correct apollo mutation', async () => {
const mutationResolver = jest
.fn()
.mockResolvedValue(createContainerProtectionRuleMutationPayload());
mountComponentWithApollo({ mutationResolver });
await findRepositoryPathPatternInput().setValue(
createContainerProtectionRuleMutationInput.repositoryPathPattern,
);
await submitForm();
expect(mutationResolver).toHaveBeenCalledWith({
input: { projectPath: 'path', ...createContainerProtectionRuleMutationInput },
});
});
it('emits event "submit" when apollo mutation successful', async () => {
const mutationResolver = jest
.fn()
.mockResolvedValue(createContainerProtectionRuleMutationPayload());
mountComponentWithApollo({ mutationResolver });
await submitForm();
expect(wrapper.emitted('submit')).toBeDefined();
const expectedEventSubmitPayload = createContainerProtectionRuleMutationPayload().data
.createContainerRegistryProtectionRule.containerRegistryProtectionRule;
expect(wrapper.emitted('submit')[0]).toEqual([expectedEventSubmitPayload]);
expect(wrapper.emitted()).not.toHaveProperty('cancel');
});
it('shows error alert with general message when apollo mutation request responds with errors', async () => {
mountComponentWithApollo({
mutationResolver: jest.fn().mockResolvedValue(
createContainerProtectionRuleMutationPayload({
errors: createContainerProtectionRuleMutationPayloadErrors,
}),
),
});
await submitForm();
expect(findAlert().isVisible()).toBe(true);
expect(findAlert().text()).toBe(createContainerProtectionRuleMutationPayloadErrors[0]);
});
it('shows error alert with general message when apollo mutation request fails', async () => {
mountComponentWithApollo({
mutationResolver: jest.fn().mockRejectedValue(new Error('GraphQL error')),
});
await submitForm();
expect(findAlert().isVisible()).toBe(true);
expect(findAlert().text()).toBe('Something went wrong while saving the protection rule.');
});
});
});
});
......@@ -4,6 +4,7 @@ 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 ContainerProtectionRuleForm from '~/packages_and_registries/settings/project/components/container_protection_rule_form.vue';
import ContainerProtectionRules from '~/packages_and_registries/settings/project/components/container_protection_rules.vue';
import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
import ContainerProtectionRuleQuery from '~/packages_and_registries/settings/project/graphql/queries/get_container_protection_rules.query.graphql';
......@@ -27,6 +28,9 @@ describe('Container protection rules project settings', () => {
const findTableBody = () => extendedWrapper(findTable().findAllByRole('rowgroup').at(1));
const findTableRow = (i) => extendedWrapper(findTableBody().findAllByRole('row').at(i));
const findTableLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAddProtectionRuleForm = () => wrapper.findComponent(ContainerProtectionRuleForm);
const findAddProtectionRuleFormSubmitButton = () =>
wrapper.findByRole('button', { name: /add protection rule/i });
const findAlert = () => wrapper.findByRole('alert');
const mountComponent = (mountFn = mountExtended, provide = defaultProvidedValues, config) => {
......@@ -70,7 +74,7 @@ describe('Container protection rules project settings', () => {
expect(findTable().exists()).toBe(true);
});
describe('table "package protection rules"', () => {
describe('table "container protection rules"', () => {
const findTableRowCell = (i, j) => findTableRow(i).findAllByRole('cell').at(j);
it('renders table with Container protection rules', async () => {
......@@ -253,6 +257,77 @@ describe('Container protection rules project settings', () => {
});
});
describe('button "Add protection rule"', () => {
it('button exists', async () => {
createComponent();
await waitForPromises();
expect(findAddProtectionRuleFormSubmitButton().isVisible()).toBe(true);
});
it('does not initially render form "add protection rule"', async () => {
createComponent();
await waitForPromises();
expect(findAddProtectionRuleFormSubmitButton().isVisible()).toBe(true);
expect(findAddProtectionRuleForm().exists()).toBe(false);
});
describe('when button is clicked', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
await findAddProtectionRuleFormSubmitButton().trigger('click');
});
it('renders form "add protection rule"', () => {
expect(findAddProtectionRuleForm().isVisible()).toBe(true);
});
it('disables the button "add protection rule"', () => {
expect(findAddProtectionRuleFormSubmitButton().attributes('disabled')).toBeDefined();
});
});
});
describe('form "add protection rule"', () => {
let containerProtectionRuleQueryResolver;
beforeEach(async () => {
containerProtectionRuleQueryResolver = jest
.fn()
.mockResolvedValue(containerProtectionRuleQueryPayload());
createComponent({ containerProtectionRuleQueryResolver });
await waitForPromises();
await findAddProtectionRuleFormSubmitButton().trigger('click');
});
it('handles event "submit"', async () => {
await findAddProtectionRuleForm().vm.$emit('submit');
expect(containerProtectionRuleQueryResolver).toHaveBeenCalledTimes(2);
expect(findAddProtectionRuleForm().exists()).toBe(false);
expect(findAddProtectionRuleFormSubmitButton().attributes('disabled')).not.toBeDefined();
});
it('handles event "cancel"', async () => {
await findAddProtectionRuleForm().vm.$emit('cancel');
expect(containerProtectionRuleQueryResolver).toHaveBeenCalledTimes(1);
expect(findAddProtectionRuleForm().exists()).toBe(false);
expect(findAddProtectionRuleFormSubmitButton().attributes()).not.toHaveProperty('disabled');
});
});
describe('alert "errorMessage"', () => {
const findAlertButtonDismiss = () => wrapper.findByRole('button', { name: /dismiss/i });
......
......@@ -202,3 +202,25 @@ export const containerProtectionRuleQueryPayload = ({
},
},
});
export const createContainerProtectionRuleMutationPayload = ({ override, errors = [] } = {}) => ({
data: {
createContainerRegistryProtectionRule: {
containerRegistryProtectionRule: {
...containerProtectionRulesData[0],
...override,
},
errors,
},
},
});
export const createContainerProtectionRuleMutationInput = {
repositoryPathPattern: `@flight/flight-developer-14-*`,
pushProtectedUpToAccessLevel: 'DEVELOPER',
deleteProtectedUpToAccessLevel: 'DEVELOPER',
};
export const createContainerProtectionRuleMutationPayloadErrors = [
'Repository path pattern has already been taken',
];
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment