import { GlButton, GlDisclosureDropdown, GlDrawer } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { cloneDeep } from 'lodash';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import {
filteredTokens,
getIssuesCountsQueryResponse,
getIssuesQueryEmptyResponse,
getIssuesQueryResponse,
locationSearch,
setSortPreferenceMutationResponse,
setSortPreferenceMutationResponseWithErrors,
urlParams,
} from 'jest/issues/list/mock_data';
import { createAlert, VARIANT_INFO } from '~/alert';
import { TYPENAME_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { STATUS_ALL, STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { issuableListTabs } from '~/vue_shared/issuable/list/constants';
import EmptyStateWithAnyIssues from '~/issues/list/components/empty_state_with_any_issues.vue';
import EmptyStateWithoutAnyIssues from '~/issues/list/components/empty_state_without_any_issues.vue';
import IssuesListApp from '~/issues/list/components/issues_list_app.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue';
import {
CREATED_DESC,
RELATIVE_POSITION,
RELATIVE_POSITION_ASC,
UPDATED_DESC,
urlSortParams,
} from '~/issues/list/constants';
import eventHub from '~/issues/list/eventhub';
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 { HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import {
WORK_ITEM_TYPE_ENUM_INCIDENT,
WORK_ITEM_TYPE_ENUM_ISSUE,
WORK_ITEM_TYPE_ENUM_TASK,
WORK_ITEM_TYPE_ENUM_TEST_CASE,
} from '~/work_items/constants';
import {
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
TOKEN_TYPE_CONTACT,
TOKEN_TYPE_LABEL,
TOKEN_TYPE_MILESTONE,
TOKEN_TYPE_MY_REACTION,
TOKEN_TYPE_ORGANIZATION,
TOKEN_TYPE_RELEASE,
TOKEN_TYPE_SEARCH_WITHIN,
TOKEN_TYPE_TYPE,
TOKEN_TYPE_CREATED,
TOKEN_TYPE_CLOSED,
} from '~/vue_shared/components/filtered_search_bar/constants';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
import {
workItemResponseFactory,
workItemByIidResponseFactory,
mockAwardEmojiThumbsUp,
mockAwardsWidget,
mockAssignees,
mockLabels,
mockMilestone,
} from 'jest/work_items/mock_data';
import('~/issuable');
import('~/users_select');
jest.mock('~/sentry/sentry_browser_wrapper');
jest.mock('~/alert');
jest.mock('~/lib/utils/scroll_utils', () => ({ scrollUp: jest.fn() }));
describe('CE IssuesListApp component', () => {
let axiosMock;
let wrapper;
Vue.use(VueApollo);
Vue.use(VueRouter);
const defaultProvide = {
autocompleteAwardEmojisPath: 'autocomplete/award/emojis/path',
calendarPath: 'calendar/path',
canBulkUpdate: false,
canCreateProjects: false,
canReadCrmContact: false,
canReadCrmOrganization: false,
emptyStateSvgPath: 'empty-state.svg',
exportCsvPath: 'export/csv/path',
fullPath: 'path/to/project',
hasAnyIssues: true,
hasAnyProjects: true,
hasBlockedIssuesFeature: true,
hasIssueDateFilterFeature: true,
hasIssuableHealthStatusFeature: true,
hasIssueWeightsFeature: true,
hasIterationsFeature: true,
hasScopedLabelsFeature: true,
initialEmail: 'email@example.com',
initialSort: CREATED_DESC,
isIssueRepositioningDisabled: false,
isProject: true,
isPublicVisibilityRestricted: false,
isSignedIn: true,
jiraIntegrationPath: 'jira/integration/path',
newIssuePath: 'new/issue/path',
newProjectPath: 'new/project/path',
releasesPath: 'releases/path',
rssPath: 'rss/path',
showNewIssueLink: true,
signInPath: 'sign/in/path',
groupId: '',
};
let defaultQueryResponse = getIssuesQueryResponse;
let router;
if (IS_EE) {
defaultQueryResponse = cloneDeep(getIssuesQueryResponse);
defaultQueryResponse.data.project.issues.nodes[0].blockingCount = 1;
defaultQueryResponse.data.project.issues.nodes[0].healthStatus = null;
defaultQueryResponse.data.project.issues.nodes[0].weight = 5;
}
const mockIssuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse);
const mockIssuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse);
const deleteWorkItemMutationHandler = jest
.fn()
.mockResolvedValue({ data: { workItemDelete: { errors: [] } } });
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail);
const findGlButton = () => wrapper.findComponent(GlButton);
const findGlButtons = () => wrapper.findAllComponents(GlButton);
const findIssuableList = () => wrapper.findComponent(IssuableList);
const findListViewTypeBtn = () => wrapper.findByTestId('list-view-type');
const findGridtViewTypeBtn = () => wrapper.findByTestId('grid-view-type');
const findViewTypeLocalStorageSync = () => wrapper.findAllComponents(LocalStorageSync).at(0);
const findNewResourceDropdown = () => wrapper.findComponent(NewResourceDropdown);
const findCalendarButton = () => wrapper.findByTestId('subscribe-calendar');
const findRssButton = () => wrapper.findByTestId('subscribe-rss');
const findIssuableDrawer = () => wrapper.findComponent(GlDrawer);
const findDrawerWorkItem = () => wrapper.findComponent(WorkItemDetail);
const findLabelsToken = () =>
findIssuableList()
.props('searchTokens')
.find((token) => token.type === TOKEN_TYPE_LABEL);
const mountComponent = ({
provide = {},
data = {},
issuesQueryResponse = mockIssuesQueryResponse,
issuesCountsQueryResponse = mockIssuesCountsQueryResponse,
sortPreferenceMutationResponse = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse),
stubs = {},
mountFn = shallowMount,
deleteMutationHandler = deleteWorkItemMutationHandler,
} = {}) => {
const requestHandlers = [
[getIssuesQuery, issuesQueryResponse],
[getIssuesCountsQuery, issuesCountsQueryResponse],
[setSortPreferenceMutation, sortPreferenceMutationResponse],
[deleteWorkItemMutation, deleteMutationHandler],
];
router = new VueRouter({ mode: 'history' });
return mountFn(IssuesListApp, {
apolloProvider: createMockApollo(
requestHandlers,
{},
{
typePolicies: {
Query: {
fields: {
project: {
merge: true,
},
group: {
merge: true,
},
},
},
},
},
),
router,
provide: {
...defaultProvide,
...provide,
},
data() {
return data;
},
stubs,
});
};
beforeEach(() => {
setWindowLocation(TEST_HOST);
axiosMock = new AxiosMockAdapter(axios);
});
afterEach(() => {
axiosMock.reset();
});
describe('IssuableList', () => {
beforeEach(() => {
wrapper = mountComponent();
return waitForPromises();
});
it('renders', () => {
expect(findIssuableList().props()).toMatchObject({
namespace: defaultProvide.fullPath,
recentSearchesStorageKey: 'issues',
sortOptions: getSortOptions({
hasBlockedIssuesFeature: defaultProvide.hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature: defaultProvide.hasIssuableHealthStatusFeature,
hasIssueWeightsFeature: defaultProvide.hasIssueWeightsFeature,
}),
initialSortBy: CREATED_DESC,
issuables: getIssuesQueryResponse.data.project.issues.nodes,
tabs: issuableListTabs,
currentTab: STATUS_OPEN,
tabCounts: {
opened: 1,
closed: 1,
all: 1,
},
issuablesLoading: false,
isManualOrdering: false,
showBulkEditSidebar: false,
showPaginationControls: true,
useKeysetPagination: true,
hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage,
hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage,
});
expect(findIssuableList().props('isGridView')).toBe(false);
});
});
describe('header action buttons', () => {
describe('actions dropdown', () => {
it('renders', () => {
wrapper = mountComponent({ mountFn: mount });
expect(findDropdown().props()).toMatchObject({
category: 'tertiary',
icon: 'ellipsis_v',
toggleText: 'Actions',
textSrOnly: true,
});
});
describe('csv import/export buttons', () => {
describe('when user is signed in', () => {
beforeEach(() => {
setWindowLocation('?search=refactor&state=opened');
wrapper = mountComponent({
provide: { initialSortBy: CREATED_DESC, isSignedIn: true },
mountFn: mount,
});
return waitForPromises();
});
it('renders', () => {
expect(findCsvImportExportButtons().props()).toMatchObject({
exportCsvPath: `${defaultProvide.exportCsvPath}?search=refactor&state=opened`,
issuableCount: 1,
});
});
});
describe('when user is not signed in', () => {
it('does not render', () => {
wrapper = mountComponent({ provide: { isSignedIn: false }, mountFn: mount });
expect(findCsvImportExportButtons().exists()).toBe(false);
});
});
describe('when in a group context', () => {
it('does not render', () => {
wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount });
expect(findCsvImportExportButtons().exists()).toBe(false);
});
});
});
it('renders RSS button link', () => {
wrapper = mountComponent({ mountFn: mountExtended });
expect(findRssButton().attributes('href')).toBe(defaultProvide.rssPath);
});
it('renders calendar button link', () => {
wrapper = mountComponent({ mountFn: mountExtended });
expect(findCalendarButton().attributes('href')).toBe(defaultProvide.calendarPath);
});
});
describe('bulk edit button', () => {
it('renders when user has permissions', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount });
expect(findGlButton().text()).toBe('Bulk edit');
});
it('does not render when user does not have permissions', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: false }, mountFn: mount });
expect(findGlButtons().filter((button) => button.text() === 'Bulk edit')).toHaveLength(0);
});
it('emits "issuables:enableBulkEdit" event to legacy bulk edit class', async () => {
wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount });
jest.spyOn(eventHub, '$emit');
findGlButton().vm.$emit('click');
await waitForPromises();
expect(eventHub.$emit).toHaveBeenCalledWith('issuables:enableBulkEdit');
});
});
describe('new issue button', () => {
it('renders when user has permissions', () => {
wrapper = mountComponent({ provide: { showNewIssueLink: true }, mountFn: mount });
expect(findGlButton().text()).toBe('New issue');
expect(findGlButton().attributes('href')).toBe(defaultProvide.newIssuePath);
});
it('does not render when user does not have permissions', () => {
wrapper = mountComponent({ provide: { showNewIssueLink: false }, mountFn: mount });
expect(findGlButtons().filter((button) => button.text() === 'New issue')).toHaveLength(0);
});
});
describe('new issue split dropdown', () => {
it('does not render in a project context', () => {
wrapper = mountComponent({ provide: { isProject: true }, mountFn: mount });
expect(findNewResourceDropdown().exists()).toBe(false);
});
it('renders in a group context', () => {
wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount });
expect(findNewResourceDropdown().exists()).toBe(true);
});
});
});
describe('header action buttons with the grid view enabled', () => {
beforeEach(() => {
wrapper = mountComponent({
mountFn: shallowMountExtended,
provide: {
glFeatures: {
issuesGridView: true,
},
},
stubs: {
IssuableList: stubComponent(IssuableList, {
template: `
`,
}),
},
});
});
it('switch between list and grid', async () => {
findGridtViewTypeBtn().vm.$emit('click');
await nextTick();
expect(findIssuableList().props('isGridView')).toBe(true);
expect(findViewTypeLocalStorageSync().props('value')).toBe('Grid');
findListViewTypeBtn().vm.$emit('click');
await nextTick();
expect(findIssuableList().props('isGridView')).toBe(false);
expect(findViewTypeLocalStorageSync().props('value')).toBe('List');
});
});
describe('initial url params', () => {
describe('page', () => {
it('page_after is set from the url params', () => {
setWindowLocation('?page_after=randomCursorString&first_page_size=20');
wrapper = mountComponent();
expect(wrapper.vm.$route.query).toMatchObject({
page_after: 'randomCursorString',
first_page_size: '20',
});
});
it('page_before is set from the url params', () => {
setWindowLocation('?page_before=anotherRandomCursorString&last_page_size=20');
wrapper = mountComponent();
expect(wrapper.vm.$route.query).toMatchObject({
page_before: 'anotherRandomCursorString',
last_page_size: '20',
});
});
});
describe('search', () => {
it('is set from the url params', () => {
setWindowLocation(locationSearch);
wrapper = mountComponent();
expect(wrapper.vm.$route.query).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) => {
wrapper = 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) => {
wrapper = 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) => {
wrapper = mountComponent({ provide: { initialSort: sort } });
expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC);
},
);
});
describe('when sort is manual and issue repositioning is disabled', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: { initialSort: RELATIVE_POSITION, isIssueRepositioningDisabled: true },
});
});
it('changes the sort to the default of created descending', () => {
expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC);
});
it('shows an alert to tell the user that manual reordering is disabled', () => {
expect(createAlert).toHaveBeenCalledWith({
message: IssuesListApp.i18n.issueRepositioningMessage,
variant: VARIANT_INFO,
});
});
});
});
describe('state', () => {
it('is set from the url params', () => {
const initialState = STATUS_ALL;
setWindowLocation(`?state=${initialState}`);
wrapper = mountComponent();
expect(findIssuableList().props('currentTab')).toBe(initialState);
});
});
describe('filter tokens', () => {
it('is set from the url params', () => {
setWindowLocation(locationSearch);
wrapper = mountComponent();
expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens);
});
});
});
describe('bulk edit', () => {
describe.each([true, false])(
'when "issuables:toggleBulkEdit" event is received with payload `%s`',
(isBulkEdit) => {
beforeEach(() => {
wrapper = mountComponent();
eventHub.$emit('issuables:toggleBulkEdit', isBulkEdit);
});
it(`${isBulkEdit ? 'enables' : 'disables'} bulk edit`, () => {
expect(findIssuableList().props('showBulkEditSidebar')).toBe(isBulkEdit);
});
},
);
});
describe('IssuableByEmail component', () => {
describe.each`
initialEmail | hasAnyIssues | isSignedIn | exists
${false} | ${false} | ${false} | ${false}
${false} | ${true} | ${false} | ${false}
${false} | ${false} | ${true} | ${false}
${false} | ${true} | ${true} | ${false}
${true} | ${false} | ${false} | ${false}
${true} | ${true} | ${false} | ${false}
${true} | ${false} | ${true} | ${true}
${true} | ${true} | ${true} | ${true}
`(
`when issue creation by email is enabled=$initialEmail`,
({ initialEmail, hasAnyIssues, isSignedIn, exists }) => {
it(`${initialEmail ? 'renders' : 'does not render'}`, () => {
wrapper = mountComponent({ provide: { initialEmail, hasAnyIssues, isSignedIn } });
expect(findIssuableByEmail().exists()).toBe(exists);
});
},
);
});
describe('empty states', () => {
describe('when there are issues', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: { hasAnyIssues: true },
mountFn: mount,
issuesQueryResponse: getIssuesQueryEmptyResponse,
});
return waitForPromises();
});
it('shows EmptyStateWithAnyIssues empty state', () => {
expect(wrapper.findComponent(EmptyStateWithAnyIssues).props()).toEqual({
hasSearch: false,
isOpenTab: true,
});
});
});
describe('when there are no issues', () => {
beforeEach(() => {
wrapper = mountComponent({ provide: { hasAnyIssues: false } });
});
it('shows EmptyStateWithoutAnyIssues empty state', () => {
expect(wrapper.findComponent(EmptyStateWithoutAnyIssues).props()).toEqual({
currentTabCount: 0,
exportCsvPathWithQuery: defaultProvide.exportCsvPath,
showCsvButtons: true,
showNewIssueDropdown: false,
});
});
});
});
describe('tokens', () => {
const mockCurrentUser = {
id: 1,
name: 'Administrator',
username: 'root',
avatar_url: 'avatar/url',
};
describe('when user is signed out', () => {
beforeEach(() => {
wrapper = mountComponent({ provide: { isSignedIn: false } });
});
it('does not render My-Reaction or Confidential tokens', () => {
expect(findIssuableList().props('searchTokens')).not.toMatchObject([
{ type: TOKEN_TYPE_AUTHOR, preloadedUsers: [mockCurrentUser] },
{ type: TOKEN_TYPE_ASSIGNEE, preloadedUsers: [mockCurrentUser] },
{ type: TOKEN_TYPE_MY_REACTION },
{ type: TOKEN_TYPE_CONFIDENTIAL },
]);
});
});
describe('when user does not have CRM enabled', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: { canReadCrmContact: false, canReadCrmOrganization: false },
});
});
it('does not render Contact or Organization tokens', () => {
expect(findIssuableList().props('searchTokens')).not.toMatchObject([
{ type: TOKEN_TYPE_CONTACT },
{ type: TOKEN_TYPE_ORGANIZATION },
]);
});
});
describe('when all tokens are available', () => {
beforeEach(() => {
window.gon = {
current_user_id: mockCurrentUser.id,
current_user_fullname: mockCurrentUser.name,
current_username: mockCurrentUser.username,
current_user_avatar_url: mockCurrentUser.avatar_url,
};
wrapper = mountComponent({
provide: {
canReadCrmContact: true,
canReadCrmOrganization: true,
isSignedIn: true,
},
});
});
it('renders all tokens alphabetically', () => {
const preloadedUsers = [
{ ...mockCurrentUser, id: convertToGraphQLId(TYPENAME_USER, 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_CONTACT },
{ type: TOKEN_TYPE_CREATED },
{ type: TOKEN_TYPE_LABEL },
{ type: TOKEN_TYPE_MILESTONE },
{ type: TOKEN_TYPE_MY_REACTION },
{ type: TOKEN_TYPE_ORGANIZATION },
{ type: TOKEN_TYPE_RELEASE },
{ type: TOKEN_TYPE_SEARCH_WITHIN },
{ type: TOKEN_TYPE_TYPE },
]);
});
});
});
describe('errors', () => {
describe.each`
error | mountOption | message
${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues}
${'fetching issue counts'} | ${'issuesCountsQueryResponse'} | ${IssuesListApp.i18n.errorFetchingCounts}
`('when there is an error $error', ({ mountOption, message }) => {
beforeEach(() => {
wrapper = mountComponent({
[mountOption]: jest.fn().mockRejectedValue(new Error('ERROR')),
});
return 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', () => {
wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockRejectedValue(new Error()) });
findIssuableList().vm.$emit('dismiss-alert');
expect(findIssuableList().props('error')).toBeNull();
});
});
describe('events', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(async () => {
wrapper = mountComponent();
await waitForPromises();
router.push = jest.fn();
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(router.push).toHaveBeenCalledWith({
query: expect.objectContaining({ state: STATUS_CLOSED }),
});
});
});
describe.each`
event | params
${'next-page'} | ${{ page_after: 'endcursor', page_before: undefined, first_page_size: 20, last_page_size: undefined }}
${'previous-page'} | ${{ page_after: undefined, page_before: 'startcursor', first_page_size: undefined, last_page_size: 20 }}
`('when "$event" event is emitted by IssuableList', ({ event, params }) => {
beforeEach(async () => {
wrapper = mountComponent({
data: {
pageInfo: {
endCursor: 'endCursor',
startCursor: 'startCursor',
},
},
});
await waitForPromises();
router.push = jest.fn();
findIssuableList().vm.$emit(event);
});
it('scrolls to the top', () => {
expect(scrollUp).toHaveBeenCalled();
});
it(`updates url`, () => {
expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining(params),
});
});
});
describe('when "reorder" event is emitted by IssuableList', () => {
const issueOne = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/1',
iid: '101',
reference: 'group/project#1',
webPath: '/group/project/-/issues/1',
};
const issueTwo = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/2',
iid: '102',
reference: 'group/project#2',
webPath: '/group/project/-/issues/2',
};
const issueThree = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/3',
iid: '103',
reference: 'group/project#3',
webPath: '/group/project/-/issues/3',
};
const issueFour = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/4',
iid: '104',
reference: 'group/project#4',
webPath: '/group/project/-/issues/4',
};
const response = (isProject = true) => ({
data: {
[isProject ? 'project' : 'group']: {
id: '1',
issues: {
...defaultQueryResponse.data.project.issues,
nodes: [issueOne, issueTwo, issueThree, issueFour],
},
},
},
});
describe('when successful', () => {
describe.each([true, false])('when isProject=%s', (isProject) => {
describe.each`
description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId
${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id}
${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id}
${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id}
${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null}
`(
'when moving issue $description',
({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
beforeEach(() => {
wrapper = mountComponent({
provide: { isProject },
issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)),
});
return waitForPromises();
});
it('makes API call to reorder the issue', async () => {
findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
await waitForPromises();
expect(axiosMock.history.put[0]).toMatchObject({
url: joinPaths(issueToMove.webPath, 'reorder'),
data: JSON.stringify({
move_before_id: getIdFromGraphQLId(moveBeforeId),
move_after_id: getIdFromGraphQLId(moveAfterId),
}),
});
});
},
);
});
});
describe('when unsuccessful', () => {
beforeEach(() => {
wrapper = mountComponent({
issuesQueryResponse: jest.fn().mockResolvedValue(response()),
});
return waitForPromises();
});
it('displays an error message', async () => {
axiosMock
.onPut(joinPaths(issueOne.webPath, 'reorder'))
.reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 });
await waitForPromises();
expect(findIssuableList().props('error')).toBe(IssuesListApp.i18n.reorderError);
expect(Sentry.captureException).toHaveBeenCalledWith(
new Error('Request failed with status code 500'),
);
});
});
});
describe('when "sort" event is emitted by IssuableList', () => {
it.each(Object.keys(urlSortParams))(
'updates to the new sort when payload is `%s`',
(sortKey) => {
// Ensure initial sort key is different so we can trigger an update when emitting a sort key
wrapper =
sortKey === CREATED_DESC
? mountComponent({ provide: { initialSort: UPDATED_DESC } })
: mountComponent();
router.push = jest.fn();
findIssuableList().vm.$emit('sort', sortKey);
expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining({ sort: urlSortParams[sortKey] }),
});
},
);
describe('when issue repositioning is disabled', () => {
const initialSort = CREATED_DESC;
beforeEach(() => {
wrapper = mountComponent({
provide: { initialSort, isIssueRepositioningDisabled: true },
});
router.push = jest.fn();
findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC);
});
it('does not update the sort to manual', () => {
expect(router.push).not.toHaveBeenCalled();
});
it('shows an alert to tell the user that manual reordering is disabled', () => {
expect(createAlert).toHaveBeenCalledWith({
message: IssuesListApp.i18n.issueRepositioningMessage,
variant: VARIANT_INFO,
});
});
});
describe('when user is signed in', () => {
it('calls mutation to save sort preference', () => {
const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse);
wrapper = mountComponent({ sortPreferenceMutationResponse: 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);
wrapper = mountComponent({ sortPreferenceMutationResponse: 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);
wrapper = mountComponent({
provide: { isSignedIn: false },
sortPreferenceMutationResponse: mutationMock,
});
findIssuableList().vm.$emit('sort', CREATED_DESC);
expect(mutationMock).not.toHaveBeenCalled();
});
});
});
describe('when "update-legacy-bulk-edit" event is emitted by IssuableList', () => {
beforeEach(() => {
wrapper = mountComponent();
jest.spyOn(eventHub, '$emit');
findIssuableList().vm.$emit('update-legacy-bulk-edit');
});
it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
});
});
describe('when "filter" event is emitted by IssuableList', () => {
it('updates IssuableList with url params', async () => {
wrapper = mountComponent();
router.push = jest.fn();
findIssuableList().vm.$emit('filter', filteredTokens);
await nextTick();
expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining(urlParams),
});
});
});
describe('when "page-size-change" event is emitted by IssuableList', () => {
it('updates url params with new page size', async () => {
wrapper = mountComponent();
router.push = jest.fn();
findIssuableList().vm.$emit('page-size-change', 50);
await nextTick();
expect(router.push).toHaveBeenCalledTimes(1);
expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining({ first_page_size: 50 }),
});
});
});
});
describe('public visibility', () => {
it.each`
description | isPublicVisibilityRestricted | isSignedIn | hideUsers
${'shows users when public visibility is not restricted and is not signed in'} | ${false} | ${false} | ${false}
${'shows users when public visibility is not restricted and is signed in'} | ${false} | ${true} | ${false}
${'hides users when public visibility is restricted and is not signed in'} | ${true} | ${false} | ${true}
${'shows users when public visibility is restricted and is signed in'} | ${true} | ${true} | ${false}
`('$description', async ({ isPublicVisibilityRestricted, isSignedIn, hideUsers }) => {
const mockQuery = jest.fn().mockResolvedValue(defaultQueryResponse);
wrapper = mountComponent({
provide: { isPublicVisibilityRestricted, isSignedIn },
issuesQueryResponse: mockQuery,
});
await waitForPromises();
expect(mockQuery).toHaveBeenCalledWith(expect.objectContaining({ hideUsers }));
});
});
describe('fetching issues', () => {
beforeEach(() => {
wrapper = mountComponent();
});
it('fetches issue, incident, test case, and task types', () => {
const types = [
WORK_ITEM_TYPE_ENUM_ISSUE,
WORK_ITEM_TYPE_ENUM_INCIDENT,
WORK_ITEM_TYPE_ENUM_TEST_CASE,
WORK_ITEM_TYPE_ENUM_TASK,
];
expect(mockIssuesQueryResponse).toHaveBeenCalledWith(expect.objectContaining({ types }));
expect(mockIssuesCountsQueryResponse).toHaveBeenCalledWith(
expect.objectContaining({ types }),
);
});
});
describe('when providing token for labels', () => {
it('passes function to fetchLatestLabels property if frontend caching is enabled', () => {
wrapper = mountComponent({
provide: {
glFeatures: {
frontendCaching: true,
},
},
});
expect(typeof findLabelsToken().fetchLatestLabels).toBe('function');
});
it('passes null to fetchLatestLabels property if frontend caching is disabled', () => {
wrapper = mountComponent({
provide: {
glFeatures: {
frontendCaching: false,
},
},
});
expect(findLabelsToken().fetchLatestLabels).toBe(null);
});
});
describe('when issue drawer is enabled', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: {
glFeatures: {
issuesListDrawer: true,
},
},
stubs: {
GlDrawer,
},
});
});
it('renders issuable drawer component', () => {
expect(findIssuableDrawer().exists()).toBe(true);
});
it('renders issuable drawer closed by default', () => {
expect(findIssuableDrawer().props('open')).toBe(false);
});
describe('on selecting an issuable', () => {
beforeEach(() => {
findIssuableList().vm.$emit(
'select-issuable',
getIssuesQueryResponse.data.project.issues.nodes[0],
);
return nextTick();
});
it('opens issuable drawer', () => {
expect(findIssuableDrawer().props('open')).toBe(true);
});
it('selects active issuable', () => {
expect(findIssuableList().props('activeIssuable')).toEqual(
getIssuesQueryResponse.data.project.issues.nodes[0],
);
});
describe('when closing the drawer', () => {
it('closes the drawer on drawer `close` event', async () => {
findIssuableDrawer().vm.$emit('close');
await nextTick();
expect(findIssuableDrawer().props('open')).toBe(false);
});
it('removes active issuable', async () => {
findIssuableDrawer().vm.$emit('close');
await nextTick();
expect(findIssuableList().props('activeIssuable')).toBe(null);
});
});
describe('when updating an issuable', () => {
it('refetches the list if the issuable changed state', async () => {
const {
data: { workItem },
} = workItemResponseFactory({ iid: '789', state: 'CLOSED' });
findDrawerWorkItem().vm.$emit('work-item-updated', workItem);
await waitForPromises();
expect(mockIssuesQueryResponse).toHaveBeenCalledTimes(2);
expect(mockIssuesCountsQueryResponse).toHaveBeenCalledTimes(2);
});
it('updates the assignees field of active issuable', async () => {
const {
data: { workItem },
} = workItemResponseFactory({ iid: '789' });
findDrawerWorkItem().vm.$emit('work-item-updated', workItem);
await waitForPromises();
expect(findIssuableList().props('issuables')[0].assignees.nodes).toEqual(
mockAssignees.map((assignee) => ({
...assignee,
__persist: true,
})),
);
});
it('updates the labels field of active issuable', async () => {
const {
data: { workItem },
} = workItemResponseFactory({ iid: '789' });
findDrawerWorkItem().vm.$emit('work-item-updated', workItem);
await waitForPromises();
expect(findIssuableList().props('issuables')[0].labels.nodes).toEqual(
mockLabels.map((label) => ({
...label,
__persist: true,
textColor: undefined,
})),
);
});
it('updates the upvotes count of active issuable', async () => {
const workItem = workItemByIidResponseFactory({
iid: '789',
awardEmoji: {
...mockAwardsWidget,
nodes: [mockAwardEmojiThumbsUp],
},
}).data.workspace.workItems.nodes[0];
findDrawerWorkItem().vm.$emit('work-item-emoji-updated', workItem);
await waitForPromises();
expect(findIssuableList().props('issuables')[0].upvotes).toBe(1);
});
it('updates the milestone field of active issuable', async () => {
const {
data: { workItem },
} = workItemResponseFactory({ iid: '789' });
findDrawerWorkItem().vm.$emit('work-item-updated', workItem);
await waitForPromises();
expect(findIssuableList().props('issuables')[0].milestone).toEqual({
...mockMilestone,
__persist: true,
expired: undefined,
state: undefined,
});
});
it('updates the title and confidential state of active issuable', async () => {
const {
data: { workItem },
} = workItemResponseFactory({ iid: '789', confidential: true });
findDrawerWorkItem().vm.$emit('work-item-updated', workItem);
await waitForPromises();
expect(findIssuableList().props('issuables')[0].title).toBe('Updated title');
expect(findIssuableList().props('issuables')[0].confidential).toBe(true);
});
it('refetches the list if new child was added to active issuable', async () => {
findDrawerWorkItem().vm.$emit('addChild');
await waitForPromises();
expect(mockIssuesQueryResponse).toHaveBeenCalledTimes(2);
expect(mockIssuesCountsQueryResponse).toHaveBeenCalledTimes(2);
});
it('updates issuable type to objective if promoted to objective', async () => {
findDrawerWorkItem().vm.$emit('promotedToObjective', '789');
await waitForPromises();
// required for cache updates
jest.runOnlyPendingTimers();
await nextTick();
expect(findIssuableList().props('issuables')[0].type).toBe('OBJECTIVE');
});
});
describe('when deleting an issuable from the drawer', () => {
beforeEach(async () => {
const {
data: { workItem },
} = workItemResponseFactory({ iid: '789' });
findDrawerWorkItem().vm.$emit('deleteWorkItem', workItem);
await waitForPromises();
});
it('should refetch issues and issues count', () => {
expect(mockIssuesQueryResponse).toHaveBeenCalledTimes(2);
expect(mockIssuesCountsQueryResponse).toHaveBeenCalledTimes(2);
});
it('should close the issue drawer', () => {
expect(findIssuableDrawer().props('open')).toBe(false);
});
});
});
});
it('shows an error when deleting from the drawer fails', async () => {
const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
const {
data: { workItem },
} = workItemResponseFactory({ iid: '789' });
wrapper = mountComponent({
provide: {
glFeatures: {
issuesListDrawer: true,
},
},
stubs: {
GlDrawer,
},
deleteMutationHandler: errorHandler,
});
findIssuableList().vm.$emit(
'select-issuable',
getIssuesQueryResponse.data.project.issues.nodes[0],
);
await nextTick();
findDrawerWorkItem().vm.$emit('deleteWorkItem', workItem);
await waitForPromises();
expect(Sentry.captureException).toHaveBeenCalled();
expect(findIssuableList().props('error')).toBe('An error occurred while deleting an issuable.');
});
});