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 @@ + + + + + + + {{ alertErrorMessage }} + + + + + + + + + + + + + + + + {{ __('Protect') }} + {{ __('Cancel') }} + + + + 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 @@ @@ -92,16 +111,30 @@ export default { {{ $options.i18n.settingBlockTitle }} + + + {{ s__('PackageRegistry|Add package protection rule') }} + + + + 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 79c9e718590d7497650e59f9c4b03bea9fb95b44..49395a7188ce34101f45b0e3d922b4be8b4f39e8 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -34637,6 +34637,9 @@ msgstr "" msgid "PackageRegistry|Add composer registry" msgstr "" +msgid "PackageRegistry|Add package protection rule" +msgstr "" + msgid "PackageRegistry|Additional metadata" msgstr "" @@ -34892,6 +34895,9 @@ msgstr "" msgid "PackageRegistry|Maven XML" msgstr "" +msgid "PackageRegistry|Npm" +msgstr "" + msgid "PackageRegistry|NuGet" msgstr "" @@ -34999,6 +35005,9 @@ msgstr "" msgid "PackageRegistry|RubyGems" msgstr "" +msgid "PackageRegistry|Rule saved." +msgstr "" + msgid "PackageRegistry|Show Composer commands" msgstr "" @@ -35041,6 +35050,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', +];