summaryrefslogtreecommitdiff
path: root/spec/frontend/issues
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/issues')
-rw-r--r--spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js332
-rw-r--r--spec/frontend/issues/dashboard/mock_data.js88
-rw-r--r--spec/frontend/issues/list/components/empty_state_with_any_issues_spec.js68
-rw-r--r--spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js211
-rw-r--r--spec/frontend/issues/list/components/issue_card_statistics_spec.js64
-rw-r--r--spec/frontend/issues/list/components/issues_list_app_spec.js192
-rw-r--r--spec/frontend/issues/list/mock_data.js40
-rw-r--r--spec/frontend/issues/list/utils_spec.js28
-rw-r--r--spec/frontend/issues/related_merge_requests/store/actions_spec.js6
-rw-r--r--spec/frontend/issues/show/components/app_spec.js30
-rw-r--r--spec/frontend/issues/show/components/description_spec.js10
-rw-r--r--spec/frontend/issues/show/components/header_actions_spec.js34
-rw-r--r--spec/frontend/issues/show/components/incidents/edit_timeline_event_spec.js8
-rw-r--r--spec/frontend/issues/show/components/incidents/incident_tabs_spec.js83
-rw-r--r--spec/frontend/issues/show/components/incidents/mock_data.js21
-rw-r--r--spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js41
-rw-r--r--spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js27
-rw-r--r--spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js17
-rw-r--r--spec/frontend/issues/show/components/locked_warning_spec.js55
19 files changed, 1104 insertions, 251 deletions
diff --git a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js
index 3f72396cce6..3f40772f7fc 100644
--- a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js
+++ b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js
@@ -1,58 +1,380 @@
import { 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 IssuesDashboardApp from '~/issues/dashboard/components/issues_dashboard_app.vue';
+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,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { IssuableStates } from '~/vue_shared/issuable/list/constants';
+import { emptyIssuesQueryResponse, 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 = {
calendarPath: 'calendar/path',
emptyStateSvgPath: 'empty-state.svg',
+ hasBlockedIssuesFeature: 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: IssuesDashboardApp.i18n.calendarButtonText });
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: IssuesDashboardApp.i18n.rssButtonText });
- const mountComponent = () => {
- wrapper = mountExtended(IssuesDashboardApp, { provide: defaultProvide });
+ const mountComponent = ({
+ provide = {},
+ issuesQueryHandler = jest.fn().mockResolvedValue(defaultQueryResponse),
+ sortPreferenceMutationResponse = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse),
+ } = {}) => {
+ wrapper = mountExtended(IssuesDashboardApp, {
+ apolloProvider: createMockApollo([
+ [getIssuesQuery, issuesQueryHandler],
+ [setSortPreferenceMutation, sortPreferenceMutationResponse],
+ ]),
+ provide: {
+ ...defaultProvide,
+ ...provide,
+ },
+ });
};
beforeEach(() => {
- mountComponent();
+ setWindowLocation(TEST_HOST);
+ axiosMock = new AxiosMockAdapter(axios);
});
- it('renders IssuableList component', () => {
+ afterEach(() => {
+ axiosMock.reset();
+ });
+
+ it('renders IssuableList component', async () => {
+ mountComponent();
+ jest.runOnlyPendingTimers();
+ await waitForPromises();
+
expect(findIssuableList().props()).toMatchObject({
currentTab: IssuableStates.Opened,
+ hasNextPage: true,
+ hasPreviousPage: false,
+ hasScopedLabelsFeature: defaultProvide.hasScopedLabelsFeature,
+ initialSortBy: CREATED_DESC,
+ issuables: issuesQueryResponse.data.issues.nodes,
+ issuablesLoading: false,
namespace: 'dashboard',
recentSearchesStorageKey: 'issues',
searchInputPlaceholder: IssuesDashboardApp.i18n.searchInputPlaceholder,
+ showPaginationControls: true,
+ sortOptions: getSortOptions({
+ hasBlockedIssuesFeature: defaultProvide.hasBlockedIssuesFeature,
+ hasIssuableHealthStatusFeature: defaultProvide.hasIssuableHealthStatusFeature,
+ hasIssueWeightsFeature: defaultProvide.hasIssueWeightsFeature,
+ }),
tabs: IssuesDashboardApp.IssuableListTabs,
+ urlParams: {
+ sort: urlSortParams[CREATED_DESC],
+ state: IssuableStates.Opened,
+ },
+ useKeysetPagination: true,
});
});
it('renders RSS button link', () => {
+ mountComponent();
+
expect(findRssButton().attributes('href')).toBe(defaultProvide.rssPath);
expect(findRssButton().props('icon')).toBe('rss');
});
it('renders calendar button link', () => {
+ mountComponent();
+
expect(findCalendarButton().attributes('href')).toBe(defaultProvide.calendarPath);
expect(findCalendarButton().props('icon')).toBe('calendar');
});
- it('renders empty state', () => {
+ it('renders issue time information', async () => {
+ mountComponent();
+ jest.runOnlyPendingTimers();
+ await waitForPromises();
+
+ expect(findIssueCardTimeInfo().exists()).toBe(true);
+ });
+
+ it('renders issue statistics', async () => {
+ mountComponent();
+ jest.runOnlyPendingTimers();
+ await waitForPromises();
+
+ expect(findIssueCardStatistics().exists()).toBe(true);
+ });
+
+ it('renders empty state', async () => {
+ mountComponent({ issuesQueryHandler: jest.fn().mockResolvedValue(emptyIssuesQueryResponse) });
+ await waitForPromises();
+
expect(findEmptyState().props()).toMatchObject({
svgPath: defaultProvide.emptyStateSvgPath,
title: IssuesDashboardApp.i18n.emptyStateTitle,
});
});
+
+ 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 = IssuableStates.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('when there is an error fetching issues', () => {
+ beforeEach(() => {
+ mountComponent({ issuesQueryHandler: jest.fn().mockRejectedValue(new Error('ERROR')) });
+ jest.runOnlyPendingTimers();
+ return waitForPromises();
+ });
+
+ it('shows an error message', () => {
+ expect(findIssuableList().props('error')).toBe(i18n.errorFetchingIssues);
+ expect(Sentry.captureException).toHaveBeenCalledWith(new Error('ERROR'));
+ });
+
+ it('clears error message when "dismiss-alert" event is emitted from IssuableList', async () => {
+ 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',
+ };
+ const originalGon = window.gon;
+
+ beforeEach(() => {
+ window.gon = {
+ ...originalGon,
+ current_user_id: mockCurrentUser.id,
+ current_user_fullname: mockCurrentUser.name,
+ current_username: mockCurrentUser.username,
+ current_user_avatar_url: mockCurrentUser.avatar_url,
+ };
+ mountComponent();
+ });
+
+ afterEach(() => {
+ window.gon = originalGon;
+ });
+
+ 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 },
+ ]);
+ });
+ });
+
+ describe('events', () => {
+ describe('when "click-tab" event is emitted by IssuableList', () => {
+ beforeEach(() => {
+ mountComponent();
+
+ findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
+ });
+
+ it('updates ui to the new tab', () => {
+ expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed);
+ });
+
+ it('updates url to the new tab', () => {
+ expect(findIssuableList().props('urlParams')).toMatchObject({
+ state: IssuableStates.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({ 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);
+ 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);
+ mountComponent({
+ provide: { isSignedIn: false },
+ sortPreferenceMutationResponse: mutationMock,
+ });
+
+ findIssuableList().vm.$emit('sort', CREATED_DESC);
+
+ expect(mutationMock).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/issues/dashboard/mock_data.js b/spec/frontend/issues/dashboard/mock_data.js
new file mode 100644
index 00000000000..feb4cb80bd8
--- /dev/null
+++ b/spec/frontend/issues/dashboard/mock_data.js
@@ -0,0 +1,88 @@
+export const issuesQueryResponse = {
+ data: {
+ issues: {
+ nodes: [
+ {
+ __typename: 'Issue',
+ id: 'gid://gitlab/Issue/123456',
+ iid: '789',
+ closedAt: null,
+ confidential: false,
+ createdAt: '2021-05-22T04:08:01Z',
+ downvotes: 2,
+ dueDate: '2021-05-29',
+ hidden: false,
+ humanTimeEstimate: null,
+ mergeRequestsCount: false,
+ moved: false,
+ reference: 'group/project#123456',
+ state: 'opened',
+ title: 'Issue title',
+ type: 'issue',
+ updatedAt: '2021-05-22T04:08:01Z',
+ upvotes: 3,
+ userDiscussionsCount: 4,
+ webPath: 'project/-/issues/789',
+ webUrl: 'project/-/issues/789',
+ assignees: {
+ nodes: [
+ {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/234',
+ avatarUrl: 'avatar/url',
+ name: 'Marge Simpson',
+ username: 'msimpson',
+ webUrl: 'url/msimpson',
+ },
+ ],
+ },
+ author: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/456',
+ avatarUrl: 'avatar/url',
+ name: 'Homer Simpson',
+ username: 'hsimpson',
+ webUrl: 'url/hsimpson',
+ },
+ labels: {
+ nodes: [
+ {
+ id: 'gid://gitlab/ProjectLabel/456',
+ color: '#333',
+ title: 'Label title',
+ description: 'Label description',
+ },
+ ],
+ },
+ milestone: null,
+ taskCompletionStatus: {
+ completedCount: 1,
+ count: 2,
+ },
+ },
+ ],
+ pageInfo: {
+ __typename: 'PageInfo',
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: 'startcursor',
+ endCursor: 'endcursor',
+ },
+ },
+ },
+};
+
+export const emptyIssuesQueryResponse = {
+ data: {
+ issues: {
+ nodes: [],
+ pageInfo: {
+ __typename: 'PageInfo',
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: '',
+ endCursor: '',
+ },
+ },
+ },
+};
diff --git a/spec/frontend/issues/list/components/empty_state_with_any_issues_spec.js b/spec/frontend/issues/list/components/empty_state_with_any_issues_spec.js
new file mode 100644
index 00000000000..d0d20ef03e1
--- /dev/null
+++ b/spec/frontend/issues/list/components/empty_state_with_any_issues_spec.js
@@ -0,0 +1,68 @@
+import { GlEmptyState } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import EmptyStateWithAnyIssues from '~/issues/list/components/empty_state_with_any_issues.vue';
+import IssuesListApp from '~/issues/list/components/issues_list_app.vue';
+
+describe('EmptyStateWithAnyIssues component', () => {
+ let wrapper;
+
+ const defaultProvide = {
+ emptyStateSvgPath: 'empty/state/svg/path',
+ newIssuePath: 'new/issue/path',
+ showNewIssueLink: false,
+ };
+
+ const findGlEmptyState = () => wrapper.findComponent(GlEmptyState);
+
+ const mountComponent = (props = {}) => {
+ wrapper = shallowMount(EmptyStateWithAnyIssues, {
+ propsData: {
+ hasSearch: true,
+ isOpenTab: true,
+ ...props,
+ },
+ provide: defaultProvide,
+ });
+ };
+
+ describe('when there is a search (with no results)', () => {
+ beforeEach(() => {
+ mountComponent({ hasSearch: true });
+ });
+
+ it('shows empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ description: IssuesListApp.i18n.noSearchResultsDescription,
+ title: IssuesListApp.i18n.noSearchResultsTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ });
+ });
+ });
+
+ describe('when "Open" tab is active', () => {
+ beforeEach(() => {
+ mountComponent({ hasSearch: false, isOpenTab: true });
+ });
+
+ it('shows empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ description: IssuesListApp.i18n.noOpenIssuesDescription,
+ title: IssuesListApp.i18n.noOpenIssuesTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ });
+ });
+ });
+
+ describe('when "Closed" tab is active', () => {
+ beforeEach(() => {
+ mountComponent({ hasSearch: false, isOpenTab: false });
+ });
+
+ it('shows empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ title: IssuesListApp.i18n.noClosedIssuesTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js
new file mode 100644
index 00000000000..065139f10f4
--- /dev/null
+++ b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js
@@ -0,0 +1,211 @@
+import { GlEmptyState, GlLink } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
+import EmptyStateWithoutAnyIssues from '~/issues/list/components/empty_state_without_any_issues.vue';
+import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue';
+import { i18n } from '~/issues/list/constants';
+
+describe('EmptyStateWithoutAnyIssues component', () => {
+ let wrapper;
+
+ const defaultProps = {
+ currentTabCount: 0,
+ exportCsvPathWithQuery: 'export/csv/path',
+ };
+
+ const defaultProvide = {
+ canCreateProjects: false,
+ emptyStateSvgPath: 'empty/state/svg/path',
+ fullPath: 'full/path',
+ isSignedIn: true,
+ jiraIntegrationPath: 'jira/integration/path',
+ newIssuePath: 'new/issue/path',
+ newProjectPath: 'new/project/path',
+ showNewIssueLink: false,
+ signInPath: 'sign/in/path',
+ };
+
+ const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
+ const findGlEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findGlLink = () => wrapper.findComponent(GlLink);
+ const findIssuesHelpPageLink = () =>
+ wrapper.findByRole('link', { name: i18n.noIssuesDescription });
+ const findJiraDocsLink = () =>
+ wrapper.findByRole('link', { name: 'Enable the Jira integration' });
+ const findNewIssueDropdown = () => wrapper.findComponent(NewIssueDropdown);
+ const findNewIssueLink = () => wrapper.findByRole('link', { name: i18n.newIssueLabel });
+ const findNewProjectLink = () => wrapper.findByRole('link', { name: i18n.newProjectLabel });
+
+ const mountComponent = ({ props = {}, provide = {} } = {}) => {
+ wrapper = mountExtended(EmptyStateWithoutAnyIssues, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ provide: {
+ ...defaultProvide,
+ ...provide,
+ },
+ stubs: {
+ NewIssueDropdown: true,
+ },
+ });
+ };
+
+ describe('when signed in', () => {
+ describe('empty state', () => {
+ it('renders empty state', () => {
+ mountComponent();
+
+ expect(findGlEmptyState().props()).toMatchObject({
+ title: i18n.noIssuesTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ });
+ });
+
+ describe('description', () => {
+ it('renders issues docs link', () => {
+ mountComponent();
+
+ expect(findIssuesHelpPageLink().attributes('href')).toBe(
+ EmptyStateWithoutAnyIssues.issuesHelpPagePath,
+ );
+ });
+
+ describe('"create a project first" description', () => {
+ describe('when can create projects', () => {
+ it('renders', () => {
+ mountComponent({ provide: { canCreateProjects: true } });
+
+ expect(findGlEmptyState().text()).toContain(i18n.noGroupIssuesSignedInDescription);
+ });
+ });
+
+ describe('when cannot create projects', () => {
+ it('does not render', () => {
+ mountComponent({ provide: { canCreateProjects: false } });
+
+ expect(findGlEmptyState().text()).not.toContain(
+ i18n.noGroupIssuesSignedInDescription,
+ );
+ });
+ });
+ });
+ });
+
+ describe('actions', () => {
+ describe('"New project" link', () => {
+ describe('when can create projects', () => {
+ it('renders', () => {
+ mountComponent({ provide: { canCreateProjects: true } });
+
+ expect(findNewProjectLink().attributes('href')).toBe(defaultProvide.newProjectPath);
+ });
+ });
+
+ describe('when cannot create projects', () => {
+ it('does not render', () => {
+ mountComponent({ provide: { canCreateProjects: false } });
+
+ expect(findNewProjectLink().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('"New issue" link', () => {
+ describe('when can show new issue link', () => {
+ it('renders', () => {
+ mountComponent({ provide: { showNewIssueLink: true } });
+
+ expect(findNewIssueLink().attributes('href')).toBe(defaultProvide.newIssuePath);
+ });
+ });
+
+ describe('when cannot show new issue link', () => {
+ it('does not render', () => {
+ mountComponent({ provide: { showNewIssueLink: false } });
+
+ expect(findNewIssueLink().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('CSV import/export buttons', () => {
+ describe('when can show csv buttons', () => {
+ it('renders', () => {
+ mountComponent({ props: { showCsvButtons: true } });
+
+ expect(findCsvImportExportButtons().props()).toMatchObject({
+ exportCsvPath: defaultProps.exportCsvPathWithQuery,
+ issuableCount: 0,
+ });
+ });
+ });
+
+ describe('when cannot show csv buttons', () => {
+ it('does not render', () => {
+ mountComponent({ props: { showCsvButtons: false } });
+
+ expect(findCsvImportExportButtons().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('new issue dropdown', () => {
+ describe('when can show new issue dropdown', () => {
+ it('renders', () => {
+ mountComponent({ props: { showNewIssueDropdown: true } });
+
+ expect(findNewIssueDropdown().exists()).toBe(true);
+ });
+ });
+
+ describe('when cannot show new issue dropdown', () => {
+ it('does not render', () => {
+ mountComponent({ props: { showNewIssueDropdown: false } });
+
+ expect(findNewIssueDropdown().exists()).toBe(false);
+ });
+ });
+ });
+ });
+ });
+
+ describe('Jira section', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('shows Jira integration information', () => {
+ const paragraphs = wrapper.findAll('p');
+ expect(paragraphs.at(1).text()).toContain(i18n.jiraIntegrationTitle);
+ expect(paragraphs.at(2).text()).toMatchInterpolatedText(i18n.jiraIntegrationMessage);
+ expect(paragraphs.at(3).text()).toContain(i18n.jiraIntegrationSecondaryMessage);
+ });
+
+ it('renders Jira integration docs link', () => {
+ expect(findJiraDocsLink().attributes('href')).toBe(defaultProvide.jiraIntegrationPath);
+ });
+ });
+ });
+
+ describe('when signed out', () => {
+ beforeEach(() => {
+ mountComponent({ provide: { isSignedIn: false } });
+ });
+
+ it('renders empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ title: i18n.noIssuesTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ primaryButtonText: i18n.noIssuesSignedOutButtonText,
+ primaryButtonLink: defaultProvide.signInPath,
+ });
+ });
+
+ it('renders issues docs link', () => {
+ expect(findGlLink().attributes('href')).toBe(EmptyStateWithoutAnyIssues.issuesHelpPagePath);
+ expect(findGlLink().text()).toBe(i18n.noIssuesDescription);
+ });
+ });
+});
diff --git a/spec/frontend/issues/list/components/issue_card_statistics_spec.js b/spec/frontend/issues/list/components/issue_card_statistics_spec.js
new file mode 100644
index 00000000000..180d4ab7eb6
--- /dev/null
+++ b/spec/frontend/issues/list/components/issue_card_statistics_spec.js
@@ -0,0 +1,64 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import IssueCardStatistics from '~/issues/list/components/issue_card_statistics.vue';
+import { i18n } from '~/issues/list/constants';
+
+describe('IssueCardStatistics CE component', () => {
+ let wrapper;
+
+ const findMergeRequests = () => wrapper.findByTestId('merge-requests');
+ const findUpvotes = () => wrapper.findByTestId('issuable-upvotes');
+ const findDownvotes = () => wrapper.findByTestId('issuable-downvotes');
+
+ const mountComponent = ({ mergeRequestsCount, upvotes, downvotes } = {}) => {
+ wrapper = shallowMountExtended(IssueCardStatistics, {
+ propsData: {
+ issue: {
+ mergeRequestsCount,
+ upvotes,
+ downvotes,
+ },
+ },
+ });
+ };
+
+ describe('when issue attributes are undefined', () => {
+ it('does not render the attributes', () => {
+ mountComponent();
+
+ expect(findMergeRequests().exists()).toBe(false);
+ expect(findUpvotes().exists()).toBe(false);
+ expect(findDownvotes().exists()).toBe(false);
+ });
+ });
+
+ describe('when issue attributes are defined', () => {
+ beforeEach(() => {
+ mountComponent({ mergeRequestsCount: 1, upvotes: 5, downvotes: 9 });
+ });
+
+ it('renders merge requests', () => {
+ const mergeRequests = findMergeRequests();
+
+ expect(mergeRequests.text()).toBe('1');
+ expect(mergeRequests.attributes('title')).toBe(i18n.relatedMergeRequests);
+ expect(mergeRequests.findComponent(GlIcon).props('name')).toBe('merge-request');
+ });
+
+ it('renders upvotes', () => {
+ const upvotes = findUpvotes();
+
+ expect(upvotes.text()).toBe('5');
+ expect(upvotes.attributes('title')).toBe(i18n.upvotes);
+ expect(upvotes.findComponent(GlIcon).props('name')).toBe('thumb-up');
+ });
+
+ it('renders downvotes', () => {
+ const downvotes = findDownvotes();
+
+ expect(downvotes.text()).toBe('9');
+ expect(downvotes.attributes('title')).toBe(i18n.downvotes);
+ expect(downvotes.findComponent(GlIcon).props('name')).toBe('thumb-down');
+ });
+ });
+});
diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js
index d0c93c896b3..4c5d8ce3cd1 100644
--- a/spec/frontend/issues/list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues/list/components/issues_list_app_spec.js
@@ -1,4 +1,4 @@
-import { GlButton, GlEmptyState } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
@@ -21,18 +21,21 @@ import {
setSortPreferenceMutationResponseWithErrors,
urlParams,
} from 'jest/issues/list/mock_data';
-import createFlash, { FLASH_TYPES } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/flash';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
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, IssuableStates } 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 NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue';
import {
CREATED_DESC,
RELATIVE_POSITION,
RELATIVE_POSITION_ASC,
+ UPDATED_DESC,
urlSortParams,
} from '~/issues/list/constants';
import eventHub from '~/issues/list/eventhub';
@@ -58,10 +61,11 @@ import {
TOKEN_TYPE_MY_REACTION,
TOKEN_TYPE_ORGANIZATION,
TOKEN_TYPE_RELEASE,
+ TOKEN_TYPE_SEARCH_WITHIN,
TOKEN_TYPE_TYPE,
} from '~/vue_shared/components/filtered_search_bar/constants';
-import('~/issuable/bulk_update_sidebar');
+import('~/issuable');
import('~/users_select');
jest.mock('@sentry/browser');
@@ -122,10 +126,8 @@ describe('CE IssuesListApp component', () => {
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail);
- const findGlButton = () => wrapper.findComponent(GlButton);
const findGlButtons = () => wrapper.findAllComponents(GlButton);
const findGlButtonAt = (index) => findGlButtons().at(index);
- const findGlEmptyState = () => wrapper.findComponent(GlEmptyState);
const findIssuableList = () => wrapper.findComponent(IssuableList);
const findNewIssueDropdown = () => wrapper.findComponent(NewIssueDropdown);
@@ -182,7 +184,11 @@ describe('CE IssuesListApp component', () => {
namespace: defaultProvide.fullPath,
recentSearchesStorageKey: 'issues',
searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder,
- sortOptions: getSortOptions(true, true),
+ sortOptions: getSortOptions({
+ hasBlockedIssuesFeature: defaultProvide.hasBlockedIssuesFeature,
+ hasIssuableHealthStatusFeature: defaultProvide.hasIssuableHealthStatusFeature,
+ hasIssueWeightsFeature: defaultProvide.hasIssueWeightsFeature,
+ }),
initialSortBy: CREATED_DESC,
issuables: getIssuesQueryResponse.data.project.issues.nodes,
tabs: IssuableListTabs,
@@ -395,9 +401,9 @@ describe('CE IssuesListApp component', () => {
});
it('shows an alert to tell the user that manual reordering is disabled', () => {
- expect(createFlash).toHaveBeenCalledWith({
+ expect(createAlert).toHaveBeenCalledWith({
message: IssuesListApp.i18n.issueRepositioningMessage,
- type: FLASH_TYPES.NOTICE,
+ variant: VARIANT_INFO,
});
});
});
@@ -435,9 +441,9 @@ describe('CE IssuesListApp component', () => {
});
it('shows an alert to tell the user they must be signed in to search', () => {
- expect(createFlash).toHaveBeenCalledWith({
+ expect(createAlert).toHaveBeenCalledWith({
message: IssuesListApp.i18n.anonymousSearchingMessage,
- type: FLASH_TYPES.NOTICE,
+ variant: VARIANT_INFO,
});
});
});
@@ -486,136 +492,29 @@ describe('CE IssuesListApp component', () => {
describe('empty states', () => {
describe('when there are issues', () => {
- describe('when search returns no results', () => {
- beforeEach(() => {
- setWindowLocation(`?search=no+results`);
-
- wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount });
- });
-
- it('shows empty state', () => {
- expect(findGlEmptyState().props()).toMatchObject({
- description: IssuesListApp.i18n.noSearchResultsDescription,
- title: IssuesListApp.i18n.noSearchResultsTitle,
- svgPath: defaultProvide.emptyStateSvgPath,
- });
- });
- });
-
- describe('when "Open" tab has no issues', () => {
- beforeEach(() => {
- wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount });
- });
-
- it('shows empty state', () => {
- expect(findGlEmptyState().props()).toMatchObject({
- description: IssuesListApp.i18n.noOpenIssuesDescription,
- title: IssuesListApp.i18n.noOpenIssuesTitle,
- svgPath: defaultProvide.emptyStateSvgPath,
- });
- });
+ beforeEach(() => {
+ wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount });
});
- describe('when "Closed" tab has no issues', () => {
- beforeEach(() => {
- setWindowLocation(`?state=${IssuableStates.Closed}`);
-
- wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount });
- });
-
- it('shows empty state', () => {
- expect(findGlEmptyState().props()).toMatchObject({
- title: IssuesListApp.i18n.noClosedIssuesTitle,
- svgPath: defaultProvide.emptyStateSvgPath,
- });
+ it('shows EmptyStateWithAnyIssues empty state', () => {
+ expect(wrapper.findComponent(EmptyStateWithAnyIssues).props()).toEqual({
+ hasSearch: false,
+ isOpenTab: true,
});
});
});
describe('when there are no issues', () => {
- describe('when user is logged in', () => {
- beforeEach(() => {
- wrapper = mountComponent({
- provide: { hasAnyIssues: false, isSignedIn: true },
- mountFn: mount,
- });
- });
-
- it('shows empty state', () => {
- expect(findGlEmptyState().props()).toMatchObject({
- title: IssuesListApp.i18n.noIssuesSignedInTitle,
- svgPath: defaultProvide.emptyStateSvgPath,
- });
- expect(findGlEmptyState().text()).toContain(
- IssuesListApp.i18n.noIssuesSignedInDescription,
- );
- });
-
- it('shows "New issue" and import/export buttons', () => {
- expect(findGlButton().text()).toBe(IssuesListApp.i18n.newIssueLabel);
- expect(findGlButton().attributes('href')).toBe(defaultProvide.newIssuePath);
- expect(findCsvImportExportButtons().props()).toMatchObject({
- exportCsvPath: defaultProvide.exportCsvPath,
- issuableCount: 0,
- });
- });
-
- it('shows Jira integration information', () => {
- const paragraphs = wrapper.findAll('p');
- const links = wrapper.findAll('.gl-link');
- expect(paragraphs.at(1).text()).toContain(IssuesListApp.i18n.jiraIntegrationTitle);
- expect(paragraphs.at(2).text()).toContain(
- 'Enable the Jira integration to view your Jira issues in GitLab.',
- );
- expect(paragraphs.at(3).text()).toContain(
- IssuesListApp.i18n.jiraIntegrationSecondaryMessage,
- );
- expect(links.at(1).text()).toBe('Enable the Jira integration');
- expect(links.at(1).attributes('href')).toBe(defaultProvide.jiraIntegrationPath);
- });
- });
-
- describe('when user is logged in and can create projects', () => {
- beforeEach(() => {
- wrapper = mountComponent({
- provide: { canCreateProjects: true, hasAnyIssues: false, isSignedIn: true },
- stubs: { GlEmptyState },
- });
- });
-
- it('shows empty state with additional description about creating projects', () => {
- expect(findGlEmptyState().text()).toContain(
- IssuesListApp.i18n.noIssuesSignedInDescription,
- );
- expect(findGlEmptyState().text()).toContain(
- IssuesListApp.i18n.noGroupIssuesSignedInDescription,
- );
- });
-
- it('shows "New project" button', () => {
- expect(findGlButton().text()).toBe(IssuesListApp.i18n.newProjectLabel);
- expect(findGlButton().attributes('href')).toBe(defaultProvide.newProjectPath);
- });
+ beforeEach(() => {
+ wrapper = mountComponent({ provide: { hasAnyIssues: false } });
});
- describe('when user is logged out', () => {
- beforeEach(() => {
- wrapper = mountComponent({
- provide: { hasAnyIssues: false, isSignedIn: false },
- mountFn: mount,
- });
- });
-
- it('shows empty state', () => {
- expect(findGlEmptyState().props()).toMatchObject({
- title: IssuesListApp.i18n.noIssuesSignedOutTitle,
- svgPath: defaultProvide.emptyStateSvgPath,
- primaryButtonText: IssuesListApp.i18n.noIssuesSignedOutButtonText,
- primaryButtonLink: defaultProvide.signInPath,
- });
- expect(findGlEmptyState().text()).toContain(
- IssuesListApp.i18n.noIssuesSignedOutDescription,
- );
+ it('shows EmptyStateWithoutAnyIssues empty state', () => {
+ expect(wrapper.findComponent(EmptyStateWithoutAnyIssues).props()).toEqual({
+ currentTabCount: 0,
+ exportCsvPathWithQuery: defaultProvide.exportCsvPath,
+ showCsvButtons: true,
+ showNewIssueDropdown: false,
});
});
});
@@ -636,8 +535,8 @@ describe('CE IssuesListApp component', () => {
it('does not render My-Reaction or Confidential tokens', () => {
expect(findIssuableList().props('searchTokens')).not.toMatchObject([
- { type: TOKEN_TYPE_AUTHOR, preloadedAuthors: [mockCurrentUser] },
- { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors: [mockCurrentUser] },
+ { type: TOKEN_TYPE_AUTHOR, preloadedUsers: [mockCurrentUser] },
+ { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers: [mockCurrentUser] },
{ type: TOKEN_TYPE_MY_REACTION },
{ type: TOKEN_TYPE_CONFIDENTIAL },
]);
@@ -685,13 +584,13 @@ describe('CE IssuesListApp component', () => {
});
it('renders all tokens alphabetically', () => {
- const preloadedAuthors = [
+ const preloadedUsers = [
{ ...mockCurrentUser, id: convertToGraphQLId('User', mockCurrentUser.id) },
];
expect(findIssuableList().props('searchTokens')).toMatchObject([
- { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors },
- { type: TOKEN_TYPE_AUTHOR, preloadedAuthors },
+ { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers },
+ { type: TOKEN_TYPE_AUTHOR, preloadedUsers },
{ type: TOKEN_TYPE_CONFIDENTIAL },
{ type: TOKEN_TYPE_CONTACT },
{ type: TOKEN_TYPE_LABEL },
@@ -699,6 +598,7 @@ describe('CE IssuesListApp component', () => {
{ type: TOKEN_TYPE_MY_REACTION },
{ type: TOKEN_TYPE_ORGANIZATION },
{ type: TOKEN_TYPE_RELEASE },
+ { type: TOKEN_TYPE_SEARCH_WITHIN },
{ type: TOKEN_TYPE_TYPE },
]);
});
@@ -899,7 +799,11 @@ describe('CE IssuesListApp component', () => {
it.each(Object.keys(urlSortParams))(
'updates to the new sort when payload is `%s`',
async (sortKey) => {
- wrapper = mountComponent();
+ // 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);
@@ -929,9 +833,9 @@ describe('CE IssuesListApp component', () => {
});
it('shows an alert to tell the user that manual reordering is disabled', () => {
- expect(createFlash).toHaveBeenCalledWith({
+ expect(createAlert).toHaveBeenCalledWith({
message: IssuesListApp.i18n.issueRepositioningMessage,
- type: FLASH_TYPES.NOTICE,
+ variant: VARIANT_INFO,
});
});
});
@@ -941,9 +845,9 @@ describe('CE IssuesListApp component', () => {
const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse);
wrapper = mountComponent({ sortPreferenceMutationResponse: mutationMock });
- findIssuableList().vm.$emit('sort', CREATED_DESC);
+ findIssuableList().vm.$emit('sort', UPDATED_DESC);
- expect(mutationMock).toHaveBeenCalledWith({ input: { issuesSort: CREATED_DESC } });
+ expect(mutationMock).toHaveBeenCalledWith({ input: { issuesSort: UPDATED_DESC } });
});
it('captures error when mutation response has errors', async () => {
@@ -952,7 +856,7 @@ describe('CE IssuesListApp component', () => {
.mockResolvedValue(setSortPreferenceMutationResponseWithErrors);
wrapper = mountComponent({ sortPreferenceMutationResponse: mutationMock });
- findIssuableList().vm.$emit('sort', CREATED_DESC);
+ findIssuableList().vm.$emit('sort', UPDATED_DESC);
await waitForPromises();
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!'));
@@ -1016,9 +920,9 @@ describe('CE IssuesListApp component', () => {
});
it('shows an alert to tell the user they must be signed in to search', () => {
- expect(createFlash).toHaveBeenCalledWith({
+ expect(createAlert).toHaveBeenCalledWith({
message: IssuesListApp.i18n.anonymousSearchingMessage,
- type: FLASH_TYPES.NOTICE,
+ variant: VARIANT_INFO,
});
});
});
diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js
index 62fcbf7aad0..0690501dee9 100644
--- a/spec/frontend/issues/list/mock_data.js
+++ b/spec/frontend/issues/list/mock_data.js
@@ -1,7 +1,7 @@
import {
FILTERED_SEARCH_TERM,
OPERATOR_IS,
- OPERATOR_IS_NOT,
+ OPERATOR_NOT,
OPERATOR_OR,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
@@ -132,6 +132,8 @@ export const locationSearch = [
'?search=find+issues',
'author_username=homer',
'not[author_username]=marge',
+ 'or[author_username]=burns',
+ 'or[author_username]=smithers',
'assignee_username[]=bart',
'assignee_username[]=lisa',
'assignee_username[]=5',
@@ -184,41 +186,43 @@ export const locationSearchWithSpecialValues = [
export const filteredTokens = [
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'homer', operator: OPERATOR_IS } },
- { type: TOKEN_TYPE_AUTHOR, value: { data: 'marge', operator: OPERATOR_IS_NOT } },
+ { type: TOKEN_TYPE_AUTHOR, value: { data: 'marge', operator: OPERATOR_NOT } },
+ { type: TOKEN_TYPE_AUTHOR, value: { data: 'burns', operator: OPERATOR_OR } },
+ { type: TOKEN_TYPE_AUTHOR, value: { data: 'smithers', operator: OPERATOR_OR } },
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'bart', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lisa', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: '5', operator: OPERATOR_IS } },
- { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'patty', operator: OPERATOR_IS_NOT } },
- { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'selma', operator: OPERATOR_IS_NOT } },
+ { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'patty', operator: OPERATOR_NOT } },
+ { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'selma', operator: OPERATOR_NOT } },
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'carl', operator: OPERATOR_OR } },
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lenny', operator: OPERATOR_OR } },
{ type: TOKEN_TYPE_MILESTONE, value: { data: 'season 3', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_MILESTONE, value: { data: 'season 4', operator: OPERATOR_IS } },
- { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 20', operator: OPERATOR_IS_NOT } },
- { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 30', operator: OPERATOR_IS_NOT } },
+ { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 20', operator: OPERATOR_NOT } },
+ { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 30', operator: OPERATOR_NOT } },
{ type: TOKEN_TYPE_LABEL, value: { data: 'cartoon', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_LABEL, value: { data: 'tv', operator: OPERATOR_IS } },
- { type: TOKEN_TYPE_LABEL, value: { data: 'live action', operator: OPERATOR_IS_NOT } },
- { type: TOKEN_TYPE_LABEL, value: { data: 'drama', operator: OPERATOR_IS_NOT } },
+ { type: TOKEN_TYPE_LABEL, value: { data: 'live action', operator: OPERATOR_NOT } },
+ { type: TOKEN_TYPE_LABEL, value: { data: 'drama', operator: OPERATOR_NOT } },
{ type: TOKEN_TYPE_RELEASE, value: { data: 'v3', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_RELEASE, value: { data: 'v4', operator: OPERATOR_IS } },
- { type: TOKEN_TYPE_RELEASE, value: { data: 'v20', operator: OPERATOR_IS_NOT } },
- { type: TOKEN_TYPE_RELEASE, value: { data: 'v30', operator: OPERATOR_IS_NOT } },
+ { type: TOKEN_TYPE_RELEASE, value: { data: 'v20', operator: OPERATOR_NOT } },
+ { type: TOKEN_TYPE_RELEASE, value: { data: 'v30', operator: OPERATOR_NOT } },
{ type: TOKEN_TYPE_TYPE, value: { data: 'issue', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_TYPE, value: { data: 'feature', operator: OPERATOR_IS } },
- { type: TOKEN_TYPE_TYPE, value: { data: 'bug', operator: OPERATOR_IS_NOT } },
- { type: TOKEN_TYPE_TYPE, value: { data: 'incident', operator: OPERATOR_IS_NOT } },
+ { type: TOKEN_TYPE_TYPE, value: { data: 'bug', operator: OPERATOR_NOT } },
+ { type: TOKEN_TYPE_TYPE, value: { data: 'incident', operator: OPERATOR_NOT } },
{ type: TOKEN_TYPE_MY_REACTION, value: { data: 'thumbsup', operator: OPERATOR_IS } },
- { type: TOKEN_TYPE_MY_REACTION, value: { data: 'thumbsdown', operator: OPERATOR_IS_NOT } },
+ { type: TOKEN_TYPE_MY_REACTION, value: { data: 'thumbsdown', operator: OPERATOR_NOT } },
{ type: TOKEN_TYPE_CONFIDENTIAL, value: { data: 'yes', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_ITERATION, value: { data: '4', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_ITERATION, value: { data: '12', operator: OPERATOR_IS } },
- { type: TOKEN_TYPE_ITERATION, value: { data: '20', operator: OPERATOR_IS_NOT } },
- { type: TOKEN_TYPE_ITERATION, value: { data: '42', operator: OPERATOR_IS_NOT } },
+ { type: TOKEN_TYPE_ITERATION, value: { data: '20', operator: OPERATOR_NOT } },
+ { type: TOKEN_TYPE_ITERATION, value: { data: '42', operator: OPERATOR_NOT } },
{ type: TOKEN_TYPE_EPIC, value: { data: '12', operator: OPERATOR_IS } },
- { type: TOKEN_TYPE_EPIC, value: { data: '34', operator: OPERATOR_IS_NOT } },
+ { type: TOKEN_TYPE_EPIC, value: { data: '34', operator: OPERATOR_NOT } },
{ type: TOKEN_TYPE_WEIGHT, value: { data: '1', operator: OPERATOR_IS } },
- { type: TOKEN_TYPE_WEIGHT, value: { data: '3', operator: OPERATOR_IS_NOT } },
+ { type: TOKEN_TYPE_WEIGHT, value: { data: '3', operator: OPERATOR_NOT } },
{ type: TOKEN_TYPE_CONTACT, value: { data: '123', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_ORGANIZATION, value: { data: '456', operator: OPERATOR_IS } },
{ type: FILTERED_SEARCH_TERM, value: { data: 'find' } },
@@ -264,6 +268,7 @@ export const apiParams = {
weight: '3',
},
or: {
+ authorUsernames: ['burns', 'smithers'],
assigneeUsernames: ['carl', 'lenny'],
},
};
@@ -283,6 +288,7 @@ export const apiParamsWithSpecialValues = {
export const urlParams = {
author_username: 'homer',
'not[author_username]': 'marge',
+ 'or[author_username]': ['burns', 'smithers'],
'assignee_username[]': ['bart', 'lisa', '5'],
'not[assignee_username][]': ['patty', 'selma'],
'or[assignee_username][]': ['carl', 'lenny'],
diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js
index 3c6332d5728..a281ed1c989 100644
--- a/spec/frontend/issues/list/utils_spec.js
+++ b/spec/frontend/issues/list/utils_spec.js
@@ -69,26 +69,40 @@ describe('isSortKey', () => {
describe('getSortOptions', () => {
describe.each`
- hasIssueWeightsFeature | hasBlockedIssuesFeature | length | containsWeight | containsBlocking
- ${false} | ${false} | ${10} | ${false} | ${false}
- ${true} | ${false} | ${11} | ${true} | ${false}
- ${false} | ${true} | ${11} | ${false} | ${true}
- ${true} | ${true} | ${12} | ${true} | ${true}
+ hasIssuableHealthStatusFeature | hasIssueWeightsFeature | hasBlockedIssuesFeature | length | containsHealthStatus | containsWeight | containsBlocking
+ ${false} | ${false} | ${false} | ${10} | ${false} | ${false} | ${false}
+ ${false} | ${false} | ${true} | ${11} | ${false} | ${false} | ${true}
+ ${false} | ${true} | ${false} | ${11} | ${false} | ${true} | ${false}
+ ${false} | ${true} | ${true} | ${12} | ${false} | ${true} | ${true}
+ ${true} | ${false} | ${false} | ${11} | ${true} | ${false} | ${false}
+ ${true} | ${false} | ${true} | ${12} | ${true} | ${false} | ${true}
+ ${true} | ${true} | ${false} | ${12} | ${true} | ${true} | ${false}
+ ${true} | ${true} | ${true} | ${13} | ${true} | ${true} | ${true}
`(
- 'when hasIssueWeightsFeature=$hasIssueWeightsFeature and hasBlockedIssuesFeature=$hasBlockedIssuesFeature',
+ 'when hasIssuableHealthStatusFeature=$hasIssuableHealthStatusFeature, hasIssueWeightsFeature=$hasIssueWeightsFeature and hasBlockedIssuesFeature=$hasBlockedIssuesFeature',
({
+ hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
hasBlockedIssuesFeature,
length,
+ containsHealthStatus,
containsWeight,
containsBlocking,
}) => {
- const sortOptions = getSortOptions(hasIssueWeightsFeature, hasBlockedIssuesFeature);
+ const sortOptions = getSortOptions({
+ hasBlockedIssuesFeature,
+ hasIssuableHealthStatusFeature,
+ hasIssueWeightsFeature,
+ });
it('returns the correct length of sort options', () => {
expect(sortOptions).toHaveLength(length);
});
+ it(`${containsHealthStatus ? 'contains' : 'does not contain'} health status option`, () => {
+ expect(sortOptions.some((option) => option.title === 'Health')).toBe(containsHealthStatus);
+ });
+
it(`${containsWeight ? 'contains' : 'does not contain'} weight option`, () => {
expect(sortOptions.some((option) => option.title === 'Weight')).toBe(containsWeight);
});
diff --git a/spec/frontend/issues/related_merge_requests/store/actions_spec.js b/spec/frontend/issues/related_merge_requests/store/actions_spec.js
index 4327fac15d4..d3ec6c3bc9d 100644
--- a/spec/frontend/issues/related_merge_requests/store/actions_spec.js
+++ b/spec/frontend/issues/related_merge_requests/store/actions_spec.js
@@ -1,6 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
-import createFlash from '~/flash';
+import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import * as actions from '~/issues/related_merge_requests/store/actions';
import * as types from '~/issues/related_merge_requests/store/mutation_types';
@@ -95,8 +95,8 @@ describe('RelatedMergeRequest store actions', () => {
[],
[{ type: 'requestData' }, { type: 'receiveDataError' }],
);
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith({
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
message: expect.stringMatching('Something went wrong'),
});
});
diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js
index 3d027e2084c..6cf44e60092 100644
--- a/spec/frontend/issues/show/components/app_spec.js
+++ b/spec/frontend/issues/show/components/app_spec.js
@@ -5,7 +5,7 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import '~/behaviors/markdown/render_gfm';
+import { createAlert } from '~/flash';
import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/constants';
import IssuableApp from '~/issues/show/components/app.vue';
import DescriptionComponent from '~/issues/show/components/description.vue';
@@ -26,8 +26,10 @@ import {
zoomMeetingUrl,
} from '../mock_data/mock_data';
-jest.mock('~/lib/utils/url_utility');
+jest.mock('~/flash');
jest.mock('~/issues/show/event_hub');
+jest.mock('~/lib/utils/url_utility');
+jest.mock('~/behaviors/markdown/render_gfm');
const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
@@ -270,9 +272,7 @@ describe('Issuable output', () => {
await wrapper.vm.updateIssuable();
expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
- expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
- `Error updating issue`,
- );
+ expect(createAlert).toHaveBeenCalledWith({ message: `Error updating issue` });
});
it('returns the correct error message for issuableType', async () => {
@@ -282,9 +282,7 @@ describe('Issuable output', () => {
await nextTick();
await wrapper.vm.updateIssuable();
expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
- expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
- `Error updating merge request`,
- );
+ expect(createAlert).toHaveBeenCalledWith({ message: `Error updating merge request` });
});
it('shows error message from backend if exists', async () => {
@@ -294,9 +292,9 @@ describe('Issuable output', () => {
.mockRejectedValue({ response: { data: { errors: [msg] } } });
await wrapper.vm.updateIssuable();
- expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
- `${wrapper.vm.defaultErrorMessage}. ${msg}`,
- );
+ expect(createAlert).toHaveBeenCalledWith({
+ message: `${wrapper.vm.defaultErrorMessage}. ${msg}`,
+ });
});
});
});
@@ -354,9 +352,7 @@ describe('Issuable output', () => {
.reply(() => Promise.reject(new Error('something went wrong')));
return wrapper.vm.requestTemplatesAndShowForm().then(() => {
- expect(document.querySelector('.flash-container .flash-text').textContent).toContain(
- 'Error updating issue',
- );
+ expect(createAlert).toHaveBeenCalledWith({ message: 'Error updating issue' });
expect(formSpy).toHaveBeenCalledWith();
});
@@ -402,9 +398,9 @@ describe('Issuable output', () => {
wrapper.setProps({ issuableType: 'merge request' });
return wrapper.vm.updateStoreState().then(() => {
- expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
- `Error updating ${wrapper.vm.issuableType}`,
- );
+ expect(createAlert).toHaveBeenCalledWith({
+ message: `Error updating ${wrapper.vm.issuableType}`,
+ });
});
});
});
diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js
index 9d9abce887b..889ff450825 100644
--- a/spec/frontend/issues/show/components/description_spec.js
+++ b/spec/frontend/issues/show/components/description_spec.js
@@ -1,7 +1,6 @@
import $ from 'jquery';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-import '~/behaviors/markdown/render_gfm';
import { GlTooltip, GlModal } from '@gitlab/ui';
import setWindowLocation from 'helpers/set_window_location_helper';
@@ -12,7 +11,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import createFlash from '~/flash';
+import { createAlert } from '~/flash';
import Description from '~/issues/show/components/description.vue';
import { updateHistory } from '~/lib/utils/url_utility';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
@@ -21,6 +20,7 @@ import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_ite
import TaskList from '~/task_list';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
import {
projectWorkItemTypesQueryResponse,
createWorkItemFromTaskMutationResponse,
@@ -37,6 +37,7 @@ jest.mock('~/lib/utils/url_utility', () => ({
updateHistory: jest.fn(),
}));
jest.mock('~/task_list');
+jest.mock('~/behaviors/markdown/render_gfm');
const showModal = jest.fn();
const hideModal = jest.fn();
@@ -161,7 +162,6 @@ describe('Description component', () => {
});
it('applies syntax highlighting and math when description changed', async () => {
- const prototypeSpy = jest.spyOn($.prototype, 'renderGFM');
createComponent();
await wrapper.setProps({
@@ -169,7 +169,7 @@ describe('Description component', () => {
});
expect(findGfmContent().exists()).toBe(true);
- expect(prototypeSpy).toHaveBeenCalled();
+ expect(renderGFM).toHaveBeenCalled();
});
it('sets data-update-url', () => {
@@ -370,7 +370,7 @@ describe('Description component', () => {
await waitForPromises();
- expect(createFlash).toHaveBeenCalledWith(
+ expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Something went wrong when creating task. Please try again.',
}),
diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js
index dc2b3c6fc48..7d6ca44e679 100644
--- a/spec/frontend/issues/show/components/header_actions_spec.js
+++ b/spec/frontend/issues/show/components/header_actions_spec.js
@@ -3,7 +3,7 @@ import { GlButton, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { mockTracking } from 'helpers/tracking_helper';
-import createFlash, { FLASH_TYPES } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { IssuableStatus, IssueType } from '~/issues/constants';
import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue';
import HeaderActions from '~/issues/show/components/header_actions.vue';
@@ -171,19 +171,19 @@ describe('HeaderActions component', () => {
${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems} | ${findDesktopDropdown}
`('$description', ({ isCloseIssueItemVisible, findDropdownItems, findDropdown }) => {
describe.each`
- description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic | canDestroyIssue
- ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${`when user can create ${issueType}`} | ${`New related ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${`when user cannot create ${issueType}`} | ${`New related ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true}
- ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true}
- ${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true}
- ${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true}
- ${`when user can delete ${issueType}`} | ${`Delete ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${`when user cannot delete ${issueType}`} | ${`Delete ${issueType}`} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false}
+ description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic | canDestroyIssue
+ ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user can create ${issueType}`} | ${`New related ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user cannot create ${issueType}`} | ${`New related ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true}
+ ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true}
+ ${'when user can report abuse'} | ${'Report abuse to administrator'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true}
+ ${'when user cannot report abuse'} | ${'Report abuse to administrator'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true}
+ ${`when user can delete ${issueType}`} | ${`Delete ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user cannot delete ${issueType}`} | ${`Delete ${issueType}`} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false}
`(
'$description',
({
@@ -284,9 +284,9 @@ describe('HeaderActions component', () => {
});
it('shows a success message and tells the user they are being redirected', () => {
- expect(createFlash).toHaveBeenCalledWith({
+ expect(createAlert).toHaveBeenCalledWith({
message: 'The issue was successfully promoted to an epic. Redirecting to epic...',
- type: FLASH_TYPES.SUCCESS,
+ variant: VARIANT_SUCCESS,
});
});
@@ -309,7 +309,7 @@ describe('HeaderActions component', () => {
});
it('shows an error message', () => {
- expect(createFlash).toHaveBeenCalledWith({
+ expect(createAlert).toHaveBeenCalledWith({
message: HeaderActions.i18n.promoteErrorMessage,
});
});
diff --git a/spec/frontend/issues/show/components/incidents/edit_timeline_event_spec.js b/spec/frontend/issues/show/components/incidents/edit_timeline_event_spec.js
index 4c1638a9147..81c3c30bf8a 100644
--- a/spec/frontend/issues/show/components/incidents/edit_timeline_event_spec.js
+++ b/spec/frontend/issues/show/components/incidents/edit_timeline_event_spec.js
@@ -40,5 +40,13 @@ describe('Edit Timeline events', () => {
expect(wrapper.emitted()).toEqual(cancelEvent);
});
+
+ it('should emit the delete event', async () => {
+ const deleteEvent = { delete: [[]] };
+
+ await findTimelineEventsForm().vm.$emit('delete');
+
+ expect(wrapper.emitted()).toEqual(deleteEvent);
+ });
});
});
diff --git a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
index 458c1c3f858..33a3a6eddfc 100644
--- a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
+++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
@@ -1,10 +1,11 @@
-import { GlTab } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import merge from 'lodash/merge';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
import DescriptionComponent from '~/issues/show/components/description.vue';
import HighlightBar from '~/issues/show/components/incidents/highlight_bar.vue';
-import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue';
+import IncidentTabs, {
+ incidentTabsI18n,
+} from '~/issues/show/components/incidents/incident_tabs.vue';
import INVALID_URL from '~/lib/utils/invalid_url';
import Tracking from '~/tracking';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
@@ -16,11 +17,24 @@ const mockAlert = {
iid: '1',
};
+const defaultMocks = {
+ $apollo: {
+ queries: {
+ alert: {
+ loading: true,
+ },
+ timelineEvents: {
+ loading: false,
+ },
+ },
+ },
+};
+
describe('Incident Tabs component', () => {
let wrapper;
- const mountComponent = (data = {}, options = {}) => {
- wrapper = shallowMount(
+ const mountComponent = ({ data = {}, options = {}, mount = shallowMountExtended } = {}) => {
+ wrapper = mount(
IncidentTabs,
merge(
{
@@ -29,7 +43,7 @@ describe('Incident Tabs component', () => {
},
stubs: {
DescriptionComponent: true,
- MetricsTab: true,
+ IncidentMetricTab: true,
},
provide: {
fullPath: '',
@@ -37,41 +51,37 @@ describe('Incident Tabs component', () => {
projectId: '',
issuableId: '',
uploadMetricsFeatureAvailable: true,
+ slaFeatureAvailable: true,
+ canUpdate: true,
+ canUpdateTimelineEvent: true,
},
data() {
return { alert: mockAlert, ...data };
},
- mocks: {
- $apollo: {
- queries: {
- alert: {
- loading: true,
- },
- timelineEvents: {
- loading: false,
- },
- },
- },
- },
+ mocks: defaultMocks,
},
options,
),
);
};
- const findTabs = () => wrapper.findAllComponents(GlTab);
- const findSummaryTab = () => findTabs().at(0);
- const findAlertDetailsTab = () => wrapper.find('[data-testid="alert-details-tab"]');
+ const findSummaryTab = () => wrapper.findByTestId('summary-tab');
+ const findTimelineTab = () => wrapper.findByTestId('timeline-tab');
+ const findAlertDetailsTab = () => wrapper.findByTestId('alert-details-tab');
const findAlertDetailsComponent = () => wrapper.findComponent(AlertDetailsTable);
const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent);
const findHighlightBarComponent = () => wrapper.findComponent(HighlightBar);
+ const findTabButtonByFilter = (filter) => wrapper.findAllByRole('tab').filter(filter);
+ const findTimelineTabButton = () =>
+ findTabButtonByFilter((inner) => inner.text() === incidentTabsI18n.timelineTitle).at(0);
+ const findActiveTabs = () => findTabButtonByFilter((inner) => inner.classes('active'));
- describe('empty state', () => {
+ describe('with no alerts', () => {
beforeEach(() => {
- mountComponent({ alert: null });
+ mountComponent({ data: { alert: null } });
});
- it('does not show the alert details tab', () => {
+ it('does not show the alert details tab option', () => {
expect(findAlertDetailsComponent().exists()).toBe(false);
});
});
@@ -83,7 +93,12 @@ describe('Incident Tabs component', () => {
it('renders the summary tab', () => {
expect(findSummaryTab().exists()).toBe(true);
- expect(findSummaryTab().attributes('title')).toBe('Summary');
+ expect(findSummaryTab().attributes('title')).toBe(incidentTabsI18n.summaryTitle);
+ });
+
+ it('renders the timeline tab', () => {
+ expect(findTimelineTab().exists()).toBe(true);
+ expect(findTimelineTab().attributes('title')).toBe(incidentTabsI18n.timelineTitle);
});
it('renders the alert details tab', () => {
@@ -125,4 +140,22 @@ describe('Incident Tabs component', () => {
expect(Tracking.event).toHaveBeenCalledWith(category, action);
});
});
+
+ describe('tab changing', () => {
+ beforeEach(() => {
+ mountComponent({ mount: mountExtended });
+ });
+
+ it('shows only the summary tab by default', async () => {
+ expect(findActiveTabs()).toHaveLength(1);
+ expect(findActiveTabs().at(0).text()).toBe(incidentTabsI18n.summaryTitle);
+ });
+
+ it("shows the timeline tab after it's clicked", async () => {
+ await findTimelineTabButton().trigger('click');
+
+ expect(findActiveTabs()).toHaveLength(1);
+ expect(findActiveTabs().at(0).text()).toBe(incidentTabsI18n.timelineTitle);
+ });
+ });
});
diff --git a/spec/frontend/issues/show/components/incidents/mock_data.js b/spec/frontend/issues/show/components/incidents/mock_data.js
index adea2b6df59..9accfcea791 100644
--- a/spec/frontend/issues/show/components/incidents/mock_data.js
+++ b/spec/frontend/issues/show/components/incidents/mock_data.js
@@ -13,6 +13,9 @@ export const mockEvents = [
noteHtml: '<p>Dummy event 1</p>',
occurredAt: '2022-03-22T15:59:00Z',
updatedAt: '2022-03-22T15:59:08Z',
+ timelineEventTags: {
+ nodes: [],
+ },
__typename: 'TimelineEventType',
},
{
@@ -29,6 +32,18 @@ export const mockEvents = [
noteHtml: '<p>Dummy event 2</p>',
occurredAt: '2022-03-23T14:57:00Z',
updatedAt: '2022-03-23T14:57:08Z',
+ timelineEventTags: {
+ nodes: [
+ {
+ id: 'gid://gitlab/IncidentManagement::TimelineEvent/132',
+ name: 'Start time',
+ },
+ {
+ id: 'gid://gitlab/IncidentManagement::TimelineEvent/132',
+ name: 'End time',
+ },
+ ],
+ },
__typename: 'TimelineEventType',
},
{
@@ -45,6 +60,9 @@ export const mockEvents = [
noteHtml: '<p>Dummy event 3</p>',
occurredAt: '2022-03-23T15:59:00Z',
updatedAt: '2022-03-23T15:59:08Z',
+ timelineEventTags: {
+ nodes: [],
+ },
__typename: 'TimelineEventType',
},
];
@@ -152,6 +170,9 @@ export const mockGetTimelineData = {
action: 'comment',
occurredAt: '2022-07-01T12:47:00Z',
createdAt: '2022-07-20T12:47:40Z',
+ timelineEventTags: {
+ nodes: [],
+ },
},
],
},
diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js
index 0ce3f75f576..d5b199cc790 100644
--- a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js
+++ b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js
@@ -22,11 +22,12 @@ describe('Timeline events form', () => {
useFakeDate(fakeDate);
let wrapper;
- const mountComponent = ({ mountMethod = shallowMountExtended } = {}) => {
+ const mountComponent = ({ mountMethod = shallowMountExtended } = {}, props = {}) => {
wrapper = mountMethod(TimelineEventsForm, {
propsData: {
showSaveAndAdd: true,
isEventProcessed: false,
+ ...props,
},
stubs: {
GlButton: true,
@@ -43,6 +44,7 @@ describe('Timeline events form', () => {
const findSubmitButton = () => wrapper.findByText(timelineFormI18n.save);
const findSubmitAndAddButton = () => wrapper.findByText(timelineFormI18n.saveAndAdd);
const findCancelButton = () => wrapper.findByText(timelineFormI18n.cancel);
+ const findDeleteButton = () => wrapper.findByText(timelineFormI18n.delete);
const findDatePicker = () => wrapper.findComponent(GlDatepicker);
const findHourInput = () => wrapper.findByTestId('input-hours');
const findMinuteInput = () => wrapper.findByTestId('input-minutes');
@@ -68,6 +70,9 @@ describe('Timeline events form', () => {
findCancelButton().vm.$emit('click');
await waitForPromises();
};
+ const deleteForm = () => {
+ findDeleteButton().vm.$emit('click');
+ };
it('renders markdown-field component with correct list of toolbar items', () => {
mountComponent({ mountMethod: mountExtended });
@@ -165,4 +170,38 @@ describe('Timeline events form', () => {
expect(findSubmitAndAddButton().props('disabled')).toBe(true);
});
});
+
+ describe('Delete button', () => {
+ it('does not show the delete button if showDelete prop is false', () => {
+ mountComponent({ mountMethod: mountExtended }, { showDelete: false });
+
+ expect(findDeleteButton().exists()).toBe(false);
+ });
+
+ it('shows the delete button if showDelete prop is true', () => {
+ mountComponent({ mountMethod: mountExtended }, { showDelete: true });
+
+ expect(findDeleteButton().exists()).toBe(true);
+ });
+
+ it('disables the delete button if isEventProcessed prop is true', () => {
+ mountComponent({ mountMethod: mountExtended }, { showDelete: true, isEventProcessed: true });
+
+ expect(findDeleteButton().props('disabled')).toBe(true);
+ });
+
+ it('does not disable the delete button if isEventProcessed prop is false', () => {
+ mountComponent({ mountMethod: mountExtended }, { showDelete: true, isEventProcessed: false });
+
+ expect(findDeleteButton().props('disabled')).toBe(false);
+ });
+
+ it('emits delete event on click', () => {
+ mountComponent({ mountMethod: mountExtended }, { showDelete: true, isEventProcessed: true });
+
+ deleteForm();
+
+ expect(wrapper.emitted('delete')).toEqual([[]]);
+ });
+ });
});
diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js
index 1bf8d68efd4..ba0527e5395 100644
--- a/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js
+++ b/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js
@@ -1,5 +1,5 @@
import timezoneMock from 'timezone-mock';
-import { GlIcon, GlDropdown } from '@gitlab/ui';
+import { GlIcon, GlDropdown, GlBadge } from '@gitlab/ui';
import { nextTick } from 'vue';
import { timelineItemI18n } from '~/issues/show/components/incidents/constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
@@ -27,25 +27,24 @@ describe('IncidentTimelineEventList', () => {
const findCommentIcon = () => wrapper.findComponent(GlIcon);
const findEventTime = () => wrapper.findByTestId('event-time');
+ const findEventTag = () => wrapper.findComponent(GlBadge);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDeleteButton = () => wrapper.findByText(timelineItemI18n.delete);
describe('template', () => {
- it('shows comment icon', () => {
+ beforeEach(() => {
mountComponent();
+ });
+ it('shows comment icon', () => {
expect(findCommentIcon().exists()).toBe(true);
});
it('sets correct props for icon', () => {
- mountComponent();
-
expect(findCommentIcon().props('name')).toBe(mockEvents[0].action);
});
it('displays the correct time', () => {
- mountComponent();
-
expect(findEventTime().text()).toBe('15:59 UTC');
});
@@ -58,8 +57,6 @@ describe('IncidentTimelineEventList', () => {
describe(timezone, () => {
beforeEach(() => {
timezoneMock.register(timezone);
-
- mountComponent();
});
afterEach(() => {
@@ -72,10 +69,20 @@ describe('IncidentTimelineEventList', () => {
});
});
+ describe('timeline event tag', () => {
+ it('does not show when tag is not provided', () => {
+ expect(findEventTag().exists()).toBe(false);
+ });
+
+ it('shows when tag is provided', () => {
+ mountComponent({ propsData: { eventTag: 'Start time' } });
+
+ expect(findEventTag().exists()).toBe(true);
+ });
+ });
+
describe('action dropdown', () => {
it('does not show the action dropdown by default', () => {
- mountComponent();
-
expect(findDropdown().exists()).toBe(false);
expect(findDeleteButton().exists()).toBe(false);
});
diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js
index dff1c429d07..a7250e8ad0d 100644
--- a/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js
+++ b/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js
@@ -92,6 +92,9 @@ describe('IncidentTimelineEventList', () => {
expect(findItems().at(1).props('occurredAt')).toBe(mockEvents[1].occurredAt);
expect(findItems().at(1).props('action')).toBe(mockEvents[1].action);
expect(findItems().at(1).props('noteHtml')).toBe(mockEvents[1].noteHtml);
+ expect(findItems().at(1).props('eventTag')).toBe(
+ mockEvents[1].timelineEventTags.nodes[0].name,
+ );
});
it('formats dates correctly', () => {
@@ -120,6 +123,20 @@ describe('IncidentTimelineEventList', () => {
});
});
+ describe('getFirstTag', () => {
+ it('returns undefined, when timelineEventTags contains an empty array', () => {
+ const returnedTag = wrapper.vm.getFirstTag(mockEvents[0].timelineEventTags);
+
+ expect(returnedTag).toEqual(undefined);
+ });
+
+ it('returns the first string, when timelineEventTags contains array with at least one tag', () => {
+ const returnedTag = wrapper.vm.getFirstTag(mockEvents[1].timelineEventTags);
+
+ expect(returnedTag).toBe(mockEvents[1].timelineEventTags.nodes[0].name);
+ });
+ });
+
describe('delete functionality', () => {
beforeEach(() => {
mockConfirmAction({ confirmed: true });
diff --git a/spec/frontend/issues/show/components/locked_warning_spec.js b/spec/frontend/issues/show/components/locked_warning_spec.js
new file mode 100644
index 00000000000..08f0338d41b
--- /dev/null
+++ b/spec/frontend/issues/show/components/locked_warning_spec.js
@@ -0,0 +1,55 @@
+import { GlAlert, GlLink } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { sprintf } from '~/locale';
+import { IssuableType } from '~/issues/constants';
+import LockedWarning, { i18n } from '~/issues/show/components/locked_warning.vue';
+
+describe('LockedWarning component', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = mountExtended(LockedWarning, {
+ propsData: props,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLink = () => wrapper.findComponent(GlLink);
+
+ describe.each([IssuableType.Issue, IssuableType.Epic])(
+ 'with issuableType set to %s',
+ (issuableType) => {
+ let alert;
+ let link;
+ beforeEach(() => {
+ createComponent({ issuableType });
+ alert = findAlert();
+ link = findLink();
+ });
+
+ afterEach(() => {
+ alert = null;
+ link = null;
+ });
+
+ it('displays a non-closable alert', () => {
+ expect(alert.exists()).toBe(true);
+ expect(alert.props('dismissible')).toBe(false);
+ });
+
+ it(`displays correct message`, async () => {
+ expect(alert.text()).toMatchInterpolatedText(sprintf(i18n.alertMessage, { issuableType }));
+ });
+
+ it(`displays a link with correct text`, async () => {
+ expect(link.exists()).toBe(true);
+ expect(link.text()).toBe(`the ${issuableType}`);
+ });
+ },
+ );
+});