diff options
Diffstat (limited to 'spec/frontend/packages_and_registries')
19 files changed, 1176 insertions, 175 deletions
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js index 0b59fe2d8ce..7da91c4af96 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js @@ -33,7 +33,7 @@ describe('Image List Row', () => { const findListItemComponent = () => wrapper.findComponent(ListItem); const findShowFullPathButton = () => wrapper.findComponent(GlButton); - const mountComponent = (props, features = {}) => { + const mountComponent = (props) => { wrapper = shallowMount(Component, { stubs: { RouterLink, @@ -47,9 +47,6 @@ describe('Image List Row', () => { }, provide: { config: {}, - glFeatures: { - ...features, - }, }, directives: { GlTooltip: createMockDirective(), @@ -88,23 +85,43 @@ describe('Image List Row', () => { }); describe('image title and path', () => { - it('contains a link to the details page', () => { + it('renders shortened name of image and contains a link to the details page', () => { mountComponent(); const link = findDetailsLink(); - expect(link.text()).toBe(item.path); - expect(findDetailsLink().props('to')).toMatchObject({ + expect(link.text()).toBe('gitlab-test/rails-12009'); + + expect(link.props('to')).toMatchObject({ name: 'details', params: { id: getIdFromGraphQLId(item.id), }, }); + + expect(findShowFullPathButton().exists()).toBe(true); }); it('when the image has no name lists the path', () => { mountComponent({ item: { ...item, name: '' } }); + expect(findDetailsLink().text()).toBe('gitlab-test'); + }); + + it('clicking on shortened name of image hides the button & shows full path', async () => { + mountComponent(); + + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + const mockFocusFn = jest.fn(); + wrapper.vm.$refs.imageName.$el.focus = mockFocusFn; + + await findShowFullPathButton().trigger('click'); + + expect(findShowFullPathButton().exists()).toBe(false); expect(findDetailsLink().text()).toBe(item.path); + expect(mockFocusFn).toHaveBeenCalled(); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_show_full_path', { + label: 'registry_image_list', + }); }); it('contains a clipboard button', () => { @@ -149,35 +166,6 @@ describe('Image List Row', () => { expect(findClipboardButton().attributes('disabled')).toBe('true'); }); }); - - describe('when containerRegistryShowShortenedPath feature enabled', () => { - let trackingSpy; - - beforeEach(() => { - mountComponent({}, { containerRegistryShowShortenedPath: true }); - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - it('renders shortened name of image', () => { - expect(findShowFullPathButton().exists()).toBe(true); - expect(findDetailsLink().text()).toBe('gitlab-test/rails-12009'); - }); - - it('clicking on shortened name of image hides the button & shows full path', async () => { - const btn = findShowFullPathButton(); - const mockFocusFn = jest.fn(); - wrapper.vm.$refs.imageName.$el.focus = mockFocusFn; - - await btn.trigger('click'); - - expect(findShowFullPathButton().exists()).toBe(false); - expect(findDetailsLink().text()).toBe(item.path); - expect(mockFocusFn).toHaveBeenCalled(); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_show_full_path', { - label: 'registry_image_list', - }); - }); - }); }); describe('delete button', () => { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js index e6d81d4a28f..bcc8e41fce8 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js @@ -58,6 +58,12 @@ describe('registry_header', () => { describe('sub header parts', () => { describe('images count', () => { + it('does not exist', async () => { + await mountComponent({ imagesCount: 0 }); + + expect(findImagesCountSubHeader().exists()).toBe(false); + }); + it('exists', async () => { await mountComponent({ imagesCount: 1 }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js index ee6470a9df8..310398b01cf 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js @@ -26,6 +26,7 @@ import { import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql'; +import getContainerRepositoriesDetails from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql'; import component from '~/packages_and_registries/container_registry/explorer/pages/details.vue'; import Tracking from '~/tracking'; @@ -34,6 +35,7 @@ import { graphQLImageDetailsMock, graphQLDeleteImageRepositoryTagsMock, graphQLDeleteImageRepositoryTagImportingErrorMock, + graphQLProjectImageRepositoriesDetailsMock, containerRepositoryMock, graphQLEmptyImageDetailsMock, tagsMock, @@ -64,6 +66,9 @@ describe('Details Page', () => { const defaultConfig = { noContainersImage: 'noContainersImage', + projectListUrl: 'projectListUrl', + groupListUrl: 'groupListUrl', + isGroupPage: false, }; const cleanTags = tagsMock.map((t) => { @@ -81,7 +86,8 @@ describe('Details Page', () => { const mountComponent = ({ resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()), mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock), - tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock)), + tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock())), + detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock), options, config = defaultConfig, } = {}) => { @@ -91,6 +97,7 @@ describe('Details Page', () => { [getContainerRepositoryDetailsQuery, resolver], [deleteContainerRepositoryTagsMutation, mutationResolver], [getContainerRepositoryTagsQuery, tagsResolver], + [getContainerRepositoriesDetails, detailsResolver], ]; apolloProvider = createMockApollo(requestHandlers); @@ -256,11 +263,13 @@ describe('Details Page', () => { describe('confirmDelete event', () => { let mutationResolver; let tagsResolver; + let detailsResolver; beforeEach(() => { mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock); - tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock)); - mountComponent({ mutationResolver, tagsResolver }); + tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock())); + detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ mutationResolver, tagsResolver, detailsResolver }); return waitForApolloRequestRender(); }); @@ -280,6 +289,7 @@ describe('Details Page', () => { await waitForPromises(); expect(tagsResolver).toHaveBeenCalled(); + expect(detailsResolver).toHaveBeenCalled(); }); }); @@ -298,6 +308,7 @@ describe('Details Page', () => { await waitForPromises(); expect(tagsResolver).toHaveBeenCalled(); + expect(detailsResolver).toHaveBeenCalled(); }); }); }); @@ -359,14 +370,16 @@ describe('Details Page', () => { describe('importing repository error', () => { let mutationResolver; let tagsResolver; + let detailsResolver; beforeEach(async () => { mutationResolver = jest .fn() .mockResolvedValue(graphQLDeleteImageRepositoryTagImportingErrorMock); - tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock)); + tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock())); + detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); - mountComponent({ mutationResolver, tagsResolver }); + mountComponent({ mutationResolver, tagsResolver, detailsResolver }); await waitForApolloRequestRender(); }); @@ -378,6 +391,7 @@ describe('Details Page', () => { await waitForPromises(); expect(tagsResolver).toHaveBeenCalled(); + expect(detailsResolver).toHaveBeenCalled(); const deleteAlert = findDeleteAlert(); expect(deleteAlert.exists()).toBe(true); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js index fb5ee4e6884..0164d92ce34 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js @@ -1,12 +1,13 @@ -import { GlTable, GlPagination, GlModal } from '@gitlab/ui'; +import { GlTable, GlPagination } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import { last } from 'lodash'; import Vuex from 'vuex'; import stubChildren from 'helpers/stub_children'; import PackagesList from '~/packages_and_registries/infrastructure_registry/list/components/packages_list.vue'; import PackagesListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue'; import { TRACKING_ACTIONS } from '~/packages_and_registries/shared/constants'; import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants'; import Tracking from '~/tracking'; @@ -22,7 +23,7 @@ describe('packages_list', () => { const findPackagesListLoader = () => wrapper.findComponent(PackagesListLoader); const findPackageListPagination = () => wrapper.findComponent(GlPagination); - const findPackageListDeleteModal = () => wrapper.findComponent(GlModal); + const findPackageListDeleteModal = () => wrapper.findComponent(DeletePackageModal); const findEmptySlot = () => wrapper.findComponent(EmptySlotStub); const findPackagesListRow = () => wrapper.findComponent(PackagesListRow); @@ -65,7 +66,7 @@ describe('packages_list', () => { stubs: { ...stubChildren(PackagesList), GlTable, - GlModal, + DeletePackageModal, }, ...options, }); @@ -109,52 +110,38 @@ describe('packages_list', () => { expect(sorting.exists()).toBe(true); }); - it('contains a modal component', () => { - const sorting = findPackageListDeleteModal(); - expect(sorting.exists()).toBe(true); + it("doesn't contain a modal component", () => { + expect(findPackageListDeleteModal().props('itemToBeDeleted')).toBeNull(); }); }); describe('when the user can destroy the package', () => { - beforeEach(() => { + let itemToBeDeleted; + + beforeEach(async () => { mountComponent(); + itemToBeDeleted = last(packageList); + await findPackagesListRow().vm.$emit('packageToDelete', itemToBeDeleted); }); - it('setItemToBeDeleted sets itemToBeDeleted and open the modal', async () => { - const mockModalShow = jest.spyOn(wrapper.vm.$refs.packageListDeleteModal, 'show'); - const item = last(wrapper.vm.list); - - findPackagesListRow().vm.$emit('packageToDelete', item); - - await nextTick(); - expect(wrapper.vm.itemToBeDeleted).toEqual(item); - expect(mockModalShow).toHaveBeenCalled(); + afterEach(() => { + itemToBeDeleted = null; }); - it('deleteItemConfirmation resets itemToBeDeleted', () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ itemToBeDeleted: 1 }); - wrapper.vm.deleteItemConfirmation(); - expect(wrapper.vm.itemToBeDeleted).toEqual(null); + it('passes itemToBeDeleted to the modal', () => { + expect(findPackageListDeleteModal().props('itemToBeDeleted')).toStrictEqual(itemToBeDeleted); }); it('deleteItemConfirmation emit package:delete', async () => { - const itemToBeDeleted = { id: 2 }; - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ itemToBeDeleted }); - wrapper.vm.deleteItemConfirmation(); - await nextTick(); + await findPackageListDeleteModal().vm.$emit('ok'); + expect(wrapper.emitted('package:delete')[0]).toEqual([itemToBeDeleted]); }); - it('deleteItemCanceled resets itemToBeDeleted', () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ itemToBeDeleted: 1 }); - wrapper.vm.deleteItemCanceled(); - expect(wrapper.vm.itemToBeDeleted).toEqual(null); + it.each(['ok', 'cancel'])('resets itemToBeDeleted when modal emits %s', async (event) => { + await findPackageListDeleteModal().vm.$emit(event); + + expect(findPackageListDeleteModal().props('itemToBeDeleted')).toBeNull(); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/delete_modal_spec.js b/spec/frontend/packages_and_registries/package_registry/components/delete_modal_spec.js new file mode 100644 index 00000000000..e0e26434680 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/delete_modal_spec.js @@ -0,0 +1,71 @@ +import { GlModal as RealGlModal } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; +import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue'; + +const GlModal = stubComponent(RealGlModal, { + methods: { + show: jest.fn(), + }, +}); + +describe('DeleteModal', () => { + let wrapper; + + const defaultItemsToBeDeleted = [ + { + name: 'package 01', + }, + { + name: 'package 02', + }, + ]; + + const findModal = () => wrapper.findComponent(GlModal); + + const mountComponent = ({ itemsToBeDeleted = defaultItemsToBeDeleted } = {}) => { + wrapper = shallowMountExtended(DeleteModal, { + propsData: { + itemsToBeDeleted, + }, + stubs: { + GlModal, + }, + }); + }; + + beforeEach(() => { + mountComponent(); + }); + + it('passes title prop', () => { + expect(findModal().props('title')).toMatchInterpolatedText('Delete packages'); + }); + + it('passes actionPrimary prop', () => { + expect(findModal().props('actionPrimary')).toStrictEqual({ + text: 'Permanently delete', + attributes: [{ variant: 'danger' }, { category: 'primary' }], + }); + }); + + it('renders description', () => { + expect(findModal().text()).toContain( + 'You are about to delete 2 packages. This operation is irreversible.', + ); + }); + + it('emits confirm when primary event is emitted', () => { + expect(wrapper.emitted('confirm')).toBeUndefined(); + + findModal().vm.$emit('primary'); + + expect(wrapper.emitted('confirm')).toHaveLength(1); + }); + + it('show calls gl-modal show', () => { + findModal().vm.show(); + + expect(GlModal.methods.show).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js new file mode 100644 index 00000000000..f0fa9592419 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js @@ -0,0 +1,152 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; +import { packageData } from '../../mock_data'; + +describe('PackageVersionsList', () => { + let wrapper; + + const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>empty message</div>' }; + const packageList = [ + packageData({ + name: 'version 1', + }), + packageData({ + id: `gid://gitlab/Packages::Package/112`, + name: 'version 2', + }), + ]; + + const uiElements = { + findLoader: () => wrapper.findComponent(PackagesListLoader), + findListPagination: () => wrapper.findComponent(GlKeysetPagination), + findEmptySlot: () => wrapper.findComponent(EmptySlotStub), + findListRow: () => wrapper.findAllComponents(VersionRow), + }; + const mountComponent = (props) => { + wrapper = shallowMountExtended(PackageVersionsList, { + propsData: { + versions: packageList, + pageInfo: {}, + isLoading: false, + ...props, + }, + slots: { + 'empty-state': EmptySlotStub, + }, + }); + }; + + describe('when list is loading', () => { + beforeEach(() => { + mountComponent({ isLoading: true, versions: [] }); + }); + it('displays loader', () => { + expect(uiElements.findLoader().exists()).toBe(true); + }); + + it('does not display rows', () => { + expect(uiElements.findListRow().exists()).toBe(false); + }); + + it('does not display empty slot message', () => { + expect(uiElements.findEmptySlot().exists()).toBe(false); + }); + + it('does not display pagination', () => { + expect(uiElements.findListPagination().exists()).toBe(false); + }); + }); + + describe('when list is loaded and has no data', () => { + beforeEach(() => { + mountComponent({ isLoading: false, versions: [] }); + }); + + it('displays empty slot message', () => { + expect(uiElements.findEmptySlot().exists()).toBe(true); + }); + + it('does not display loader', () => { + expect(uiElements.findLoader().exists()).toBe(false); + }); + + it('does not display rows', () => { + expect(uiElements.findListRow().exists()).toBe(false); + }); + + it('does not display pagination', () => { + expect(uiElements.findListPagination().exists()).toBe(false); + }); + }); + + describe('when list is loaded with data', () => { + beforeEach(() => { + mountComponent(); + }); + + it('displays package version rows', () => { + expect(uiElements.findListRow().exists()).toEqual(true); + expect(uiElements.findListRow()).toHaveLength(packageList.length); + }); + + it('binds the correct props', () => { + expect(uiElements.findListRow().at(0).props()).toMatchObject({ + packageEntity: expect.objectContaining(packageList[0]), + }); + + expect(uiElements.findListRow().at(1).props()).toMatchObject({ + packageEntity: expect.objectContaining(packageList[1]), + }); + }); + + describe('pagination display', () => { + it('does not display pagination if there is no previous or next page', () => { + expect(uiElements.findListPagination().exists()).toBe(false); + }); + + it('displays pagination if pageInfo.hasNextPage is true', async () => { + await wrapper.setProps({ pageInfo: { hasNextPage: true } }); + expect(uiElements.findListPagination().exists()).toBe(true); + }); + + it('displays pagination if pageInfo.hasPreviousPage is true', async () => { + await wrapper.setProps({ pageInfo: { hasPreviousPage: true } }); + expect(uiElements.findListPagination().exists()).toBe(true); + }); + + it('displays pagination if both pageInfo.hasNextPage and pageInfo.hasPreviousPage are true', async () => { + await wrapper.setProps({ pageInfo: { hasNextPage: true, hasPreviousPage: true } }); + expect(uiElements.findListPagination().exists()).toBe(true); + }); + }); + + it('does not display loader', () => { + expect(uiElements.findLoader().exists()).toBe(false); + }); + + it('does not display empty slot message', () => { + expect(uiElements.findEmptySlot().exists()).toBe(false); + }); + }); + + describe('when user interacts with pagination', () => { + beforeEach(() => { + mountComponent({ pageInfo: { hasNextPage: true } }); + }); + + it('emits prev-page event when paginator emits prev event', () => { + uiElements.findListPagination().vm.$emit('prev'); + + expect(wrapper.emitted('prev-page')).toHaveLength(1); + }); + + it('emits next-page when paginator emits next event', () => { + uiElements.findListPagination().vm.$emit('next'); + + expect(wrapper.emitted('next-page')).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap index 5be05ddf629..a7de751aadd 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap @@ -8,7 +8,14 @@ exports[`packages_list_row renders 1`] = ` <div class="gl-display-flex gl-align-items-center gl-py-3" > - <!----> + <div + class="gl-w-7 gl-display-flex gl-justify-content-start gl-pl-2" + > + <gl-form-checkbox-stub + class="gl-m-0" + id="2" + /> + </div> <div class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1" diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js index b5a512b8806..913b4f5926f 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -1,4 +1,4 @@ -import { GlSprintf } from '@gitlab/ui'; +import { GlFormCheckbox, GlSprintf } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -40,9 +40,11 @@ describe('packages_list_row', () => { const findPublishMethod = () => wrapper.findComponent(PublishMethod); const findCreatedDateText = () => wrapper.findByTestId('created-date'); const findTimeAgoTooltip = () => wrapper.findComponent(TimeagoTooltip); + const findBulkDeleteAction = () => wrapper.findComponent(GlFormCheckbox); const mountComponent = ({ packageEntity = packageWithoutTags, + selected = false, provide = defaultProvide, } = {}) => { wrapper = shallowMountExtended(PackagesListRow, { @@ -53,6 +55,7 @@ describe('packages_list_row', () => { }, propsData: { packageEntity, + selected, }, directives: { GlTooltip: createMockDirective(), @@ -117,14 +120,13 @@ describe('packages_list_row', () => { }); }); - it('emits the packageToDelete event when the delete button is clicked', async () => { + it('emits the delete event when the delete button is clicked', async () => { mountComponent({ packageEntity: packageWithoutTags }); findDeleteDropdown().vm.$emit('click'); await nextTick(); - expect(wrapper.emitted('packageToDelete')).toHaveLength(1); - expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]); + expect(wrapper.emitted('delete')).toHaveLength(1); }); }); @@ -151,6 +153,39 @@ describe('packages_list_row', () => { }); }); + describe('left action template', () => { + it('does not render checkbox if not permitted', () => { + mountComponent({ + packageEntity: { ...packageWithoutTags, canDestroy: false }, + }); + + expect(findBulkDeleteAction().exists()).toBe(false); + }); + + it('renders checkbox', () => { + mountComponent(); + + expect(findBulkDeleteAction().exists()).toBe(true); + expect(findBulkDeleteAction().attributes('checked')).toBeUndefined(); + }); + + it('emits select when checked', () => { + mountComponent(); + + findBulkDeleteAction().vm.$emit('change'); + + expect(wrapper.emitted('select')).toHaveLength(1); + }); + + it('renders checkbox in selected state if selected', () => { + mountComponent({ + selected: true, + }); + + expect(findBulkDeleteAction().attributes('checked')).toBe('true'); + }); + }); + describe('secondary left info', () => { it('has the package version', () => { mountComponent(); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js index 3e3607a361c..7cc5bea0f7a 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js @@ -1,8 +1,10 @@ -import { GlAlert, GlKeysetPagination, GlModal, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlSprintf } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; import { DELETE_PACKAGE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_TRACKING_ACTION, @@ -35,16 +37,11 @@ describe('packages_list', () => { }; const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' }; - const GlModalStub = { - name: GlModal.name, - template: '<div><slot></slot></div>', - methods: { show: jest.fn() }, - }; const findPackagesListLoader = () => wrapper.findComponent(PackagesListLoader); - const findPackageListPagination = () => wrapper.findComponent(GlKeysetPagination); - const findPackageListDeleteModal = () => wrapper.findComponent(GlModalStub); + const findPackageListDeleteModal = () => wrapper.findComponent(DeletePackageModal); const findEmptySlot = () => wrapper.findComponent(EmptySlotStub); + const findRegistryList = () => wrapper.findComponent(RegistryList); const findPackagesListRow = () => wrapper.findComponent(PackagesListRow); const findErrorPackageAlert = () => wrapper.findComponent(GlAlert); @@ -55,8 +52,9 @@ describe('packages_list', () => { ...props, }, stubs: { - GlModal: GlModalStub, + DeletePackageModal, GlSprintf, + RegistryList, }, slots: { 'empty-state': EmptySlotStub, @@ -64,10 +62,6 @@ describe('packages_list', () => { }); }; - beforeEach(() => { - GlModalStub.methods.show.mockReset(); - }); - afterEach(() => { wrapper.destroy(); }); @@ -81,12 +75,12 @@ describe('packages_list', () => { expect(findPackagesListLoader().exists()).toBe(true); }); - it('does not show the rows', () => { - expect(findPackagesListRow().exists()).toBe(false); + it('does not show the registry list', () => { + expect(findRegistryList().exists()).toBe(false); }); - it('does not show the pagination', () => { - expect(findPackageListPagination().exists()).toBe(false); + it('does not show the rows', () => { + expect(findPackagesListRow().exists()).toBe(false); }); }); @@ -99,22 +93,29 @@ describe('packages_list', () => { expect(findPackagesListLoader().exists()).toBe(false); }); + it('shows the registry list', () => { + expect(findRegistryList().exists()).toBe(true); + }); + + it('shows the registry list with the right props', () => { + expect(findRegistryList().props()).toMatchObject({ + title: '2 packages', + items: defaultProps.list, + pagination: defaultProps.pageInfo, + isLoading: false, + }); + }); + it('shows the rows', () => { expect(findPackagesListRow().exists()).toBe(true); }); }); describe('layout', () => { - it('contains a pagination component', () => { - mountComponent({ pageInfo: { hasPreviousPage: true } }); - - expect(findPackageListPagination().exists()).toBe(true); - }); - - it('contains a modal component', () => { + it("doesn't contain a visible modal component", () => { mountComponent(); - expect(findPackageListDeleteModal().exists()).toBe(true); + expect(findPackageListDeleteModal().props('itemToBeDeleted')).toBeNull(); }); it('does not have an error alert displayed', () => { @@ -125,31 +126,46 @@ describe('packages_list', () => { }); describe('when the user can destroy the package', () => { - beforeEach(() => { + beforeEach(async () => { mountComponent(); - findPackagesListRow().vm.$emit('packageToDelete', firstPackage); - return nextTick(); + await findPackagesListRow().vm.$emit('delete', firstPackage); }); - it('deleting a package opens the modal', () => { - expect(findPackageListDeleteModal().text()).toContain(firstPackage.name); + it('passes itemToBeDeleted to the modal', () => { + expect(findPackageListDeleteModal().props('itemToBeDeleted')).toStrictEqual(firstPackage); }); - it('confirming on the modal emits package:delete', async () => { - findPackageListDeleteModal().vm.$emit('ok'); - - await nextTick(); + it('emits package:delete when modal confirms', async () => { + await findPackageListDeleteModal().vm.$emit('ok'); expect(wrapper.emitted('package:delete')[0]).toEqual([firstPackage]); }); - it('closing the modal resets itemToBeDeleted', async () => { - // triggering the v-model - findPackageListDeleteModal().vm.$emit('input', false); + it.each(['ok', 'cancel'])('resets itemToBeDeleted when modal emits %s', async (event) => { + await findPackageListDeleteModal().vm.$emit(event); - await nextTick(); + expect(findPackageListDeleteModal().props('itemToBeDeleted')).toBeNull(); + }); + }); + + describe('when the user can bulk destroy packages', () => { + beforeEach(() => { + mountComponent(); + }); + + it('passes itemToBeDeleted to the modal when there is only one package', async () => { + await findRegistryList().vm.$emit('delete', [firstPackage]); + + expect(findPackageListDeleteModal().props('itemToBeDeleted')).toStrictEqual(firstPackage); + expect(wrapper.emitted('delete')).toBeUndefined(); + }); + + it('emits delete when there is more than one package', () => { + const items = [firstPackage, secondPackage]; + findRegistryList().vm.$emit('delete', items); - expect(findPackageListDeleteModal().text()).not.toContain(firstPackage.name); + expect(wrapper.emitted('delete')).toHaveLength(1); + expect(wrapper.emitted('delete')[0]).toEqual([items]); }); }); @@ -196,15 +212,15 @@ describe('packages_list', () => { }); it('emits prev-page events when the prev event is fired', () => { - findPackageListPagination().vm.$emit('prev'); + findRegistryList().vm.$emit('prev-page'); - expect(wrapper.emitted('prev-page')).toEqual([[]]); + expect(wrapper.emitted('prev-page')).toHaveLength(1); }); it('emits next-page events when the next event is fired', () => { - findPackageListPagination().vm.$emit('next'); + findRegistryList().vm.$emit('next-page'); - expect(wrapper.emitted('next-page')).toEqual([[]]); + expect(wrapper.emitted('next-page')).toHaveLength(1); }); }); @@ -215,7 +231,7 @@ describe('packages_list', () => { beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); mountComponent(); - findPackagesListRow().vm.$emit('packageToDelete', firstPackage); + findPackagesListRow().vm.$emit('delete', firstPackage); return nextTick(); }); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index c2b6fb734d6..f36c5923532 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -233,6 +233,12 @@ export const packageDetailsQuery = (extendPackage) => ({ }, versions: { nodes: packageVersions(), + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + endCursor: 'endCursor', + startCursor: 'startCursor', + }, __typename: 'PackageConnection', }, dependencyLinks: { @@ -288,6 +294,33 @@ export const packageDestroyMutation = () => ({ }, }); +export const packagesDestroyMutation = () => ({ + data: { + destroyPackages: { + errors: [], + }, + }, +}); + +export const packagesDestroyMutationError = () => ({ + data: { + destroyPackages: null, + }, + errors: [ + { + message: + "The resource that you are attempting to access does not exist or you don't have permission to perform this action", + locations: [ + { + line: 2, + column: 3, + }, + ], + path: ['destroyPackages'], + }, + ], +}); + export const packageDestroyMutationError = () => ({ data: { destroyPackage: null, @@ -314,6 +347,7 @@ export const packageDestroyFilesMutation = () => ({ }, }, }); + export const packageDestroyFilesMutationError = () => ({ data: { destroyPackageFiles: null, diff --git a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap index 17905a8db2d..c2fecf87428 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap @@ -2,12 +2,62 @@ exports[`PackagesListApp renders 1`] = ` <div> + <!----> + + <gl-card-stub + bodyclass="gl-display-flex gl-p-0!" + class="gl-px-8 gl-py-6 gl-line-height-20 gl-mt-3" + footerclass="" + headerclass="" + > + <!----> + + <div + class="gl-banner-content" + > + <h2 + class="gl-banner-title" + > + Help us learn about your registry migration needs + </h2> + + <p> + If you are interested in migrating packages from your private registry to the GitLab Package Registry, take our survey and tell us more about your needs. + </p> + + <gl-button-stub + buttontextclasses="" + category="primary" + data-testid="gl-banner-primary-button" + href="https://gitlab.fra1.qualtrics.com/jfe/form/SV_cHomH9FPzOaiDTU" + icon="" + size="medium" + variant="confirm" + > + Take survey + </gl-button-stub> + + </div> + + <gl-button-stub + aria-label="Close banner" + buttontextclasses="" + category="tertiary" + class="gl-banner-close" + icon="close" + size="small" + variant="default" + /> + </gl-card-stub> + <package-title-stub count="2" helpurl="/help/user/packages/index" /> - <package-search-stub /> + <package-search-stub + class="gl-mb-5" + /> <div> <section @@ -69,5 +119,7 @@ exports[`PackagesListApp renders 1`] = ` </div> </section> </div> + + <div /> </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js index a32e76a132e..f942a334f40 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js @@ -15,8 +15,8 @@ import InstallationCommands from '~/packages_and_registries/package_registry/com import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue'; import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; -import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; +import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue'; import { FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, PACKAGE_TYPE_COMPOSER, @@ -99,6 +99,7 @@ describe('PackagesApp', () => { GlSprintf, GlTabs, GlTab, + PackageVersionsList, }, mocks: { $route: { @@ -120,8 +121,7 @@ describe('PackagesApp', () => { const findPackageFiles = () => wrapper.findComponent(PackageFiles); const findDeleteFileModal = () => wrapper.findByTestId('delete-file-modal'); const findDeleteFilesModal = () => wrapper.findByTestId('delete-files-modal'); - const findVersionRows = () => wrapper.findAllComponents(VersionRow); - const noVersionsMessage = () => wrapper.findByTestId('no-versions-message'); + const findVersionsList = () => wrapper.findComponent(PackageVersionsList); const findDependenciesCountBadge = () => wrapper.findComponent(GlBadge); const findNoDependenciesMessage = () => wrapper.findByTestId('no-dependencies-message'); const findDependencyRows = () => wrapper.findAllComponents(DependencyRow); @@ -558,38 +558,23 @@ describe('PackagesApp', () => { }); describe('versions', () => { - it('displays the correct version count when the package has versions', async () => { + it('displays versions list when the package has versions', async () => { createComponent(); await waitForPromises(); - expect(findVersionRows()).toHaveLength(packageVersions().length); + expect(findVersionsList()).toBeDefined(); }); it('binds the correct props', async () => { - const [versionPackage] = packageVersions(); - // eslint-disable-next-line no-underscore-dangle - delete versionPackage.__typename; - delete versionPackage.tags; - - createComponent(); - + const versionNodes = packageVersions(); + createComponent({ packageEntity: { versions: { nodes: versionNodes } } }); await waitForPromises(); - expect(findVersionRows().at(0).props()).toMatchObject({ - packageEntity: expect.objectContaining(versionPackage), + expect(findVersionsList().props()).toMatchObject({ + versions: expect.arrayContaining(versionNodes), }); }); - - it('displays the no versions message when there are none', async () => { - createComponent({ - resolver: jest.fn().mockResolvedValue(packageDetailsQuery({ versions: { nodes: [] } })), - }); - - await waitForPromises(); - - expect(noVersionsMessage().exists()).toBe(true); - }); }); describe('dependency links', () => { it('does not show the dependency links for a non nuget package', async () => { diff --git a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js index 0e74fbbc6d9..abdb875e839 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js @@ -1,30 +1,39 @@ -import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlAlert, GlBanner, GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; - +import * as utils from '~/lib/utils/common_utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; import ListPage from '~/packages_and_registries/package_registry/pages/list.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; import OriginalPackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; - +import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue'; import { PROJECT_RESOURCE_TYPE, GROUP_RESOURCE_TYPE, GRAPHQL_PAGE_SIZE, + HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE, EMPTY_LIST_HELP_URL, PACKAGE_HELP_URL, + DELETE_PACKAGES_ERROR_MESSAGE, + DELETE_PACKAGES_SUCCESS_MESSAGE, } from '~/packages_and_registries/package_registry/constants'; import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; +import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql'; +import { + packagesListQuery, + packageData, + pagination, + packagesDestroyMutation, + packagesDestroyMutationError, +} from '../mock_data'; -import { packagesListQuery, packageData, pagination } from '../mock_data'; - -jest.mock('~/lib/utils/common_utils'); jest.mock('~/flash'); describe('PackagesListApp', () => { @@ -49,31 +58,44 @@ describe('PackagesListApp', () => { filters: { packageName: 'foo', packageType: 'CONAN' }, }; + const findAlert = () => wrapper.findComponent(GlAlert); + const findBanner = () => wrapper.findComponent(GlBanner); const findPackageTitle = () => wrapper.findComponent(PackageTitle); const findSearch = () => wrapper.findComponent(PackageSearch); const findListComponent = () => wrapper.findComponent(PackageList); const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findDeletePackage = () => wrapper.findComponent(DeletePackage); + const findDeletePackagesModal = () => wrapper.findComponent(DeleteModal); const mountComponent = ({ resolver = jest.fn().mockResolvedValue(packagesListQuery()), + mutationResolver, provide = defaultProvide, } = {}) => { Vue.use(VueApollo); - const requestHandlers = [[getPackagesQuery, resolver]]; + const requestHandlers = [ + [getPackagesQuery, resolver], + [destroyPackagesMutation, mutationResolver], + ]; apolloProvider = createMockApollo(requestHandlers); wrapper = shallowMountExtended(ListPage, { apolloProvider, provide, stubs: { + GlBanner, GlEmptyState, GlLoadingIcon, GlSprintf, GlLink, PackageList, DeletePackage, + DeleteModal: stubComponent(DeleteModal, { + methods: { + show: jest.fn(), + }, + }), }, }); }; @@ -116,6 +138,70 @@ describe('PackagesListApp', () => { }); }); + describe('package migration survey banner', () => { + describe('with no cookie set', () => { + beforeEach(() => { + utils.setCookie = jest.fn(); + + mountComponent(); + }); + + it('displays the banner', () => { + expect(findBanner().exists()).toBe(true); + }); + + it('does not call setCookie', () => { + expect(utils.setCookie).not.toHaveBeenCalled(); + }); + + describe('when the close button is clicked', () => { + beforeEach(() => { + findBanner().vm.$emit('close'); + }); + + it('sets the dismissed cookie', () => { + expect(utils.setCookie).toHaveBeenCalledWith( + HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE, + 'true', + ); + }); + + it('does not display the banner', () => { + expect(findBanner().exists()).toBe(false); + }); + }); + + describe('when the primary button is clicked', () => { + beforeEach(() => { + findBanner().vm.$emit('primary'); + }); + + it('sets the dismissed cookie', () => { + expect(utils.setCookie).toHaveBeenCalledWith( + HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE, + 'true', + ); + }); + + it('does not display the banner', () => { + expect(findBanner().exists()).toBe(false); + }); + }); + }); + + describe('with the dismissed cookie set', () => { + beforeEach(() => { + jest.spyOn(utils, 'getCookie').mockReturnValue('true'); + + mountComponent(); + }); + + it('does not display the banner', () => { + expect(findBanner().exists()).toBe(false); + }); + }); + }); + describe('search component', () => { it('exists', () => { mountComponent(); @@ -282,4 +368,62 @@ describe('PackagesListApp', () => { expect(findListComponent().props('isLoading')).toBe(false); }); }); + + describe('bulk delete package', () => { + const items = [{ id: '1' }, { id: '2' }]; + + it('deletePackage is bound to package-list package:delete event', async () => { + mountComponent(); + + await waitForFirstRequest(); + + findListComponent().vm.$emit('delete', [{ id: '1' }, { id: '2' }]); + + await waitForPromises(); + + expect(findDeletePackagesModal().props('itemsToBeDeleted')).toEqual(items); + }); + + it('calls mutation with the right values and shows success alert', async () => { + const mutationResolver = jest.fn().mockResolvedValue(packagesDestroyMutation()); + mountComponent({ + mutationResolver, + }); + + await waitForFirstRequest(); + + findListComponent().vm.$emit('delete', items); + + findDeletePackagesModal().vm.$emit('confirm'); + + expect(mutationResolver).toHaveBeenCalledWith({ + ids: items.map((item) => item.id), + }); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().props('variant')).toEqual('success'); + expect(findAlert().text()).toMatchInterpolatedText(DELETE_PACKAGES_SUCCESS_MESSAGE); + }); + + it('on error shows danger alert', async () => { + const mutationResolver = jest.fn().mockResolvedValue(packagesDestroyMutationError()); + mountComponent({ + mutationResolver, + }); + + await waitForFirstRequest(); + + findListComponent().vm.$emit('delete', items); + + findDeletePackagesModal().vm.$emit('confirm'); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().props('variant')).toEqual('danger'); + expect(findAlert().text()).toMatchInterpolatedText(DELETE_PACKAGES_ERROR_MESSAGE); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/forwarding_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/forwarding_settings_spec.js new file mode 100644 index 00000000000..8f229182fe5 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/forwarding_settings_spec.js @@ -0,0 +1,78 @@ +import { GlFormGroup, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import component from '~/packages_and_registries/settings/group/components/forwarding_settings.vue'; + +describe('Forwarding Settings', () => { + let wrapper; + + const defaultProps = { + disabled: false, + forwarding: false, + label: 'label', + lockForwarding: false, + modelNames: { + forwarding: 'forwardField', + lockForwarding: 'lockForwardingField', + isLocked: 'lockedField', + }, + }; + + const mountComponent = (propsData = defaultProps) => { + wrapper = shallowMountExtended(component, { + propsData, + stubs: { + GlSprintf, + }, + }); + }; + + const findFormGroup = () => wrapper.findComponent(GlFormGroup); + const findForwardingCheckbox = () => wrapper.findByTestId('forwarding-checkbox'); + const findLockForwardingCheckbox = () => wrapper.findByTestId('lock-forwarding-checkbox'); + + it('has a form group', () => { + mountComponent(); + + expect(findFormGroup().exists()).toBe(true); + expect(findFormGroup().attributes()).toMatchObject({ + label: defaultProps.label, + }); + }); + + describe.each` + name | finder | label | extraProps | field + ${'forwarding'} | ${findForwardingCheckbox} | ${'Forward label package requests'} | ${{ forwarding: true }} | ${defaultProps.modelNames.forwarding} + ${'lock forwarding'} | ${findLockForwardingCheckbox} | ${'Enforce label setting for all subgroups'} | ${{ lockForwarding: true }} | ${defaultProps.modelNames.lockForwarding} + `('$name checkbox', ({ name, finder, label, extraProps, field }) => { + it('is rendered', () => { + mountComponent(); + expect(finder().exists()).toBe(true); + expect(finder().text()).toMatchInterpolatedText(label); + expect(finder().attributes('disabled')).toBeUndefined(); + expect(finder().attributes('checked')).toBeUndefined(); + }); + + it(`is checked when ${name} set`, () => { + mountComponent({ ...defaultProps, ...extraProps }); + + expect(finder().attributes('checked')).toBe('true'); + }); + + it(`emits an update event with field ${field} set`, () => { + mountComponent(); + + finder().vm.$emit('change', true); + + expect(wrapper.emitted('update')).toStrictEqual([[field, true]]); + }); + }); + + describe('disabled', () => { + it('disables both checkboxes', () => { + mountComponent({ ...defaultProps, disabled: true }); + + expect(findForwardingCheckbox().attributes('disabled')).toEqual('true'); + expect(findLockForwardingCheckbox().attributes('disabled')).toEqual('true'); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js index 31fc3ad419c..7edc321867c 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js @@ -7,6 +7,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue'; import DependencyProxySettings from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue'; +import PackagesForwardingSettings from '~/packages_and_registries/settings/group/components/packages_forwarding_settings.vue'; import component from '~/packages_and_registries/settings/group/components/group_settings_app.vue'; @@ -60,6 +61,7 @@ describe('Group Settings App', () => { const findAlert = () => wrapper.findComponent(GlAlert); const findPackageSettings = () => wrapper.findComponent(PackagesSettings); + const findPackageForwardingSettings = () => wrapper.findComponent(PackagesForwardingSettings); const findDependencyProxySettings = () => wrapper.findComponent(DependencyProxySettings); const waitForApolloQueryAndRender = async () => { @@ -67,16 +69,18 @@ describe('Group Settings App', () => { await nextTick(); }; - const packageSettingsProps = { packageSettings: packageSettings() }; + const packageSettingsProps = { packageSettings }; + const packageForwardingSettingsProps = { forwardSettings: { ...packageSettings } }; const dependencyProxyProps = { dependencyProxySettings: dependencyProxySettings(), dependencyProxyImageTtlPolicy: dependencyProxyImageTtlPolicy(), }; describe.each` - finder | entitySpecificProps | successMessage | errorMessage - ${findPackageSettings} | ${packageSettingsProps} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'} - ${findDependencyProxySettings} | ${dependencyProxyProps} | ${'Setting saved successfully'} | ${'An error occurred while saving the setting'} + finder | entitySpecificProps | successMessage | errorMessage + ${findPackageSettings} | ${packageSettingsProps} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'} + ${findPackageForwardingSettings} | ${packageForwardingSettingsProps} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'} + ${findDependencyProxySettings} | ${dependencyProxyProps} | ${'Setting saved successfully'} | ${'An error occurred while saving the setting'} `('settings blocks', ({ finder, entitySpecificProps, successMessage, errorMessage }) => { beforeEach(() => { mountComponent(); @@ -88,10 +92,7 @@ describe('Group Settings App', () => { }); it('binds the correctProps', () => { - expect(finder().props()).toMatchObject({ - isLoading: false, - ...entitySpecificProps, - }); + expect(finder().props()).toMatchObject(entitySpecificProps); }); describe('success event', () => { diff --git a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js index 13eba39ec8c..807f332f4d3 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js @@ -48,7 +48,7 @@ describe('Packages Settings', () => { apolloProvider, provide: defaultProvide, propsData: { - packageSettings: packageSettings(), + packageSettings, }, stubs: { SettingsBlock, @@ -83,7 +83,7 @@ describe('Packages Settings', () => { }; const emitMavenSettingsUpdate = (override) => { - findGenericDuplicatedSettingsExceptionsInput().vm.$emit('update', { + findMavenDuplicatedSettingsExceptionsInput().vm.$emit('update', { mavenDuplicateExceptionRegex: ')', ...override, }); @@ -117,7 +117,7 @@ describe('Packages Settings', () => { it('renders toggle', () => { mountComponent({ mountFn: mountExtended }); - const { mavenDuplicatesAllowed } = packageSettings(); + const { mavenDuplicatesAllowed } = packageSettings; expect(findMavenDuplicatedSettingsToggle().exists()).toBe(true); @@ -132,7 +132,7 @@ describe('Packages Settings', () => { it('renders ExceptionsInput and assigns duplication allowness and exception props', () => { mountComponent({ mountFn: mountExtended }); - const { mavenDuplicatesAllowed, mavenDuplicateExceptionRegex } = packageSettings(); + const { mavenDuplicatesAllowed, mavenDuplicateExceptionRegex } = packageSettings; expect(findMavenDuplicatedSettingsExceptionsInput().exists()).toBe(true); @@ -170,7 +170,7 @@ describe('Packages Settings', () => { it('renders toggle', () => { mountComponent({ mountFn: mountExtended }); - const { genericDuplicatesAllowed } = packageSettings(); + const { genericDuplicatesAllowed } = packageSettings; expect(findGenericDuplicatedSettingsToggle().exists()).toBe(true); expect(findGenericDuplicatedSettingsToggle().props()).toMatchObject({ @@ -184,7 +184,7 @@ describe('Packages Settings', () => { it('renders ExceptionsInput and assigns duplication allowness and exception props', async () => { mountComponent({ mountFn: mountExtended }); - const { genericDuplicatesAllowed, genericDuplicateExceptionRegex } = packageSettings(); + const { genericDuplicatesAllowed, genericDuplicateExceptionRegex } = packageSettings; expect(findGenericDuplicatedSettingsExceptionsInput().props()).toMatchObject({ duplicatesAllowed: genericDuplicatesAllowed, @@ -239,7 +239,7 @@ describe('Packages Settings', () => { emitMavenSettingsUpdate({ mavenDuplicateExceptionRegex }); expect(updateGroupPackagesSettingsOptimisticResponse).toHaveBeenCalledWith({ - ...packageSettings(), + ...packageSettings, mavenDuplicateExceptionRegex, }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js new file mode 100644 index 00000000000..a0b257a9496 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js @@ -0,0 +1,280 @@ +import Vue from 'vue'; +import { GlButton } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import component from '~/packages_and_registries/settings/group/components/packages_forwarding_settings.vue'; +import { + PACKAGE_FORWARDING_SETTINGS_DESCRIPTION, + PACKAGE_FORWARDING_SETTINGS_HEADER, +} from '~/packages_and_registries/settings/group/constants'; + +import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; +import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import { + packageSettings, + packageForwardingSettings, + groupPackageSettingsMock, + groupPackageForwardSettingsMutationMock, + mutationErrorMock, + npmProps, + pypiProps, + mavenProps, +} from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'); + +describe('Packages Forwarding Settings', () => { + let wrapper; + let apolloProvider; + const mutationResolverFn = jest.fn().mockResolvedValue(groupPackageForwardSettingsMutationMock()); + + const defaultProvide = { + groupPath: 'foo_group_path', + }; + + const mountComponent = ({ + forwardSettings = { ...packageSettings }, + features = {}, + mutationResolver = mutationResolverFn, + } = {}) => { + Vue.use(VueApollo); + + const requestHandlers = [[updateNamespacePackageSettings, mutationResolver]]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(component, { + apolloProvider, + provide: { + ...defaultProvide, + glFeatures: { + ...features, + }, + }, + propsData: { + forwardSettings, + }, + stubs: { + SettingsBlock, + }, + }); + }; + + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + const findForm = () => wrapper.find('form'); + const findSubmitButton = () => findForm().findComponent(GlButton); + const findDescription = () => wrapper.findByTestId('description'); + const findMavenForwardingSettings = () => wrapper.findByTestId('maven'); + const findNpmForwardingSettings = () => wrapper.findByTestId('npm'); + const findPyPiForwardingSettings = () => wrapper.findByTestId('pypi'); + + const fillApolloCache = () => { + apolloProvider.defaultClient.cache.writeQuery({ + query: getGroupPackagesSettingsQuery, + variables: { + fullPath: defaultProvide.groupPath, + }, + ...groupPackageSettingsMock, + }); + }; + + const updateNpmSettings = () => { + findNpmForwardingSettings().vm.$emit('update', 'npmPackageRequestsForwarding', false); + }; + + const submitForm = () => { + findForm().trigger('submit'); + return waitForPromises(); + }; + + afterEach(() => { + apolloProvider = null; + }); + + it('renders a settings block', () => { + mountComponent(); + + expect(findSettingsBlock().exists()).toBe(true); + }); + + it('has the correct header text', () => { + mountComponent(); + + expect(wrapper.text()).toContain(PACKAGE_FORWARDING_SETTINGS_HEADER); + }); + + it('has the correct description text', () => { + mountComponent(); + + expect(findDescription().text()).toMatchInterpolatedText( + PACKAGE_FORWARDING_SETTINGS_DESCRIPTION, + ); + }); + + it('watches changes to props', async () => { + mountComponent(); + + expect(findNpmForwardingSettings().props()).toMatchObject(npmProps); + + await wrapper.setProps({ + forwardSettings: { + ...packageSettings, + npmPackageRequestsForwardingLocked: true, + }, + }); + + expect(findNpmForwardingSettings().props()).toMatchObject({ ...npmProps, disabled: true }); + }); + + it('submit button is disabled', () => { + mountComponent(); + + expect(findSubmitButton().props('disabled')).toBe(true); + }); + + describe.each` + type | finder | props | field + ${'npm'} | ${findNpmForwardingSettings} | ${npmProps} | ${'npmPackageRequestsForwarding'} + ${'pypi'} | ${findPyPiForwardingSettings} | ${pypiProps} | ${'pypiPackageRequestsForwarding'} + ${'maven'} | ${findMavenForwardingSettings} | ${mavenProps} | ${'mavenPackageRequestsForwarding'} + `('$type settings', ({ finder, props, field }) => { + beforeEach(() => { + mountComponent({ features: { mavenCentralRequestForwarding: true } }); + }); + + it('assigns forwarding settings props', () => { + expect(finder().props()).toMatchObject(props); + }); + + it('on update event enables submit button', async () => { + finder().vm.$emit('update', field, false); + + await waitForPromises(); + + expect(findSubmitButton().props('disabled')).toBe(false); + }); + }); + + describe('maven settings', () => { + describe('with feature turned off', () => { + it('does not exist', () => { + mountComponent(); + + expect(findMavenForwardingSettings().exists()).toBe(false); + }); + }); + }); + + describe('settings update', () => { + describe('success state', () => { + it('calls the mutation with the right variables', async () => { + const { + mavenPackageRequestsForwardingLocked, + npmPackageRequestsForwardingLocked, + pypiPackageRequestsForwardingLocked, + ...packageSettingsInput + } = packageForwardingSettings; + + mountComponent(); + + fillApolloCache(); + updateNpmSettings(); + + await submitForm(); + + expect(mutationResolverFn).toHaveBeenCalledWith({ + input: { + namespacePath: defaultProvide.groupPath, + ...packageSettingsInput, + npmPackageRequestsForwarding: false, + }, + }); + }); + + it('when field are locked calls the mutation with the right variables', async () => { + mountComponent({ + forwardSettings: { + ...packageSettings, + mavenPackageRequestsForwardingLocked: true, + pypiPackageRequestsForwardingLocked: true, + }, + }); + + fillApolloCache(); + updateNpmSettings(); + + await submitForm(); + + expect(mutationResolverFn).toHaveBeenCalledWith({ + input: { + namespacePath: defaultProvide.groupPath, + lockNpmPackageRequestsForwarding: false, + npmPackageRequestsForwarding: false, + }, + }); + }); + + it('emits a success event', async () => { + mountComponent(); + fillApolloCache(); + updateNpmSettings(); + + await submitForm(); + + expect(wrapper.emitted('success')).toHaveLength(1); + }); + + it('has an optimistic response', async () => { + const npmPackageRequestsForwarding = false; + mountComponent(); + + fillApolloCache(); + + expect(findNpmForwardingSettings().props('forwarding')).toBe(true); + + updateNpmSettings(); + await submitForm(); + + expect(updateGroupPackagesSettingsOptimisticResponse).toHaveBeenCalledWith({ + ...packageSettings, + npmPackageRequestsForwarding, + }); + expect(findNpmForwardingSettings().props('forwarding')).toBe(npmPackageRequestsForwarding); + }); + }); + + describe('errors', () => { + it('mutation payload with root level errors', async () => { + const mutationResolver = jest.fn().mockResolvedValue(mutationErrorMock); + mountComponent({ mutationResolver }); + + fillApolloCache(); + + updateNpmSettings(); + await submitForm(); + + expect(wrapper.emitted('error')).toHaveLength(1); + }); + + it.each` + type | mutationResolver + ${'local'} | ${jest.fn().mockResolvedValue(groupPackageForwardSettingsMutationMock({ errors: ['foo'] }))} + ${'network'} | ${jest.fn().mockRejectedValue()} + `('mutation payload with $type error', async ({ mutationResolver }) => { + mountComponent({ mutationResolver }); + + fillApolloCache(); + + updateNpmSettings(); + await submitForm(); + + expect(wrapper.emitted('error')).toHaveLength(1); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/mock_data.js b/spec/frontend/packages_and_registries/settings/group/mock_data.js index d53446de910..1ca9dc6daeb 100644 --- a/spec/frontend/packages_and_registries/settings/group/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/group/mock_data.js @@ -1,9 +1,26 @@ -export const packageSettings = () => ({ +const packageDuplicateSettings = { mavenDuplicatesAllowed: true, mavenDuplicateExceptionRegex: '', genericDuplicatesAllowed: true, genericDuplicateExceptionRegex: '', -}); +}; + +export const packageForwardingSettings = { + mavenPackageRequestsForwarding: true, + lockMavenPackageRequestsForwarding: false, + npmPackageRequestsForwarding: true, + lockNpmPackageRequestsForwarding: false, + pypiPackageRequestsForwarding: true, + lockPypiPackageRequestsForwarding: false, + mavenPackageRequestsForwardingLocked: false, + npmPackageRequestsForwardingLocked: false, + pypiPackageRequestsForwardingLocked: false, +}; + +export const packageSettings = { + ...packageDuplicateSettings, + ...packageForwardingSettings, +}; export const dependencyProxySettings = (extend) => ({ enabled: true, @@ -21,13 +38,52 @@ export const groupPackageSettingsMock = { group: { id: '1', fullPath: 'foo_group_path', - packageSettings: packageSettings(), + packageSettings: { + ...packageSettings, + __typename: 'PackageSettings', + }, dependencyProxySetting: dependencyProxySettings(), dependencyProxyImageTtlPolicy: dependencyProxyImageTtlPolicy(), }, }, }; +export const npmProps = { + forwarding: packageForwardingSettings.npmPackageRequestsForwarding, + lockForwarding: packageForwardingSettings.lockNpmPackageRequestsForwarding, + label: 'npm', + disabled: false, + modelNames: { + forwarding: 'npmPackageRequestsForwarding', + lockForwarding: 'lockNpmPackageRequestsForwarding', + isLocked: 'npmPackageRequestsForwardingLocked', + }, +}; + +export const pypiProps = { + forwarding: packageForwardingSettings.pypiPackageRequestsForwarding, + lockForwarding: packageForwardingSettings.lockPypiPackageRequestsForwarding, + label: 'PyPI', + disabled: false, + modelNames: { + forwarding: 'pypiPackageRequestsForwarding', + lockForwarding: 'lockPypiPackageRequestsForwarding', + isLocked: 'pypiPackageRequestsForwardingLocked', + }, +}; + +export const mavenProps = { + forwarding: packageForwardingSettings.mavenPackageRequestsForwarding, + lockForwarding: packageForwardingSettings.lockMavenPackageRequestsForwarding, + label: 'Maven', + disabled: false, + modelNames: { + forwarding: 'mavenPackageRequestsForwarding', + lockForwarding: 'lockMavenPackageRequestsForwarding', + isLocked: 'mavenPackageRequestsForwardingLocked', + }, +}; + export const groupPackageSettingsMutationMock = (override) => ({ data: { updateNamespacePackageSettings: { @@ -43,6 +99,19 @@ export const groupPackageSettingsMutationMock = (override) => ({ }, }); +export const groupPackageForwardSettingsMutationMock = (override) => ({ + data: { + updateNamespacePackageSettings: { + packageSettings: { + npmPackageRequestsForwarding: true, + lockNpmPackageRequestsForwarding: false, + }, + errors: [], + ...override, + }, + }, +}); + export const dependencyProxySettingMutationMock = (override) => ({ data: { updateDependencyProxySettings: { diff --git a/spec/frontend/packages_and_registries/shared/components/delete_package_modal_spec.js b/spec/frontend/packages_and_registries/shared/components/delete_package_modal_spec.js new file mode 100644 index 00000000000..357dab593e8 --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/components/delete_package_modal_spec.js @@ -0,0 +1,82 @@ +import { GlSprintf, GlModal } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue'; + +describe('DeletePackageModal', () => { + let wrapper; + + const defaultItemToBeDeleted = { + name: 'package 01', + }; + + const findModal = () => wrapper.findComponent(GlModal); + + const mountComponent = ({ itemToBeDeleted = defaultItemToBeDeleted } = {}) => { + wrapper = shallowMountExtended(DeletePackageModal, { + propsData: { + itemToBeDeleted, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when itemToBeDeleted prop is defined', () => { + beforeEach(() => { + mountComponent(); + }); + + it('displays modal', () => { + expect(findModal().props('visible')).toBe(true); + }); + + it('passes title prop', () => { + expect(findModal().props('title')).toBe(wrapper.vm.$options.i18n.modalTitle); + }); + + it('passes actionPrimary prop', () => { + expect(findModal().props('actionPrimary')).toStrictEqual({ + text: wrapper.vm.$options.i18n.modalAction, + attributes: { + variant: 'danger', + }, + }); + }); + + it('displays description', () => { + const descriptionEl = findModal().findComponent(GlSprintf); + + expect(descriptionEl.exists()).toBe(true); + expect(descriptionEl.attributes('message')).toBe(wrapper.vm.$options.i18n.modalDescription); + }); + + it('emits ok when modal is validate', () => { + expect(wrapper.emitted().ok).toBeUndefined(); + + findModal().vm.$emit('ok'); + + expect(wrapper.emitted().ok).toHaveLength(1); + }); + + it('emits cancel when modal close', () => { + expect(wrapper.emitted().cancel).toBeUndefined(); + + findModal().vm.$emit('change', false); + + expect(wrapper.emitted().cancel).toHaveLength(1); + }); + }); + + describe('when itemToBeDeleted prop is null', () => { + beforeEach(() => { + mountComponent({ itemToBeDeleted: null }); + }); + + it("doesn't display modal", () => { + expect(findModal().props('visible')).toBe(false); + }); + }); +}); |