diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rule_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rule_form.vue new file mode 100644 index 0000000000000000000000000000000000000000..7c27eacd190ef7ddccb9a91281d72cd26f954692 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rule_form.vue @@ -0,0 +1,177 @@ +<script> +import { GlAlert, GlButton, GlFormGroup, GlForm, GlFormInput, GlFormSelect } from '@gitlab/ui'; +import createPackagesProtectionRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/create_packages_protection_rule.mutation.graphql'; +import { s__, __ } from '~/locale'; + +const PACKAGES_PROTECTION_RULES_SAVED_SUCCESS_MESSAGE = s__('PackageRegistry|Rule saved.'); +const PACKAGES_PROTECTION_RULES_SAVED_ERROR_MESSAGE = s__( + 'PackageRegistry|Something went wrong while saving the package protection rule.', +); + +const GRAPHQL_ACCESS_LEVEL_VALUE_MAINTAINER = 'MAINTAINER'; +const GRAPHQL_ACCESS_LEVEL_VALUE_DEVELOPER = 'DEVELOPER'; +const GRAPHQL_ACCESS_LEVEL_VALUE_OWNER = 'OWNER'; + +export default { + components: { + GlButton, + GlFormInput, + GlFormSelect, + GlFormGroup, + GlAlert, + GlForm, + }, + inject: ['projectPath'], + i18n: { + PACKAGES_PROTECTION_RULES_SAVED_SUCCESS_MESSAGE, + PACKAGES_PROTECTION_RULES_SAVED_ERROR_MESSAGE, + }, + data() { + return { + packageProtectionRuleFormData: { + packageNamePattern: '', + packageType: 'NPM', + pushProtectedUpToAccessLevel: GRAPHQL_ACCESS_LEVEL_VALUE_DEVELOPER, + }, + updateInProgress: false, + alertErrorMessage: '', + }; + }, + computed: { + showLoadingIcon() { + return this.updateInProgress; + }, + isEmptyPackageName() { + return !this.packageProtectionRuleFormData.packageNamePattern; + }, + isSubmitButtonDisabled() { + return this.isEmptyPackageName || this.showLoadingIcon; + }, + isFieldDisabled() { + return this.showLoadingIcon; + }, + createPackagesProtectionRuleMutationInput() { + return { + projectPath: this.projectPath, + packageNamePattern: this.packageProtectionRuleFormData.packageNamePattern, + packageType: this.packageProtectionRuleFormData.packageType, + pushProtectedUpToAccessLevel: this.packageProtectionRuleFormData + .pushProtectedUpToAccessLevel, + }; + }, + packageTypeOptions() { + return [{ value: 'NPM', text: s__('PackageRegistry|Npm') }]; + }, + pushProtectedUpToAccessLevelOptions() { + 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: createPackagesProtectionRuleMutation, + variables: { + input: this.createPackagesProtectionRuleMutationInput, + }, + }) + .then(({ data }) => { + const [errorMessage] = data?.createPackagesProtectionRule?.errors ?? []; + if (errorMessage) { + this.alertErrorMessage = errorMessage; + return; + } + + this.$emit('submit', data.createPackagesProtectionRule.packageProtectionRule); + }) + .catch(() => { + this.alertErrorMessage = PACKAGES_PROTECTION_RULES_SAVED_ERROR_MESSAGE; + }) + .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__('PackageRegistry|Package name pattern')" + label-for="input-package-name-pattern" + > + <gl-form-input + id="input-package-name-pattern" + v-model.trim="packageProtectionRuleFormData.packageNamePattern" + type="text" + required + :disabled="isFieldDisabled" + /> + </gl-form-group> + + <gl-form-group + :label="s__('PackageRegistry|Package type')" + label-for="input-package-type" + :disabled="isFieldDisabled" + > + <gl-form-select + id="input-package-type" + v-model="packageProtectionRuleFormData.packageType" + :disabled="isFieldDisabled" + :options="packageTypeOptions" + required + /> + </gl-form-group> + + <gl-form-group + :label="s__('PackageRegistry|Push protected up to access level')" + label-for="input-push-protected-up-to-access-level" + :disabled="isFieldDisabled" + > + <gl-form-select + id="input-push-protected-up-to-access-level" + v-model="packageProtectionRuleFormData.pushProtectedUpToAccessLevel" + :options="pushProtectedUpToAccessLevelOptions" + :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" + >{{ __('Protect') }}</gl-button + > + <gl-button class="gl-ml-3" type="reset">{{ __('Cancel') }}</gl-button> + </div> + </gl-form> + </div> +</template> 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 d917777880332d37a91b749c0f116b389e2b8d45..3391524a25d9bdc426147d1830a148d978c50cac 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,8 @@ <script> -import { GlCard, GlTable, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlCard, GlTable, GlLoadingIcon } from '@gitlab/ui'; import packagesProtectionRuleQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.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'; const PAGINATION_DEFAULT_PER_PAGE = 10; @@ -9,9 +10,11 @@ const PAGINATION_DEFAULT_PER_PAGE = 10; export default { components: { SettingsBlock, + GlButton, GlCard, GlTable, GlLoadingIcon, + PackagesProtectionRuleForm, }, inject: ['projectPath'], i18n: { @@ -24,6 +27,7 @@ export default { return { fetchSettingsError: false, packageProtectionRules: [], + protectionRuleFormVisibility: false, }; }, computed: { @@ -43,6 +47,9 @@ export default { isLoadingPackageProtectionRules() { return this.$apollo.queries.packageProtectionRules.loading; }, + isAddProtectionRuleButtonDisabled() { + return this.protectionRuleFormVisibility; + }, }, apollo: { packageProtectionRules: { @@ -72,6 +79,18 @@ export default { label: s__('PackageRegistry|Push protected up to access level'), }, ], + methods: { + showProtectionRuleForm() { + this.protectionRuleFormVisibility = true; + }, + hideProtectionRuleForm() { + this.protectionRuleFormVisibility = false; + }, + refetchProtectionRules() { + this.$apollo.queries.packageProtectionRules.refetch(); + this.hideProtectionRuleForm(); + }, + }, }; </script> @@ -92,16 +111,30 @@ 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__('PackageRegistry|Add package protection rule') }} + </gl-button> + </div> </div> </template> <template #default> + <packages-protection-rule-form + v-if="protectionRuleFormVisibility" + @cancel="hideProtectionRuleForm" + @submit="refetchProtectionRules" + /> + <gl-table :items="tableItems" :fields="$options.fields" show-empty stacked="md" - class="mb-3" :busy="isLoadingPackageProtectionRules" > <template #table-busy> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/create_packages_protection_rule.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/create_packages_protection_rule.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..923b9d2e4b26dc16d3ac01c157dadafb2c487ec3 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/create_packages_protection_rule.mutation.graphql @@ -0,0 +1,11 @@ +mutation createPackagesProtectionRule($input: CreatePackagesProtectionRuleInput!) { + createPackagesProtectionRule(input: $input) { + packageProtectionRule { + id + packageNamePattern + packageType + pushProtectedUpToAccessLevel + } + errors + } +} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5ac817e75e050fffc59e630cf977f7877505aa69..053a18852cbac1125b1b5e2451019779c93887f3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -34682,6 +34682,9 @@ msgstr "" msgid "PackageRegistry|Add composer registry" msgstr "" +msgid "PackageRegistry|Add package protection rule" +msgstr "" + msgid "PackageRegistry|Additional metadata" msgstr "" @@ -34937,6 +34940,9 @@ msgstr "" msgid "PackageRegistry|Maven XML" msgstr "" +msgid "PackageRegistry|Npm" +msgstr "" + msgid "PackageRegistry|NuGet" msgstr "" @@ -35044,6 +35050,9 @@ msgstr "" msgid "PackageRegistry|RubyGems" msgstr "" +msgid "PackageRegistry|Rule saved." +msgstr "" + msgid "PackageRegistry|Show Composer commands" msgstr "" @@ -35086,6 +35095,9 @@ msgstr "" msgid "PackageRegistry|Something went wrong while fetching the package metadata." msgstr "" +msgid "PackageRegistry|Something went wrong while saving the package protection rule." +msgstr "" + msgid "PackageRegistry|Sorry, your filter produced no results" msgstr "" diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rule_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rule_form_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..7697b7f6bd789204d7aade9657894252f7d86d21 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rule_form_spec.js @@ -0,0 +1,227 @@ +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 PackagesProtectionRuleForm from '~/packages_and_registries/settings/project/components/packages_protection_rule_form.vue'; +import createPackagesProtectionRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/create_packages_protection_rule.mutation.graphql'; +import { + createPackagesProtectionRuleMutationPayload, + createPackagesProtectionRuleMutationInput, + createPackagesProtectionRuleMutationPayloadErrors, +} from '../mock_data'; + +Vue.use(VueApollo); + +describe('Packages Protection Rule Form', () => { + let wrapper; + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + }; + + const findPackageNamePatternInput = () => + wrapper.findByRole('textbox', { name: /package name pattern/i }); + const findPackageTypeSelect = () => wrapper.findByRole('combobox', { name: /package type/i }); + const findPushProtectedUpToAccessLevelSelect = () => + wrapper.findByRole('combobox', { name: /push protected up to access level/i }); + const findSubmitButton = () => wrapper.findByRole('button', { name: /protect/i }); + const findForm = () => wrapper.findComponent(GlForm); + + const mountComponent = ({ data, config, provide = defaultProvidedValues } = {}) => { + wrapper = mountExtended(PackagesProtectionRuleForm, { + provide, + data() { + return { ...data }; + }, + ...config, + }); + }; + + const mountComponentWithApollo = ({ provide = defaultProvidedValues, mutationResolver } = {}) => { + const requestHandlers = [[createPackagesProtectionRuleMutation, mutationResolver]]; + + fakeApollo = createMockApollo(requestHandlers); + + mountComponent({ + provide, + config: { + apolloProvider: fakeApollo, + }, + }); + }; + + describe('form fields', () => { + describe('form field "packageType"', () => { + it('contains only the options for npm', () => { + mountComponent(); + + expect(findPackageTypeSelect().exists()).toBe(true); + const packageTypeSelectOptions = findPackageTypeSelect() + .findAll('option') + .wrappers.map((option) => option.element.value); + expect(packageTypeSelectOptions).toEqual(['NPM']); + }); + }); + + describe('form field "pushProtectedUpToAccessLevelSelect"', () => { + it('contains only the options for maintainer and owner', () => { + mountComponent(); + + expect(findPushProtectedUpToAccessLevelSelect().exists()).toBe(true); + const pushProtectedUpToAccessLevelSelectOptions = findPushProtectedUpToAccessLevelSelect() + .findAll('option') + .wrappers.map((option) => option.element.value); + expect(pushProtectedUpToAccessLevelSelectOptions).toEqual([ + 'DEVELOPER', + 'MAINTAINER', + 'OWNER', + ]); + }); + }); + + describe('when graphql mutation is in progress', () => { + beforeEach(() => { + mountComponentWithApollo(); + + findForm().trigger('submit'); + }); + + it('disables all form fields', () => { + expect(findSubmitButton().props('disabled')).toBe(true); + expect(findPackageNamePatternInput().attributes('disabled')).toBe('disabled'); + expect(findPackageTypeSelect().attributes('disabled')).toBe('disabled'); + expect(findPushProtectedUpToAccessLevelSelect().attributes('disabled')).toBe('disabled'); + }); + + it('displays a loading spinner', () => { + expect(findSubmitButton().props('loading')).toBe(true); + }); + }); + }); + + describe('form actions', () => { + describe('button "Protect"', () => { + it.each` + packageNamePattern | submitButtonDisabled + ${''} | ${true} + ${' '} | ${true} + ${createPackagesProtectionRuleMutationInput.packageNamePattern} | ${false} + `( + 'when packageNamePattern is "$packageNamePattern" then the disabled state of the submit button is $submitButtonDisabled', + async ({ packageNamePattern, submitButtonDisabled }) => { + mountComponent(); + + expect(findSubmitButton().props('disabled')).toBe(true); + + await findPackageNamePatternInput().setValue(packageNamePattern); + + expect(findSubmitButton().props('disabled')).toBe(submitButtonDisabled); + }, + ); + }); + }); + + describe('form events', () => { + describe('reset', () => { + const mutationResolver = jest + .fn() + .mockResolvedValue(createPackagesProtectionRuleMutationPayload()); + + 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(createPackagesProtectionRuleMutationPayload()); + + mountComponentWithApollo({ mutationResolver }); + + await findPackageNamePatternInput().setValue( + createPackagesProtectionRuleMutationInput.packageNamePattern, + ); + + await submitForm(); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { projectPath: 'path', ...createPackagesProtectionRuleMutationInput }, + }); + }); + + it('emits event "submit" when apollo mutation successful', async () => { + const mutationResolver = jest + .fn() + .mockResolvedValue(createPackagesProtectionRuleMutationPayload()); + + mountComponentWithApollo({ mutationResolver }); + + await submitForm(); + + expect(wrapper.emitted('submit')).toBeDefined(); + const expectedEventSubmitPayload = createPackagesProtectionRuleMutationPayload().data + .createPackagesProtectionRule.packageProtectionRule; + 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( + createPackagesProtectionRuleMutationPayload({ + errors: createPackagesProtectionRuleMutationPayloadErrors, + }), + ), + }); + + await submitForm(); + + expect(findAlert().isVisible()).toBe(true); + expect(findAlert().text()).toBe(createPackagesProtectionRuleMutationPayloadErrors[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()).toMatch( + /something went wrong while saving the package protection rule/i, + ); + }); + }); + }); +}); 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 bdb3db7a1b9b11919341e134079a58aeee232599..26ace764a72fcddc11c81de47086ce2679fa271b 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,11 +1,12 @@ -import { GlTable, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlTable, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PackagesProtectionRules from '~/packages_and_registries/settings/project/components/packages_protection_rules.vue'; -import SettingsBlock from '~/vue_shared/components/settings/settings_block.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'; @@ -22,6 +23,8 @@ describe('Packages protection rules project settings', () => { const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); const findTable = () => wrapper.findComponent(GlTable); const findTableLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findProtectionRuleForm = () => wrapper.findComponent(PackagesProtectionRuleForm); + const findAddProtectionRuleButton = () => wrapper.findComponent(GlButton); const findTableRows = () => findTable().find('tbody').findAll('tr'); const mountComponent = (mountFn = shallowMount, provide = defaultProvidedValues, config) => { @@ -94,4 +97,73 @@ describe('Packages protection rules project settings', () => { expect(findTable().exists()).toBe(true); }); }); + + it('does not initially render package protection form', async () => { + createComponent(); + + await waitForPromises(); + + expect(findAddProtectionRuleButton().exists()).toBe(true); + expect(findProtectionRuleForm().exists()).toBe(false); + }); + + describe('button "add protection rule"', () => { + it('button exists', async () => { + createComponent(); + + await waitForPromises(); + + expect(findAddProtectionRuleButton().exists()).toBe(true); + }); + + describe('when button is clicked', () => { + beforeEach(async () => { + createComponent({ mountFn: mount }); + + await waitForPromises(); + + await findAddProtectionRuleButton().trigger('click'); + }); + + it('renders package protection form', () => { + expect(findProtectionRuleForm().exists()).toBe(true); + }); + + it('disables the button "add protection rule"', () => { + expect(findAddProtectionRuleButton().attributes('disabled')).toBeDefined(); + }); + }); + }); + + describe('form "add protection rule"', () => { + let resolver; + + beforeEach(async () => { + resolver = jest.fn().mockResolvedValue(packagesProtectionRuleQueryPayload()); + + createComponent({ resolver, mountFn: mount }); + + await waitForPromises(); + + await findAddProtectionRuleButton().trigger('click'); + }); + + it("handles event 'submit'", async () => { + await findProtectionRuleForm().vm.$emit('submit'); + + expect(resolver).toHaveBeenCalledTimes(2); + + expect(findProtectionRuleForm().exists()).toBe(false); + expect(findAddProtectionRuleButton().attributes('disabled')).not.toBeDefined(); + }); + + it("handles event 'cancel'", async () => { + await findProtectionRuleForm().vm.$emit('cancel'); + + expect(resolver).toHaveBeenCalledTimes(1); + + expect(findProtectionRuleForm().exists()).toBe(false); + expect(findAddProtectionRuleButton().attributes()).not.toHaveProperty('disabled'); + }); + }); }); 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 5c546289b145e22ee4c9dfa504a68b655b0918ed..a8133c0ace6a1a4b1f31edb869d412727d1ceb71 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 @@ -112,3 +112,25 @@ export const packagesProtectionRuleQueryPayload = ({ override, errors = [] } = { }, }, }); + +export const createPackagesProtectionRuleMutationPayload = ({ override, errors = [] } = {}) => ({ + data: { + createPackagesProtectionRule: { + packageProtectionRule: { + ...packagesProtectionRulesData[0], + ...override, + }, + errors, + }, + }, +}); + +export const createPackagesProtectionRuleMutationInput = { + packageNamePattern: `@flight/flight-developer-14-*`, + packageType: 'NPM', + pushProtectedUpToAccessLevel: 'DEVELOPER', +}; + +export const createPackagesProtectionRuleMutationPayloadErrors = [ + 'Package name pattern has already been taken', +];