Skip to content
Snippets Groups Projects
Verified Commit 02098182 authored by Gerardo Navarro's avatar Gerardo Navarro :speech_balloon: 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: Gerardo Navarro's avatarGerardo Navarro <gerardo@b310.de>
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: default avatarEzekiel Kigbo <3397881-ekigbo@users.noreply.gitlab.com>
parents ef57c6e7 45effae4
No related branches found
No related tags found
No related merge requests found
Pipeline #1247680811 passed
Pipeline: E2E Omnibus GitLab EE

#1247817606

    Pipeline: GitLab

    #1247702439

      Pipeline: E2E GDK

      #1247697101

        +1
        <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