diff options
Diffstat (limited to 'spec/frontend/packages/list')
13 files changed, 1654 insertions, 0 deletions
diff --git a/spec/frontend/packages/list/coming_soon/helpers_spec.js b/spec/frontend/packages/list/coming_soon/helpers_spec.js new file mode 100644 index 00000000000..4a996bfad76 --- /dev/null +++ b/spec/frontend/packages/list/coming_soon/helpers_spec.js @@ -0,0 +1,36 @@ +import * as comingSoon from '~/packages/list/coming_soon/helpers'; +import { fakeIssues, asGraphQLResponse, asViewModel } from './mock_data'; + +jest.mock('~/api.js'); + +describe('Coming Soon Helpers', () => { + const [noLabels, acceptingMergeRequestLabel, workflowLabel] = fakeIssues; + + describe('toViewModel', () => { + it('formats a GraphQL response correctly', () => { + expect(comingSoon.toViewModel(asGraphQLResponse)).toEqual(asViewModel); + }); + }); + + describe('findWorkflowLabel', () => { + it('finds a workflow label', () => { + expect(comingSoon.findWorkflowLabel(workflowLabel.labels)).toEqual(workflowLabel.labels[0]); + }); + + it("returns undefined when there isn't one", () => { + expect(comingSoon.findWorkflowLabel(noLabels.labels)).toBeUndefined(); + }); + }); + + describe('findAcceptingContributionsLabel', () => { + it('finds the correct label when it exists', () => { + expect(comingSoon.findAcceptingContributionsLabel(acceptingMergeRequestLabel.labels)).toEqual( + acceptingMergeRequestLabel.labels[0], + ); + }); + + it("returns undefined when there isn't one", () => { + expect(comingSoon.findAcceptingContributionsLabel(noLabels.labels)).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/packages/list/coming_soon/mock_data.js b/spec/frontend/packages/list/coming_soon/mock_data.js new file mode 100644 index 00000000000..bb4568e4bd5 --- /dev/null +++ b/spec/frontend/packages/list/coming_soon/mock_data.js @@ -0,0 +1,90 @@ +export const fakeIssues = [ + { + id: 1, + iid: 1, + title: 'issue one', + webUrl: 'foo', + }, + { + id: 2, + iid: 2, + title: 'issue two', + labels: [{ title: 'Accepting merge requests', color: '#69d100' }], + milestone: { + title: '12.10', + }, + webUrl: 'foo', + }, + { + id: 3, + iid: 3, + title: 'issue three', + labels: [{ title: 'workflow::In dev', color: '#428bca' }], + webUrl: 'foo', + }, + { + id: 4, + iid: 4, + title: 'issue four', + labels: [ + { title: 'Accepting merge requests', color: '#69d100' }, + { title: 'workflow::In dev', color: '#428bca' }, + ], + webUrl: 'foo', + }, +]; + +export const asGraphQLResponse = { + project: { + issues: { + nodes: fakeIssues.map(x => ({ + ...x, + labels: { + nodes: x.labels, + }, + })), + }, + }, +}; + +export const asViewModel = [ + { + ...fakeIssues[0], + labels: [], + }, + { + ...fakeIssues[1], + labels: [ + { + title: 'Accepting merge requests', + color: '#69d100', + scoped: false, + }, + ], + }, + { + ...fakeIssues[2], + labels: [ + { + title: 'workflow::In dev', + color: '#428bca', + scoped: true, + }, + ], + }, + { + ...fakeIssues[3], + labels: [ + { + title: 'workflow::In dev', + color: '#428bca', + scoped: true, + }, + { + title: 'Accepting merge requests', + color: '#69d100', + scoped: false, + }, + ], + }, +]; diff --git a/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js b/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js new file mode 100644 index 00000000000..c4cdadc45e6 --- /dev/null +++ b/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js @@ -0,0 +1,138 @@ +import { GlEmptyState, GlSkeletonLoader, GlLabel } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import VueApollo, { ApolloQuery } from 'vue-apollo'; +import ComingSoon from '~/packages/list/coming_soon/packages_coming_soon.vue'; +import { TrackingActions } from '~/packages/shared/constants'; +import { asViewModel } from './mock_data'; +import Tracking from '~/tracking'; + +jest.mock('~/packages/list/coming_soon/helpers.js'); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('packages_coming_soon', () => { + let wrapper; + + const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); + const findAllIssues = () => wrapper.findAll('[data-testid="issue-row"]'); + const findIssuesData = () => + findAllIssues().wrappers.map(x => { + const titleLink = x.find('[data-testid="issue-title-link"]'); + const milestone = x.find('[data-testid="milestone"]'); + const issueIdLink = x.find('[data-testid="issue-id-link"]'); + const labels = x.findAll(GlLabel); + + const issueId = Number(issueIdLink.text().substr(1)); + + return { + id: issueId, + iid: issueId, + title: titleLink.text(), + webUrl: titleLink.attributes('href'), + labels: labels.wrappers.map(label => ({ + color: label.props('backgroundColor'), + title: label.props('title'), + scoped: label.props('scoped'), + })), + ...(milestone.exists() ? { milestone: { title: milestone.text() } } : {}), + }; + }); + const findIssueTitleLink = () => wrapper.find('[data-testid="issue-title-link"]'); + const findIssueIdLink = () => wrapper.find('[data-testid="issue-id-link"]'); + const findEmptyState = () => wrapper.find(GlEmptyState); + + const mountComponent = (testParams = {}) => { + const $apolloData = { + loading: testParams.isLoading || false, + }; + + wrapper = mount(ComingSoon, { + localVue, + propsData: { + illustration: 'foo', + projectPath: 'foo', + suggestedContributionsPath: 'foo', + }, + stubs: { + ApolloQuery, + GlLink: true, + }, + mocks: { + $apolloData, + }, + }); + + // Mock the GraphQL query result + wrapper.find(ApolloQuery).setData({ + result: { + data: testParams.issues || asViewModel, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when loading', () => { + beforeEach(() => mountComponent({ isLoading: true })); + + it('renders the skeleton loader', () => { + expect(findSkeletonLoader().exists()).toBe(true); + }); + }); + + describe('when there are no issues', () => { + beforeEach(() => mountComponent({ issues: [] })); + + it('renders the empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + }); + + describe('when there are issues', () => { + beforeEach(() => mountComponent()); + + it('renders each issue', () => { + expect(findIssuesData()).toEqual(asViewModel); + }); + }); + + describe('tracking', () => { + const firstIssue = asViewModel[0]; + let eventSpy; + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + mountComponent(); + }); + + it('tracks when mounted', () => { + expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_REQUESTED, {}); + }); + + it('tracks when an issue title link is clicked', () => { + eventSpy.mockClear(); + + findIssueTitleLink().vm.$emit('click'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_LIST, { + label: firstIssue.title, + value: firstIssue.iid, + }); + }); + + it('tracks when an issue id link is clicked', () => { + eventSpy.mockClear(); + + findIssueIdLink().vm.$emit('click'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_LIST, { + label: firstIssue.title, + value: firstIssue.iid, + }); + }); + }); +}); diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap new file mode 100644 index 00000000000..ed77f25916f --- /dev/null +++ b/spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`packages_filter renders 1`] = ` +<gl-search-box-by-click-stub + clearable="true" + clearbuttontitle="Clear" + clearrecentsearchestext="Clear recent searches" + closebuttontitle="Close" + norecentsearchestext="You don't have any recent searches" + placeholder="Filter by name" + recentsearchesheader="Recent searches" + value="" +/> +`; diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap new file mode 100644 index 00000000000..2b7a4c83bed --- /dev/null +++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -0,0 +1,457 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`packages_list_app renders 1`] = ` +<b-tabs-stub + activenavitemclass="gl-tab-nav-item-active gl-tab-nav-item-active-indigo" + class="gl-tabs" + contentclass=",gl-tab-content" + navclass="gl-tabs-nav" + nofade="true" + nonavstyle="true" + tag="div" +> + <template> + + <b-tab-stub + tag="div" + title="All" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="Composer" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no Composer packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no Composer packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="Conan" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no Conan packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no Conan packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="Maven" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no Maven packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no Maven packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="NPM" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no NPM packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no NPM packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="NuGet" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no NuGet packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no NuGet packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="PyPi" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no PyPi packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no PyPi packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + + <!----> + </template> + <template> + <div + class="d-flex align-self-center ml-md-auto py-1 py-md-0" + > + <package-filter-stub + class="mr-1" + /> + + <package-sort-stub /> + </div> + </template> +</b-tabs-stub> +`; diff --git a/spec/frontend/packages/list/components/packages_filter_spec.js b/spec/frontend/packages/list/components/packages_filter_spec.js new file mode 100644 index 00000000000..b186b5f5e48 --- /dev/null +++ b/spec/frontend/packages/list/components/packages_filter_spec.js @@ -0,0 +1,50 @@ +import Vuex from 'vuex'; +import { GlSearchBoxByClick } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import PackagesFilter from '~/packages/list/components/packages_filter.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_filter', () => { + let wrapper; + let store; + + const findGlSearchBox = () => wrapper.find(GlSearchBoxByClick); + + const mountComponent = () => { + store = new Vuex.Store(); + store.dispatch = jest.fn(); + + wrapper = shallowMount(PackagesFilter, { + localVue, + store, + }); + }; + + beforeEach(mountComponent); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('emits events', () => { + it('sets the filter value in the store on input', () => { + const searchString = 'foo'; + findGlSearchBox().vm.$emit('input', searchString); + + expect(store.dispatch).toHaveBeenCalledWith('setFilter', searchString); + }); + + it('emits the filter event when search box is submitted', () => { + findGlSearchBox().vm.$emit('submit'); + + expect(wrapper.emitted('filter')).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js new file mode 100644 index 00000000000..31bab3886c1 --- /dev/null +++ b/spec/frontend/packages/list/components/packages_list_app_spec.js @@ -0,0 +1,148 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlEmptyState, GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui'; +import PackageListApp from '~/packages/list/components/packages_list_app.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_list_app', () => { + let wrapper; + let store; + + const PackageList = { + name: 'package-list', + template: '<div><slot name="empty-state"></slot></div>', + }; + const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' }; + + const emptyListHelpUrl = 'helpUrl'; + const findEmptyState = () => wrapper.find(GlEmptyState); + const findListComponent = () => wrapper.find(PackageList); + const findTabComponent = (index = 0) => wrapper.findAll(GlTab).at(index); + + const createStore = (filterQuery = '') => { + store = new Vuex.Store({ + state: { + isLoading: false, + config: { + resourceId: 'project_id', + emptyListIllustration: 'helpSvg', + emptyListHelpUrl, + }, + filterQuery, + }, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = () => { + wrapper = shallowMount(PackageListApp, { + localVue, + store, + stubs: { + GlEmptyState, + GlLoadingIcon, + PackageList, + GlTab, + GlTabs, + GlSprintf, + GlLink, + }, + }); + }; + + beforeEach(() => { + createStore(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('empty state', () => { + it('generate the correct empty list link', () => { + mountComponent(); + + const link = findListComponent().find(GlLink); + + expect(link.attributes('href')).toBe(emptyListHelpUrl); + expect(link.text()).toBe('publish and share your packages'); + }); + + it('includes the right content on the default tab', () => { + mountComponent(); + + const heading = findEmptyState().find('h1'); + + expect(heading.text()).toBe('There are no packages yet'); + }); + }); + + it('call requestPackagesList on page:changed', () => { + mountComponent(); + + const list = findListComponent(); + list.vm.$emit('page:changed', 1); + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList', { page: 1 }); + }); + + it('call requestDeletePackage on package:delete', () => { + mountComponent(); + + const list = findListComponent(); + list.vm.$emit('package:delete', 'foo'); + expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo'); + }); + + it('calls requestPackagesList on sort:changed', () => { + mountComponent(); + + const list = findListComponent(); + list.vm.$emit('sort:changed'); + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); + }); + + it('does not call requestPackagesList two times on render', () => { + mountComponent(); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + }); + + describe('tab change', () => { + it('calls requestPackagesList when all tab is clicked', () => { + mountComponent(); + + findTabComponent().trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); + }); + + it('calls requestPackagesList when a package type tab is clicked', () => { + mountComponent(); + + findTabComponent(1).trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); + }); + }); + + describe('filter without results', () => { + beforeEach(() => { + createStore('foo'); + mountComponent(); + }); + + it('should show specific empty message', () => { + expect(findEmptyState().text()).toContain('Sorry, your filter produced no results'); + expect(findEmptyState().text()).toContain( + 'To widen your search, change or remove the filters above', + ); + }); + }); +}); diff --git a/spec/frontend/packages/list/components/packages_list_spec.js b/spec/frontend/packages/list/components/packages_list_spec.js new file mode 100644 index 00000000000..a90d5056212 --- /dev/null +++ b/spec/frontend/packages/list/components/packages_list_spec.js @@ -0,0 +1,219 @@ +import Vuex from 'vuex'; +import { last } from 'lodash'; +import { GlTable, GlPagination, GlModal } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import stubChildren from 'helpers/stub_children'; +import Tracking from '~/tracking'; +import PackagesList from '~/packages/list/components/packages_list.vue'; +import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; +import * as SharedUtils from '~/packages/shared/utils'; +import { TrackingActions } from '~/packages/shared/constants'; +import { packageList } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_list', () => { + let wrapper; + let store; + + const GlSortingItem = { name: 'sorting-item-stub', template: '<div><slot></slot></div>' }; + const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' }; + + const findPackagesListLoader = () => wrapper.find(PackagesListLoader); + const findPackageListPagination = () => wrapper.find(GlPagination); + const findPackageListDeleteModal = () => wrapper.find(GlModal); + const findEmptySlot = () => wrapper.find({ name: 'empty-slot-stub' }); + const findPackagesListRow = () => wrapper.find(PackagesListRow); + + const createStore = (isGroupPage, packages, isLoading) => { + const state = { + isLoading, + packages, + pagination: { + perPage: 1, + total: 1, + page: 1, + }, + config: { + isGroupPage, + }, + sorting: { + orderBy: 'version', + sort: 'desc', + }, + }; + store = new Vuex.Store({ + state, + getters: { + getList: () => packages, + }, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = ({ + isGroupPage = false, + packages = packageList, + isLoading = false, + ...options + } = {}) => { + createStore(isGroupPage, packages, isLoading); + + wrapper = mount(PackagesList, { + localVue, + store, + stubs: { + ...stubChildren(PackagesList), + GlTable, + GlSortingItem, + GlModal, + }, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when is loading', () => { + beforeEach(() => { + mountComponent({ + packages: [], + isLoading: true, + }); + }); + + it('shows skeleton loader when loading', () => { + expect(findPackagesListLoader().exists()).toBe(true); + }); + }); + + describe('when is not loading', () => { + beforeEach(() => { + mountComponent(); + }); + + it('does not show skeleton loader when not loading', () => { + expect(findPackagesListLoader().exists()).toBe(false); + }); + }); + + describe('layout', () => { + beforeEach(() => { + mountComponent(); + }); + + it('contains a pagination component', () => { + const sorting = findPackageListPagination(); + expect(sorting.exists()).toBe(true); + }); + + it('contains a modal component', () => { + const sorting = findPackageListDeleteModal(); + expect(sorting.exists()).toBe(true); + }); + }); + + describe('when the user can destroy the package', () => { + beforeEach(() => { + mountComponent(); + }); + + it('setItemToBeDeleted sets itemToBeDeleted and open the modal', () => { + const mockModalShow = jest.spyOn(wrapper.vm.$refs.packageListDeleteModal, 'show'); + const item = last(wrapper.vm.list); + + findPackagesListRow().vm.$emit('packageToDelete', item); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.itemToBeDeleted).toEqual(item); + expect(mockModalShow).toHaveBeenCalled(); + }); + }); + + it('deleteItemConfirmation resets itemToBeDeleted', () => { + wrapper.setData({ itemToBeDeleted: 1 }); + wrapper.vm.deleteItemConfirmation(); + expect(wrapper.vm.itemToBeDeleted).toEqual(null); + }); + + it('deleteItemConfirmation emit package:delete', () => { + const itemToBeDeleted = { id: 2 }; + wrapper.setData({ itemToBeDeleted }); + wrapper.vm.deleteItemConfirmation(); + return wrapper.vm.$nextTick(() => { + expect(wrapper.emitted('package:delete')[0]).toEqual([itemToBeDeleted]); + }); + }); + + it('deleteItemCanceled resets itemToBeDeleted', () => { + wrapper.setData({ itemToBeDeleted: 1 }); + wrapper.vm.deleteItemCanceled(); + expect(wrapper.vm.itemToBeDeleted).toEqual(null); + }); + }); + + describe('when the list is empty', () => { + beforeEach(() => { + mountComponent({ + packages: [], + slots: { + 'empty-state': EmptySlotStub, + }, + }); + }); + + it('show the empty slot', () => { + const emptySlot = findEmptySlot(); + expect(emptySlot.exists()).toBe(true); + }); + }); + + describe('pagination component', () => { + let pagination; + let modelEvent; + + beforeEach(() => { + mountComponent(); + pagination = findPackageListPagination(); + // retrieve the event used by v-model, a more sturdy approach than hardcoding it + modelEvent = pagination.vm.$options.model.event; + }); + + it('emits page:changed events when the page changes', () => { + pagination.vm.$emit(modelEvent, 2); + expect(wrapper.emitted('page:changed')).toEqual([[2]]); + }); + }); + + describe('tracking', () => { + let eventSpy; + let utilSpy; + const category = 'foo'; + + beforeEach(() => { + mountComponent(); + eventSpy = jest.spyOn(Tracking, 'event'); + utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category); + wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } }); + }); + + it('tracking category calls packageTypeToTrackCategory', () => { + expect(wrapper.vm.tracking.category).toBe(category); + expect(utilSpy).toHaveBeenCalledWith('conan'); + }); + + it('deleteItemConfirmation calls event', () => { + wrapper.vm.deleteItemConfirmation(); + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.DELETE_PACKAGE, + expect.any(Object), + ); + }); + }); +}); diff --git a/spec/frontend/packages/list/components/packages_sort_spec.js b/spec/frontend/packages/list/components/packages_sort_spec.js new file mode 100644 index 00000000000..ff3e8e19413 --- /dev/null +++ b/spec/frontend/packages/list/components/packages_sort_spec.js @@ -0,0 +1,92 @@ +import Vuex from 'vuex'; +import { GlSorting } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import stubChildren from 'helpers/stub_children'; +import PackagesSort from '~/packages/list/components/packages_sort.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_sort', () => { + let wrapper; + let store; + let sorting; + let sortingItems; + + const GlSortingItem = { name: 'sorting-item-stub', template: '<div><slot></slot></div>' }; + + const findPackageListSorting = () => wrapper.find(GlSorting); + const findSortingItems = () => wrapper.findAll(GlSortingItem); + + const createStore = isGroupPage => { + const state = { + config: { + isGroupPage, + }, + sorting: { + orderBy: 'version', + sort: 'desc', + }, + }; + store = new Vuex.Store({ + state, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = (isGroupPage = false) => { + createStore(isGroupPage); + + wrapper = mount(PackagesSort, { + localVue, + store, + stubs: { + ...stubChildren(PackagesSort), + GlSortingItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when is in projects', () => { + beforeEach(() => { + mountComponent(); + sorting = findPackageListSorting(); + sortingItems = findSortingItems(); + }); + + it('has all the sortable items', () => { + expect(sortingItems).toHaveLength(wrapper.vm.sortableFields.length); + }); + + it('on sort change set sorting in vuex and emit event', () => { + sorting.vm.$emit('sortDirectionChange'); + expect(store.dispatch).toHaveBeenCalledWith('setSorting', { sort: 'asc' }); + expect(wrapper.emitted('sort:changed')).toBeTruthy(); + }); + + it('on sort item click set sorting and emit event', () => { + const item = sortingItems.at(0); + const { orderBy } = wrapper.vm.sortableFields[0]; + item.vm.$emit('click'); + expect(store.dispatch).toHaveBeenCalledWith('setSorting', { orderBy }); + expect(wrapper.emitted('sort:changed')).toBeTruthy(); + }); + }); + + describe('when is in group', () => { + beforeEach(() => { + mountComponent(true); + sorting = findPackageListSorting(); + sortingItems = findSortingItems(); + }); + + it('has all the sortable items', () => { + expect(sortingItems).toHaveLength(wrapper.vm.sortableFields.length); + }); + }); +}); diff --git a/spec/frontend/packages/list/stores/actions_spec.js b/spec/frontend/packages/list/stores/actions_spec.js new file mode 100644 index 00000000000..faa629cc01f --- /dev/null +++ b/spec/frontend/packages/list/stores/actions_spec.js @@ -0,0 +1,240 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import Api from '~/api'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import * as actions from '~/packages/list/stores/actions'; +import * as types from '~/packages/list/stores/mutation_types'; +import { MISSING_DELETE_PATH_ERROR, DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/list/constants'; + +jest.mock('~/flash.js'); +jest.mock('~/api.js'); + +describe('Actions Package list store', () => { + const headers = 'bar'; + let mock; + + beforeEach(() => { + Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo', headers }); + Api.groupPackages = jest.fn().mockResolvedValue({ data: 'baz', headers }); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('requestPackagesList', () => { + const sorting = { + sort: 'asc', + orderBy: 'version', + }; + it('should fetch the project packages list when isGroupPage is false', done => { + testAction( + actions.requestPackagesList, + undefined, + { config: { isGroupPage: false, resourceId: 1 }, sorting }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, + { type: 'setLoading', payload: false }, + ], + () => { + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, + }); + done(); + }, + ); + }); + + it('should fetch the group packages list when isGroupPage is true', done => { + testAction( + actions.requestPackagesList, + undefined, + { config: { isGroupPage: true, resourceId: 2 }, sorting }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'receivePackagesListSuccess', payload: { data: 'baz', headers } }, + { type: 'setLoading', payload: false }, + ], + () => { + expect(Api.groupPackages).toHaveBeenCalledWith(2, { + params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, + }); + done(); + }, + ); + }); + + it('should fetch packages of a certain type when selectedType is present', done => { + const packageType = 'maven'; + + testAction( + actions.requestPackagesList, + undefined, + { + config: { isGroupPage: false, resourceId: 1 }, + sorting, + selectedType: { type: packageType }, + }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, + { type: 'setLoading', payload: false }, + ], + () => { + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { + page: 1, + per_page: 20, + sort: sorting.sort, + order_by: sorting.orderBy, + package_type: packageType, + }, + }); + done(); + }, + ); + }); + + it('should create flash on API error', done => { + Api.projectPackages = jest.fn().mockRejectedValue(); + testAction( + actions.requestPackagesList, + undefined, + { config: { isGroupPage: false, resourceId: 2 }, sorting }, + [], + [{ type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }], + () => { + expect(createFlash).toHaveBeenCalled(); + done(); + }, + ); + }); + }); + + describe('receivePackagesListSuccess', () => { + it('should set received packages', done => { + const data = 'foo'; + + testAction( + actions.receivePackagesListSuccess, + { data, headers }, + null, + [ + { type: types.SET_PACKAGE_LIST_SUCCESS, payload: data }, + { type: types.SET_PAGINATION, payload: headers }, + ], + [], + done, + ); + }); + }); + + describe('setInitialState', () => { + it('should commit setInitialState', done => { + testAction( + actions.setInitialState, + '1', + null, + [{ type: types.SET_INITIAL_STATE, payload: '1' }], + [], + done, + ); + }); + }); + + describe('setLoading', () => { + it('should commit set main loading', done => { + testAction( + actions.setLoading, + true, + null, + [{ type: types.SET_MAIN_LOADING, payload: true }], + [], + done, + ); + }); + }); + + describe('requestDeletePackage', () => { + const payload = { + _links: { + delete_api_path: 'foo', + }, + }; + it('should perform a delete operation on _links.delete_api_path', done => { + mock.onDelete(payload._links.delete_api_path).replyOnce(200); + Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo' }); + + testAction( + actions.requestDeletePackage, + payload, + { pagination: { page: 1 } }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'requestPackagesList', payload: { page: 1 } }, + ], + done, + ); + }); + + it('should stop the loading and call create flash on api error', done => { + mock.onDelete(payload._links.delete_api_path).replyOnce(400); + testAction( + actions.requestDeletePackage, + payload, + null, + [], + [{ type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }], + () => { + expect(createFlash).toHaveBeenCalled(); + done(); + }, + ); + }); + + it.each` + property | actionPayload + ${'_links'} | ${{}} + ${'delete_api_path'} | ${{ _links: {} }} + `('should reject and createFlash when $property is missing', ({ actionPayload }, done) => { + testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch(e => { + expect(e).toEqual(new Error(MISSING_DELETE_PATH_ERROR)); + expect(createFlash).toHaveBeenCalledWith(DELETE_PACKAGE_ERROR_MESSAGE); + done(); + }); + }); + }); + + describe('setSorting', () => { + it('should commit SET_SORTING', done => { + testAction( + actions.setSorting, + 'foo', + null, + [{ type: types.SET_SORTING, payload: 'foo' }], + [], + done, + ); + }); + }); + + describe('setFilter', () => { + it('should commit SET_FILTER', done => { + testAction( + actions.setFilter, + 'foo', + null, + [{ type: types.SET_FILTER, payload: 'foo' }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/packages/list/stores/getters_spec.js b/spec/frontend/packages/list/stores/getters_spec.js new file mode 100644 index 00000000000..080bbc21d9f --- /dev/null +++ b/spec/frontend/packages/list/stores/getters_spec.js @@ -0,0 +1,36 @@ +import getList from '~/packages/list/stores/getters'; +import { packageList } from '../../mock_data'; + +describe('Getters registry list store', () => { + let state; + + const setState = ({ isGroupPage = false } = {}) => { + state = { + packages: packageList, + config: { + isGroupPage, + }, + }; + }; + + beforeEach(() => setState()); + + afterEach(() => { + state = null; + }); + + describe('getList', () => { + it('returns a list of packages', () => { + const result = getList(state); + + expect(result).toHaveLength(packageList.length); + expect(result[0].name).toBe('Test package'); + }); + + it('adds projectPathName', () => { + const result = getList(state); + + expect(result[0].projectPathName).toMatchInlineSnapshot(`"foo / bar / baz"`); + }); + }); +}); diff --git a/spec/frontend/packages/list/stores/mutations_spec.js b/spec/frontend/packages/list/stores/mutations_spec.js new file mode 100644 index 00000000000..563a3dabbb3 --- /dev/null +++ b/spec/frontend/packages/list/stores/mutations_spec.js @@ -0,0 +1,95 @@ +import mutations from '~/packages/list/stores/mutations'; +import * as types from '~/packages/list/stores/mutation_types'; +import createState from '~/packages/list/stores/state'; +import * as commonUtils from '~/lib/utils/common_utils'; +import { npmPackage, mavenPackage } from '../../mock_data'; + +describe('Mutations Registry Store', () => { + let mockState; + beforeEach(() => { + mockState = createState(); + }); + + describe('SET_INITIAL_STATE', () => { + it('should set the initial state', () => { + const config = { + resourceId: '1', + pageType: 'groups', + userCanDelete: '', + emptyListIllustration: 'foo', + emptyListHelpUrl: 'baz', + comingSoonJson: '{ "project_path": "gitlab-org/gitlab-test" }', + }; + + const expectedState = { + ...mockState, + config: { + ...config, + isGroupPage: true, + canDestroyPackage: true, + }, + }; + mutations[types.SET_INITIAL_STATE](mockState, config); + + expect(mockState.projectId).toEqual(expectedState.projectId); + }); + }); + + describe('SET_PACKAGE_LIST_SUCCESS', () => { + it('should set a packages list', () => { + const payload = [npmPackage, mavenPackage]; + const expectedState = { ...mockState, packages: payload }; + mutations[types.SET_PACKAGE_LIST_SUCCESS](mockState, payload); + + expect(mockState.packages).toEqual(expectedState.packages); + }); + }); + + describe('SET_MAIN_LOADING', () => { + it('should set main loading', () => { + mutations[types.SET_MAIN_LOADING](mockState, true); + + expect(mockState.isLoading).toEqual(true); + }); + }); + + describe('SET_PAGINATION', () => { + const mockPagination = { perPage: 10, page: 1 }; + beforeEach(() => { + commonUtils.normalizeHeaders = jest.fn().mockReturnValue('baz'); + commonUtils.parseIntPagination = jest.fn().mockReturnValue(mockPagination); + }); + it('should set a parsed pagination', () => { + mutations[types.SET_PAGINATION](mockState, 'foo'); + expect(commonUtils.normalizeHeaders).toHaveBeenCalledWith('foo'); + expect(commonUtils.parseIntPagination).toHaveBeenCalledWith('baz'); + expect(mockState.pagination).toEqual(mockPagination); + }); + }); + + describe('SET_SORTING', () => { + it('should merge the sorting object with sort value', () => { + mutations[types.SET_SORTING](mockState, { sort: 'desc' }); + expect(mockState.sorting).toEqual({ ...mockState.sorting, sort: 'desc' }); + }); + + it('should merge the sorting object with order_by value', () => { + mutations[types.SET_SORTING](mockState, { orderBy: 'foo' }); + expect(mockState.sorting).toEqual({ ...mockState.sorting, orderBy: 'foo' }); + }); + }); + + describe('SET_SELECTED_TYPE', () => { + it('should set the selected type', () => { + mutations[types.SET_SELECTED_TYPE](mockState, { type: 'maven' }); + expect(mockState.selectedType).toEqual({ type: 'maven' }); + }); + }); + + describe('SET_FILTER', () => { + it('should set the filter query', () => { + mutations[types.SET_FILTER](mockState, 'foo'); + expect(mockState.filterQuery).toEqual('foo'); + }); + }); +}); diff --git a/spec/frontend/packages/list/utils_spec.js b/spec/frontend/packages/list/utils_spec.js new file mode 100644 index 00000000000..5bcc3784752 --- /dev/null +++ b/spec/frontend/packages/list/utils_spec.js @@ -0,0 +1,39 @@ +import { getNewPaginationPage } from '~/packages/list/utils'; + +describe('Packages list utils', () => { + describe('packageTypeDisplay', () => { + it('returns the current page when total items exceeds pagniation', () => { + expect(getNewPaginationPage(2, 20, 21)).toBe(2); + }); + + it('returns the previous page when total items is lower than or equal to pagination', () => { + expect(getNewPaginationPage(2, 20, 20)).toBe(1); + }); + + it('returns the first page when totalItems is lower than or equal to perPage', () => { + expect(getNewPaginationPage(4, 20, 20)).toBe(1); + }); + + describe('works when a different perPage is used', () => { + it('returns the current page', () => { + expect(getNewPaginationPage(2, 10, 11)).toBe(2); + }); + + it('returns the previous page', () => { + expect(getNewPaginationPage(2, 10, 10)).toBe(1); + }); + }); + + describe.each` + currentPage | totalItems | expectedResult + ${1} | ${20} | ${1} + ${2} | ${20} | ${1} + ${3} | ${40} | ${2} + ${4} | ${60} | ${3} + `(`works across numerious pages`, ({ currentPage, totalItems, expectedResult }) => { + it(`when currentPage is ${currentPage} return to the previous page ${expectedResult}`, () => { + expect(getNewPaginationPage(currentPage, 20, totalItems)).toBe(expectedResult); + }); + }); + }); +}); |