476 lines
16 KiB
JavaScript
476 lines
16 KiB
JavaScript
import { GlDisclosureDropdown, GlEmptyState } from '@gitlab/ui';
|
|
import * as Sentry from '@sentry/browser';
|
|
import AxiosMockAdapter from 'axios-mock-adapter';
|
|
import Vue, { nextTick } from 'vue';
|
|
import VueApollo from 'vue-apollo';
|
|
import { cloneDeep } from 'lodash';
|
|
import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query.graphql';
|
|
import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue';
|
|
import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
|
|
import createMockApollo from 'helpers/mock_apollo_helper';
|
|
import setWindowLocation from 'helpers/set_window_location_helper';
|
|
import { TEST_HOST } from 'helpers/test_constants';
|
|
import { mountExtended } from 'helpers/vue_test_utils_helper';
|
|
import waitForPromises from 'helpers/wait_for_promises';
|
|
import {
|
|
filteredTokens,
|
|
locationSearch,
|
|
setSortPreferenceMutationResponse,
|
|
setSortPreferenceMutationResponseWithErrors,
|
|
} from 'jest/issues/list/mock_data';
|
|
import { STATUS_ALL, STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
|
|
import IssuesDashboardApp from '~/issues/dashboard/components/issues_dashboard_app.vue';
|
|
import getIssuesCountsQuery from '~/issues/dashboard/queries/get_issues_counts.query.graphql';
|
|
import { CREATED_DESC, i18n, UPDATED_DESC, urlSortParams } from '~/issues/list/constants';
|
|
import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql';
|
|
import { getSortKey, getSortOptions } from '~/issues/list/utils';
|
|
import axios from '~/lib/utils/axios_utils';
|
|
import { scrollUp } from '~/lib/utils/scroll_utils';
|
|
import {
|
|
TOKEN_TYPE_ASSIGNEE,
|
|
TOKEN_TYPE_AUTHOR,
|
|
TOKEN_TYPE_CONFIDENTIAL,
|
|
TOKEN_TYPE_LABEL,
|
|
TOKEN_TYPE_MILESTONE,
|
|
TOKEN_TYPE_MY_REACTION,
|
|
TOKEN_TYPE_SEARCH_WITHIN,
|
|
TOKEN_TYPE_TYPE,
|
|
TOKEN_TYPE_CREATED,
|
|
TOKEN_TYPE_CLOSED,
|
|
} from '~/vue_shared/components/filtered_search_bar/constants';
|
|
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
|
|
import {
|
|
emptyIssuesQueryResponse,
|
|
issuesCountsQueryResponse,
|
|
issuesQueryResponse,
|
|
} from '../mock_data';
|
|
|
|
jest.mock('@sentry/browser');
|
|
jest.mock('~/lib/utils/scroll_utils', () => ({ scrollUp: jest.fn() }));
|
|
|
|
describe('IssuesDashboardApp component', () => {
|
|
let axiosMock;
|
|
let wrapper;
|
|
|
|
Vue.use(VueApollo);
|
|
|
|
const defaultProvide = {
|
|
autocompleteAwardEmojisPath: 'autocomplete/award/emojis/path',
|
|
autocompleteUsersPath: 'autocomplete/users.json',
|
|
calendarPath: 'calendar/path',
|
|
dashboardLabelsPath: 'dashboard/labels/path',
|
|
dashboardMilestonesPath: 'dashboard/milestones/path',
|
|
emptyStateWithFilterSvgPath: 'empty/state/with/filter/svg/path.svg',
|
|
emptyStateWithoutFilterSvgPath: 'empty/state/with/filter/svg/path.svg',
|
|
hasBlockedIssuesFeature: true,
|
|
hasIssueDateFilterFeature: true,
|
|
hasIssuableHealthStatusFeature: true,
|
|
hasIssueWeightsFeature: true,
|
|
hasScopedLabelsFeature: true,
|
|
initialSort: CREATED_DESC,
|
|
isPublicVisibilityRestricted: false,
|
|
isSignedIn: true,
|
|
rssPath: 'rss/path',
|
|
};
|
|
|
|
let defaultQueryResponse = issuesQueryResponse;
|
|
if (IS_EE) {
|
|
defaultQueryResponse = cloneDeep(issuesQueryResponse);
|
|
defaultQueryResponse.data.issues.nodes[0].blockingCount = 1;
|
|
defaultQueryResponse.data.issues.nodes[0].healthStatus = null;
|
|
defaultQueryResponse.data.issues.nodes[0].weight = 5;
|
|
}
|
|
|
|
const findCalendarButton = () => wrapper.findByRole('link', { name: i18n.calendarLabel });
|
|
const findDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
|
|
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
|
|
const findIssuableList = () => wrapper.findComponent(IssuableList);
|
|
const findIssueCardStatistics = () => wrapper.findComponent(IssueCardStatistics);
|
|
const findIssueCardTimeInfo = () => wrapper.findComponent(IssueCardTimeInfo);
|
|
const findRssButton = () => wrapper.findByRole('link', { name: i18n.rssLabel });
|
|
|
|
const mountComponent = ({
|
|
provide = {},
|
|
issuesQueryHandler = jest.fn().mockResolvedValue(defaultQueryResponse),
|
|
issuesCountsQueryHandler = jest.fn().mockResolvedValue(issuesCountsQueryResponse),
|
|
sortPreferenceMutationHandler = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse),
|
|
} = {}) => {
|
|
wrapper = mountExtended(IssuesDashboardApp, {
|
|
apolloProvider: createMockApollo([
|
|
[getIssuesQuery, issuesQueryHandler],
|
|
[getIssuesCountsQuery, issuesCountsQueryHandler],
|
|
[setSortPreferenceMutation, sortPreferenceMutationHandler],
|
|
]),
|
|
provide: {
|
|
...defaultProvide,
|
|
...provide,
|
|
},
|
|
});
|
|
};
|
|
|
|
beforeEach(() => {
|
|
setWindowLocation(TEST_HOST);
|
|
axiosMock = new AxiosMockAdapter(axios);
|
|
});
|
|
|
|
afterEach(() => {
|
|
axiosMock.reset();
|
|
});
|
|
|
|
describe('UI components', () => {
|
|
beforeEach(async () => {
|
|
setWindowLocation(locationSearch);
|
|
mountComponent();
|
|
await waitForPromises();
|
|
});
|
|
|
|
// quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/391722
|
|
// eslint-disable-next-line jest/no-disabled-tests
|
|
it.skip('renders IssuableList component', () => {
|
|
expect(findIssuableList().props()).toMatchObject({
|
|
currentTab: STATUS_OPEN,
|
|
hasNextPage: true,
|
|
hasPreviousPage: false,
|
|
hasScopedLabelsFeature: defaultProvide.hasScopedLabelsFeature,
|
|
initialSortBy: CREATED_DESC,
|
|
issuables: issuesQueryResponse.data.issues.nodes,
|
|
issuablesLoading: false,
|
|
namespace: 'dashboard',
|
|
recentSearchesStorageKey: 'issues',
|
|
searchInputPlaceholder: i18n.searchPlaceholder,
|
|
showPaginationControls: true,
|
|
sortOptions: getSortOptions({
|
|
hasBlockedIssuesFeature: defaultProvide.hasBlockedIssuesFeature,
|
|
hasIssuableHealthStatusFeature: defaultProvide.hasIssuableHealthStatusFeature,
|
|
hasIssueWeightsFeature: defaultProvide.hasIssueWeightsFeature,
|
|
}),
|
|
tabCounts: {
|
|
opened: 1,
|
|
closed: 2,
|
|
all: 3,
|
|
},
|
|
tabs: IssuesDashboardApp.issuableListTabs,
|
|
urlParams: {
|
|
sort: urlSortParams[CREATED_DESC],
|
|
state: STATUS_OPEN,
|
|
},
|
|
useKeysetPagination: true,
|
|
});
|
|
});
|
|
|
|
describe('actions dropdown', () => {
|
|
it('renders', () => {
|
|
expect(findDisclosureDropdown().props()).toMatchObject({
|
|
category: 'tertiary',
|
|
icon: 'ellipsis_v',
|
|
noCaret: true,
|
|
textSrOnly: true,
|
|
toggleText: 'Actions',
|
|
});
|
|
});
|
|
|
|
it('renders RSS button link', () => {
|
|
expect(findRssButton().attributes('href')).toBe(defaultProvide.rssPath);
|
|
});
|
|
|
|
it('renders calendar button link', () => {
|
|
expect(findCalendarButton().attributes('href')).toBe(defaultProvide.calendarPath);
|
|
});
|
|
});
|
|
|
|
it('renders issue time information', () => {
|
|
expect(findIssueCardTimeInfo().exists()).toBe(true);
|
|
});
|
|
|
|
it('renders issue statistics', () => {
|
|
expect(findIssueCardStatistics().exists()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('fetching issues', () => {
|
|
describe('with a search query', () => {
|
|
describe('when there are issues returned', () => {
|
|
beforeEach(async () => {
|
|
setWindowLocation(locationSearch);
|
|
mountComponent();
|
|
await waitForPromises();
|
|
});
|
|
|
|
it('renders the issues', () => {
|
|
expect(findIssuableList().props('issuables')).toEqual(
|
|
defaultQueryResponse.data.issues.nodes,
|
|
);
|
|
});
|
|
|
|
it('does not render empty state', () => {
|
|
expect(findEmptyState().exists()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('when there are no issues returned', () => {
|
|
beforeEach(async () => {
|
|
setWindowLocation(locationSearch);
|
|
mountComponent({
|
|
issuesQueryHandler: jest.fn().mockResolvedValue(emptyIssuesQueryResponse),
|
|
});
|
|
await waitForPromises();
|
|
});
|
|
|
|
it('renders no issues', () => {
|
|
expect(findIssuableList().props('issuables')).toEqual([]);
|
|
});
|
|
|
|
it('renders empty state', () => {
|
|
expect(findEmptyState().props()).toMatchObject({
|
|
description: i18n.noSearchResultsDescription,
|
|
svgPath: defaultProvide.emptyStateWithFilterSvgPath,
|
|
title: i18n.noSearchResultsTitle,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('with no search query', () => {
|
|
let issuesQueryHandler;
|
|
|
|
beforeEach(async () => {
|
|
issuesQueryHandler = jest.fn().mockResolvedValue(defaultQueryResponse);
|
|
mountComponent({ issuesQueryHandler });
|
|
await waitForPromises();
|
|
});
|
|
|
|
it('does not call issues query', () => {
|
|
expect(issuesQueryHandler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('renders empty state', () => {
|
|
expect(findEmptyState().props()).toMatchObject({
|
|
description: null,
|
|
svgPath: defaultProvide.emptyStateWithoutFilterSvgPath,
|
|
title: i18n.noSearchNoFilterTitle,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('initial url params', () => {
|
|
describe('search', () => {
|
|
it('is set from the url params', () => {
|
|
setWindowLocation(locationSearch);
|
|
mountComponent();
|
|
|
|
expect(findIssuableList().props('urlParams')).toMatchObject({ search: 'find issues' });
|
|
});
|
|
});
|
|
|
|
describe('sort', () => {
|
|
describe('when initial sort value uses old enum values', () => {
|
|
const oldEnumSortValues = Object.values(urlSortParams);
|
|
|
|
it.each(oldEnumSortValues)('initial sort is set with value %s', (sort) => {
|
|
mountComponent({ provide: { initialSort: sort } });
|
|
|
|
expect(findIssuableList().props('initialSortBy')).toBe(getSortKey(sort));
|
|
});
|
|
});
|
|
|
|
describe('when initial sort value uses new GraphQL enum values', () => {
|
|
const graphQLEnumSortValues = Object.keys(urlSortParams);
|
|
|
|
it.each(graphQLEnumSortValues)('initial sort is set with value %s', (sort) => {
|
|
mountComponent({ provide: { initialSort: sort.toLowerCase() } });
|
|
|
|
expect(findIssuableList().props('initialSortBy')).toBe(sort);
|
|
});
|
|
});
|
|
|
|
describe('when initial sort value is invalid', () => {
|
|
it.each(['', 'asdf', null, undefined])(
|
|
'initial sort is set to value CREATED_DESC',
|
|
(sort) => {
|
|
mountComponent({ provide: { initialSort: sort } });
|
|
|
|
expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('state', () => {
|
|
it('is set from the url params', () => {
|
|
const initialState = STATUS_ALL;
|
|
setWindowLocation(`?state=${initialState}`);
|
|
mountComponent();
|
|
|
|
expect(findIssuableList().props('currentTab')).toBe(initialState);
|
|
});
|
|
});
|
|
|
|
describe('filter tokens', () => {
|
|
it('is set from the url params', () => {
|
|
setWindowLocation(locationSearch);
|
|
mountComponent();
|
|
|
|
expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('errors', () => {
|
|
describe.each`
|
|
error | mountOption | message
|
|
${'fetching issues'} | ${'issuesQueryHandler'} | ${i18n.errorFetchingIssues}
|
|
${'fetching issue counts'} | ${'issuesCountsQueryHandler'} | ${i18n.errorFetchingCounts}
|
|
`('when there is an error $error', ({ mountOption, message }) => {
|
|
beforeEach(async () => {
|
|
setWindowLocation(locationSearch);
|
|
mountComponent({ [mountOption]: jest.fn().mockRejectedValue(new Error('ERROR')) });
|
|
await waitForPromises();
|
|
});
|
|
|
|
it('shows an error message', () => {
|
|
expect(findIssuableList().props('error')).toBe(message);
|
|
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('ERROR'));
|
|
});
|
|
});
|
|
|
|
it('clears error message when "dismiss-alert" event is emitted from IssuableList', async () => {
|
|
mountComponent({ issuesQueryHandler: jest.fn().mockRejectedValue(new Error()) });
|
|
|
|
findIssuableList().vm.$emit('dismiss-alert');
|
|
await nextTick();
|
|
|
|
expect(findIssuableList().props('error')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('tokens', () => {
|
|
const mockCurrentUser = {
|
|
id: 1,
|
|
name: 'Administrator',
|
|
username: 'root',
|
|
avatar_url: 'avatar/url',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
window.gon = {
|
|
current_user_id: mockCurrentUser.id,
|
|
current_user_fullname: mockCurrentUser.name,
|
|
current_username: mockCurrentUser.username,
|
|
current_user_avatar_url: mockCurrentUser.avatar_url,
|
|
};
|
|
mountComponent();
|
|
});
|
|
|
|
it('renders all tokens alphabetically', () => {
|
|
const preloadedUsers = [{ ...mockCurrentUser, id: mockCurrentUser.id }];
|
|
|
|
expect(findIssuableList().props('searchTokens')).toMatchObject([
|
|
{ type: TOKEN_TYPE_ASSIGNEE, preloadedUsers },
|
|
{ type: TOKEN_TYPE_AUTHOR, preloadedUsers },
|
|
{ type: TOKEN_TYPE_CLOSED },
|
|
{ type: TOKEN_TYPE_CONFIDENTIAL },
|
|
{ type: TOKEN_TYPE_CREATED },
|
|
{ type: TOKEN_TYPE_LABEL },
|
|
{ type: TOKEN_TYPE_MILESTONE },
|
|
{ type: TOKEN_TYPE_MY_REACTION },
|
|
{ type: TOKEN_TYPE_SEARCH_WITHIN },
|
|
{ type: TOKEN_TYPE_TYPE },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('events', () => {
|
|
describe('when "click-tab" event is emitted by IssuableList', () => {
|
|
beforeEach(() => {
|
|
mountComponent();
|
|
|
|
findIssuableList().vm.$emit('click-tab', STATUS_CLOSED);
|
|
});
|
|
|
|
it('updates ui to the new tab', () => {
|
|
expect(findIssuableList().props('currentTab')).toBe(STATUS_CLOSED);
|
|
});
|
|
|
|
it('updates url to the new tab', () => {
|
|
expect(findIssuableList().props('urlParams')).toMatchObject({
|
|
state: STATUS_CLOSED,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe.each(['next-page', 'previous-page'])(
|
|
'when "%s" event is emitted by IssuableList',
|
|
(event) => {
|
|
beforeEach(() => {
|
|
mountComponent();
|
|
|
|
findIssuableList().vm.$emit(event);
|
|
});
|
|
|
|
it('scrolls to the top', () => {
|
|
expect(scrollUp).toHaveBeenCalled();
|
|
});
|
|
},
|
|
);
|
|
|
|
describe('when "sort" event is emitted by IssuableList', () => {
|
|
it.each(Object.keys(urlSortParams))(
|
|
'updates to the new sort when payload is `%s`',
|
|
async (sortKey) => {
|
|
// Ensure initial sort key is different so we can trigger an update when emitting a sort key
|
|
if (sortKey === CREATED_DESC) {
|
|
mountComponent({ provide: { initialSort: UPDATED_DESC } });
|
|
} else {
|
|
mountComponent();
|
|
}
|
|
|
|
findIssuableList().vm.$emit('sort', sortKey);
|
|
await nextTick();
|
|
|
|
expect(findIssuableList().props('urlParams')).toMatchObject({
|
|
sort: urlSortParams[sortKey],
|
|
});
|
|
},
|
|
);
|
|
|
|
describe('when user is signed in', () => {
|
|
it('calls mutation to save sort preference', () => {
|
|
const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse);
|
|
mountComponent({ sortPreferenceMutationHandler: mutationMock });
|
|
|
|
findIssuableList().vm.$emit('sort', UPDATED_DESC);
|
|
|
|
expect(mutationMock).toHaveBeenCalledWith({ input: { issuesSort: UPDATED_DESC } });
|
|
});
|
|
|
|
it('captures error when mutation response has errors', async () => {
|
|
const mutationMock = jest
|
|
.fn()
|
|
.mockResolvedValue(setSortPreferenceMutationResponseWithErrors);
|
|
mountComponent({ sortPreferenceMutationHandler: mutationMock });
|
|
|
|
findIssuableList().vm.$emit('sort', UPDATED_DESC);
|
|
await waitForPromises();
|
|
|
|
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!'));
|
|
});
|
|
});
|
|
|
|
describe('when user is signed out', () => {
|
|
it('does not call mutation to save sort preference', () => {
|
|
const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse);
|
|
mountComponent({
|
|
provide: { isSignedIn: false },
|
|
sortPreferenceMutationHandler: mutationMock,
|
|
});
|
|
|
|
findIssuableList().vm.$emit('sort', CREATED_DESC);
|
|
|
|
expect(mutationMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|