spec/frontend/groups_projects/components/tab_view_spec.js (550 lines of code) (raw):

import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import { GlLoadingIcon, GlKeysetPagination, GlPagination } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import starredProjectsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/starred_projects.query.graphql.json'; import inactiveProjectsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/inactive_projects.query.graphql.json'; import personalProjectsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/personal_projects.query.graphql.json'; import membershipProjectsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/membership_projects.query.graphql.json'; import contributedProjectsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/contributed_projects.query.graphql.json'; import dashboardGroupsResponse from 'test_fixtures/groups/dashboard/index.json'; import dashboardGroupsWithChildrenResponse from 'test_fixtures/groups/dashboard/index_with_children.json'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import axios from '~/lib/utils/axios_utils'; import TabView from '~/groups_projects/components/tab_view.vue'; import { formatProjects } from '~/projects/your_work/utils'; import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; import ResourceListsEmptyState from '~/vue_shared/components/resource_lists/empty_state.vue'; import NestedGroupsProjectsList from '~/vue_shared/components/nested_groups_projects_list/nested_groups_projects_list.vue'; import { DEFAULT_PER_PAGE } from '~/api'; import { createAlert } from '~/alert'; import { CONTRIBUTED_TAB, PERSONAL_TAB, MEMBER_TAB, STARRED_TAB, INACTIVE_TAB, } from '~/projects/your_work/constants'; import { MEMBER_TAB as MEMBER_TAB_GROUPS } from '~/groups/your_work/constants'; import { FILTERED_SEARCH_TOKEN_LANGUAGE, FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL, PAGINATION_TYPE_KEYSET, PAGINATION_TYPE_OFFSET, } from '~/groups_projects/constants'; import { FILTERED_SEARCH_TERM_KEY } from '~/projects/filtered_search_and_sort/constants'; import { ACCESS_LEVEL_OWNER_INTEGER, ACCESS_LEVEL_OWNER_STRING } from '~/access_level/constants'; import { TIMESTAMP_TYPE_CREATED_AT } from '~/vue_shared/components/resource_lists/constants'; import createMockApollo from 'helpers/mock_apollo_helper'; import { resolvers } from '~/groups/your_work/graphql/resolvers'; import waitForPromises from 'helpers/wait_for_promises'; import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper'; import { pageInfoMultiplePages, programmingLanguages } from './mock_data'; jest.mock('~/alert'); Vue.use(VueApollo); const { bindInternalEventDocument } = useMockInternalEventsTracking(); describe('TabView', () => { let wrapper; let mockApollo; let mockAxios; let apolloClient; const endpoint = '/dashboard/groups.json'; const defaultPropsData = { sort: 'name_desc', filters: { [FILTERED_SEARCH_TERM_KEY]: 'foo', [FILTERED_SEARCH_TOKEN_LANGUAGE]: '8', [FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL]: ACCESS_LEVEL_OWNER_INTEGER, }, filteredSearchTermKey: FILTERED_SEARCH_TERM_KEY, timestampType: TIMESTAMP_TYPE_CREATED_AT, programmingLanguages, eventTracking: { clickStat: 'click_stat_on_your_work_projects', hoverStat: 'hover_stat_on_your_work_projects', hoverVisibility: 'hover_visibility_icon_on_your_work_projects', clickItemAfterFilter: 'click_project_after_filter_on_your_work_projects', clickTopic: 'click_topic_on_your_work_projects', }, paginationType: PAGINATION_TYPE_KEYSET, }; const createComponent = ({ handlers = [], propsData = {} } = {}) => { mockApollo = createMockApollo(handlers, resolvers(endpoint)); wrapper = shallowMountExtended(TabView, { apolloProvider: mockApollo, propsData: { ...defaultPropsData, ...propsData }, }); apolloClient = mockApollo.defaultClient; jest.spyOn(apolloClient, 'clearStore'); }; const findProjectsList = () => wrapper.findComponent(ProjectsList); const findKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); const findOffsetPagination = () => wrapper.findComponent(GlPagination); const findEmptyState = () => wrapper.findComponent(ResourceListsEmptyState); beforeEach(() => { mockAxios = new MockAdapter(axios); }); afterEach(() => { mockApollo = null; apolloClient = null; mockAxios.restore(); }); describe.each` tab | handler | expectedVariables | expectedProjects ${CONTRIBUTED_TAB} | ${[CONTRIBUTED_TAB.query, jest.fn().mockResolvedValue(contributedProjectsGraphQlResponse)]} | ${{ contributed: true, starred: false, sort: defaultPropsData.sort.toUpperCase() }} | ${contributedProjectsGraphQlResponse.data.currentUser.contributedProjects} ${PERSONAL_TAB} | ${[PERSONAL_TAB.query, jest.fn().mockResolvedValue(personalProjectsGraphQlResponse)]} | ${{ personal: true, membership: false, archived: 'EXCLUDE', sort: defaultPropsData.sort }} | ${personalProjectsGraphQlResponse.data.projects} ${MEMBER_TAB} | ${[MEMBER_TAB.query, jest.fn().mockResolvedValue(membershipProjectsGraphQlResponse)]} | ${{ personal: false, membership: true, archived: 'EXCLUDE', sort: defaultPropsData.sort }} | ${membershipProjectsGraphQlResponse.data.projects} ${STARRED_TAB} | ${[STARRED_TAB.query, jest.fn().mockResolvedValue(starredProjectsGraphQlResponse)]} | ${{ contributed: false, starred: true, sort: defaultPropsData.sort.toUpperCase() }} | ${starredProjectsGraphQlResponse.data.currentUser.starredProjects} ${INACTIVE_TAB} | ${[INACTIVE_TAB.query, jest.fn().mockResolvedValue(inactiveProjectsGraphQlResponse)]} | ${{ personal: false, membership: true, archived: 'ONLY', sort: defaultPropsData.sort }} | ${inactiveProjectsGraphQlResponse.data.projects} `( 'onMount when route name is $tab.value', ({ tab, handler, expectedVariables, expectedProjects: { nodes, count } }) => { describe('when GraphQL request is loading', () => { beforeEach(() => { createComponent({ handlers: [handler], propsData: { tab } }); }); it('shows loading icon', () => { expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); }); describe('when GraphQL request is successful', () => { beforeEach(async () => { createComponent({ handlers: [handler], propsData: { tab } }); await waitForPromises(); }); it('calls GraphQL query with correct variables', async () => { await waitForPromises(); expect(handler[1]).toHaveBeenCalledWith({ last: null, first: DEFAULT_PER_PAGE, before: null, after: null, search: defaultPropsData.filters[defaultPropsData.filteredSearchTermKey], programmingLanguageName: 'CoffeeScript', minAccessLevel: ACCESS_LEVEL_OWNER_STRING, ...expectedVariables, }); }); it('emits query-complete event', () => { expect(wrapper.emitted('query-complete')).toEqual([[]]); }); it('emits update-count event', () => { expect(wrapper.emitted('update-count')).toEqual([[tab, count]]); }); it('passes items to `ProjectsList` component', () => { expect(findProjectsList().props('items')).toEqual(formatProjects(nodes)); }); it('passes `timestampType` prop to `ProjectsList` component', () => { expect(findProjectsList().props('timestampType')).toBe(TIMESTAMP_TYPE_CREATED_AT); }); describe('when list emits refetch', () => { beforeEach(() => { findProjectsList().vm.$emit('refetch'); }); it('clears store and refetches list', async () => { expect(apolloClient.clearStore).toHaveBeenCalled(); await waitForPromises(); expect(handler[1]).toHaveBeenCalledTimes(2); }); it('emits refetch event', async () => { await waitForPromises(); expect(wrapper.emitted('refetch')).toEqual([[]]); }); }); }); describe('when GraphQL request is not successful', () => { const error = new Error(); beforeEach(async () => { createComponent({ handlers: [[handler[0], jest.fn().mockRejectedValue(error)]], propsData: { tab }, }); await waitForPromises(); }); it('displays error alert', () => { expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred loading the projects. Please refresh the page to try again.', error, captureError: true, }); }); }); }, ); describe('when tab.listComponent is NestedGroupsProjectsList', () => { beforeEach(() => { mockAxios.onGet(endpoint).replyOnce(200, dashboardGroupsResponse); }); describe('when search is defined', () => { beforeEach(async () => { createComponent({ propsData: { tab: MEMBER_TAB_GROUPS } }); await waitForPromises(); }); it('passes initialExpanded prop as true', () => { expect(wrapper.findComponent(NestedGroupsProjectsList).props('initialExpanded')).toBe(true); }); }); describe('when search is empty', () => { beforeEach(async () => { createComponent({ propsData: { tab: MEMBER_TAB_GROUPS, filters: {} } }); await waitForPromises(); }); it('passes initialExpanded prop as false', () => { expect(wrapper.findComponent(NestedGroupsProjectsList).props('initialExpanded')).toBe( false, ); }); }); describe('when load-children event is fired', () => { const [group] = dashboardGroupsResponse; const [{ children }] = dashboardGroupsWithChildrenResponse; describe('when API request is loading', () => { beforeEach(async () => { createComponent({ propsData: { tab: MEMBER_TAB_GROUPS } }); await waitForPromises(); mockAxios.onGet(endpoint).replyOnce(200, [{ ...group, children }]); wrapper.findComponent(NestedGroupsProjectsList).vm.$emit('load-children', group.id); }); it('sets item as loading', () => { expect( wrapper.findComponent(NestedGroupsProjectsList).props('items')[0].childrenLoading, ).toBe(true); }); it('unsets item as loading after API request resolves', async () => { await waitForPromises(); expect( wrapper.findComponent(NestedGroupsProjectsList).props('items')[0].childrenLoading, ).toBe(false); }); }); describe('when API request is successful', () => { beforeEach(async () => { createComponent({ propsData: { tab: MEMBER_TAB_GROUPS } }); await waitForPromises(); mockAxios.onGet(endpoint).replyOnce(200, [{ ...group, children }]); wrapper.findComponent(NestedGroupsProjectsList).vm.$emit('load-children', group.id); await waitForPromises(); }); it('calls API with parent_id argument', () => { expect(mockAxios.history.get[1].params.parent_id).toBe(group.id); }); it('updates children of item', () => { expect( wrapper .findComponent(NestedGroupsProjectsList) .props('items')[0] .children.map((child) => child.id), ).toEqual(children.map((item) => item.id)); }); }); describe('when API request is not successful', () => { beforeEach(async () => { createComponent({ propsData: { tab: MEMBER_TAB_GROUPS } }); await waitForPromises(); mockAxios.onGet(endpoint).networkError(); wrapper.findComponent(NestedGroupsProjectsList).vm.$emit('load-children', group.id); await waitForPromises(); }); it('displays error alert', () => { expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred loading the projects. Please refresh the page to try again.', error: new Error('Network Error'), captureError: true, }); }); }); }); }); describe('keyset pagination', () => { const propsData = { tab: PERSONAL_TAB }; describe('when there is one page of projects', () => { beforeEach(async () => { createComponent({ handlers: [ [PERSONAL_TAB.query, jest.fn().mockResolvedValue(personalProjectsGraphQlResponse)], ], propsData, }); await waitForPromises(); }); it('does not render pagination', () => { expect(findKeysetPagination().exists()).toBe(false); }); }); describe('when there are multiple pages of projects', () => { const mockEndCursor = 'mockEndCursor'; const mockStartCursor = 'mockStartCursor'; const handler = [ PERSONAL_TAB.query, jest.fn().mockResolvedValue({ data: { projects: { nodes: personalProjectsGraphQlResponse.data.projects.nodes, pageInfo: pageInfoMultiplePages, count: personalProjectsGraphQlResponse.data.projects.count, }, }, }), ]; beforeEach(async () => { createComponent({ handlers: [handler], propsData, }); await waitForPromises(); }); it('renders pagination', () => { expect(findKeysetPagination().exists()).toBe(true); }); describe('when next button is clicked', () => { beforeEach(() => { findKeysetPagination().vm.$emit('next', mockEndCursor); }); it('emits `keyset-page-change` event', () => { expect(wrapper.emitted('keyset-page-change')[0]).toEqual([ { endCursor: mockEndCursor, startCursor: null, }, ]); }); }); describe('when `endCursor` prop is changed', () => { beforeEach(async () => { wrapper.setProps({ endCursor: mockEndCursor }); await waitForPromises(); }); it('calls query with correct variables', () => { expect(handler[1]).toHaveBeenCalledWith({ after: mockEndCursor, before: null, first: DEFAULT_PER_PAGE, last: null, personal: true, membership: false, archived: 'EXCLUDE', sort: defaultPropsData.sort, search: defaultPropsData.filters[defaultPropsData.filteredSearchTermKey], programmingLanguageName: 'CoffeeScript', minAccessLevel: ACCESS_LEVEL_OWNER_STRING, }); }); }); describe('when previous button is clicked', () => { beforeEach(() => { findKeysetPagination().vm.$emit('prev', mockStartCursor); }); it('emits `keyset-page-change` event', () => { expect(wrapper.emitted('keyset-page-change')[0]).toEqual([ { endCursor: null, startCursor: mockStartCursor, }, ]); }); }); describe('when `startCursor` prop is changed', () => { beforeEach(async () => { wrapper.setProps({ startCursor: mockStartCursor }); await waitForPromises(); }); it('calls query with correct variables', () => { expect(handler[1]).toHaveBeenCalledWith({ after: null, before: mockStartCursor, first: null, last: DEFAULT_PER_PAGE, personal: true, membership: false, archived: 'EXCLUDE', sort: defaultPropsData.sort, search: defaultPropsData.filters[defaultPropsData.filteredSearchTermKey], programmingLanguageName: 'CoffeeScript', minAccessLevel: ACCESS_LEVEL_OWNER_STRING, }); }); }); }); }); describe('offset pagination', () => { const propsData = { tab: MEMBER_TAB_GROUPS, paginationType: PAGINATION_TYPE_OFFSET }; describe('when there is one page', () => { beforeEach(async () => { mockAxios.onGet(endpoint).replyOnce(200, dashboardGroupsResponse, { 'x-per-page': 10, 'x-page': 1, 'x-total': 9, 'x-total-pages': 1, 'x-next-page': null, 'x-prev-page': null, }); createComponent({ propsData, }); await waitForPromises(); }); it('does not render pagination', () => { expect(findOffsetPagination().exists()).toBe(false); }); }); describe('when there are multiple pages', () => { beforeEach(async () => { mockAxios.onGet(endpoint).replyOnce(200, dashboardGroupsResponse, { 'x-per-page': 10, 'x-page': 2, 'x-total': 21, 'x-total-pages': 3, 'x-next-page': 3, 'x-prev-page': 1, }); createComponent({ propsData, }); await waitForPromises(); }); it('renders pagination', () => { expect(findOffsetPagination().exists()).toBe(true); }); describe('when next button is clicked', () => { beforeEach(() => { findOffsetPagination().vm.$emit('input', 3); }); it('emits `offset-page-change` event', () => { expect(wrapper.emitted('offset-page-change')[0]).toEqual([3]); }); }); describe('when previous button is clicked', () => { beforeEach(() => { findOffsetPagination().vm.$emit('input', 1); }); it('emits `offset-page-change` event', () => { expect(wrapper.emitted('offset-page-change')[0]).toEqual([1]); }); }); describe('when `page` prop is changed', () => { beforeEach(async () => { wrapper.setProps({ page: 3 }); await waitForPromises(); }); it('calls API with page argument', () => { expect(mockAxios.history.get[1].params.page).toBe(3); }); }); }); }); describe('empty state', () => { describe('when there are no results', () => { beforeEach(async () => { createComponent({ handlers: [[CONTRIBUTED_TAB.query, jest.fn().mockResolvedValue({ nodes: [] })]], propsData: { tab: CONTRIBUTED_TAB }, }); await waitForPromises(); }); it('renders an empty state and passes title and description prop', () => { expect(findEmptyState().props('title')).toBe( CONTRIBUTED_TAB.emptyStateComponentProps.title, ); expect(findEmptyState().props('description')).toBe( CONTRIBUTED_TAB.emptyStateComponentProps.description, ); }); }); describe('when there are results', () => { beforeEach(async () => { createComponent({ handlers: [ [PERSONAL_TAB.query, jest.fn().mockResolvedValue(personalProjectsGraphQlResponse)], ], propsData: { tab: PERSONAL_TAB }, }); await waitForPromises(); }); it('does not render an empty state', () => { expect(findEmptyState().exists()).toBe(false); }); }); }); describe('event tracking', () => { let trackEventSpy; beforeEach(async () => { createComponent({ handlers: [ [PERSONAL_TAB.query, jest.fn().mockResolvedValue(personalProjectsGraphQlResponse)], ], propsData: { tab: PERSONAL_TAB }, }); await waitForPromises(); trackEventSpy = bindInternalEventDocument(wrapper.element).trackEventSpy; }); describe('when visibility is hovered', () => { beforeEach(() => { findProjectsList().vm.$emit('hover-visibility', 'private'); }); it('tracks event', () => { expect(trackEventSpy).toHaveBeenCalledWith( defaultPropsData.eventTracking.hoverVisibility, { label: 'private' }, undefined, ); }); }); describe('when stat is hovered', () => { beforeEach(() => { findProjectsList().vm.$emit('hover-stat', 'stars-count'); }); it('tracks event', () => { expect(trackEventSpy).toHaveBeenCalledWith( defaultPropsData.eventTracking.hoverStat, { label: 'stars-count' }, undefined, ); }); }); describe('when stat is clicked', () => { beforeEach(() => { findProjectsList().vm.$emit('click-stat', 'stars-count'); }); it('tracks event', () => { expect(trackEventSpy).toHaveBeenCalledWith( defaultPropsData.eventTracking.clickStat, { label: 'stars-count' }, undefined, ); }); }); describe('when topic is clicked', () => { beforeEach(() => { findProjectsList().vm.$emit('click-topic'); }); it('tracks event', () => { expect(trackEventSpy).toHaveBeenCalledWith( defaultPropsData.eventTracking.clickTopic, {}, undefined, ); }); }); describe('when avatar is clicked', () => { beforeEach(() => { findProjectsList().vm.$emit('click-avatar'); }); it('tracks event', () => { expect(trackEventSpy).toHaveBeenCalledWith( defaultPropsData.eventTracking.clickItemAfterFilter, { label: PERSONAL_TAB.value, property: JSON.stringify({ search: 'user provided value', language: '8', min_access_level: 50, }), }, undefined, ); }); }); }); });