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,
);
});
});
});
});