diff options
Diffstat (limited to 'spec/frontend/issues')
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}`); + }); + }, + ); +}); |