diff options
Diffstat (limited to 'spec/frontend/issues')
30 files changed, 3319 insertions, 0 deletions
diff --git a/spec/frontend/issues/issue_spec.js b/spec/frontend/issues/issue_spec.js new file mode 100644 index 00000000000..8a089b372ff --- /dev/null +++ b/spec/frontend/issues/issue_spec.js @@ -0,0 +1,91 @@ +import { getByText } from '@testing-library/dom'; +import MockAdapter from 'axios-mock-adapter'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; +import Issue from '~/issues/issue'; +import axios from '~/lib/utils/axios_utils'; + +describe('Issue', () => { + let testContext; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(/(.*)\/related_branches$/).reply(200, {}); + + testContext = {}; + testContext.issue = new Issue(); + }); + + afterEach(() => { + mock.restore(); + testContext.issue.dispose(); + }); + + const getIssueCounter = () => document.querySelector('.issue_counter'); + const getOpenStatusBox = () => + getByText(document, (_, el) => el.textContent.match(/Open/), { + selector: '.status-box-open', + }); + const getClosedStatusBox = () => + getByText(document, (_, el) => el.textContent.match(/Closed/), { + selector: '.status-box-issue-closed', + }); + + describe.each` + desc | isIssueInitiallyOpen | expectedCounterText + ${'with an initially open issue'} | ${true} | ${'1,000'} + ${'with an initially closed issue'} | ${false} | ${'1,002'} + `('$desc', ({ isIssueInitiallyOpen, expectedCounterText }) => { + beforeEach(() => { + if (isIssueInitiallyOpen) { + loadFixtures('issues/open-issue.html'); + } else { + loadFixtures('issues/closed-issue.html'); + } + + testContext.issueCounter = getIssueCounter(); + testContext.statusBoxClosed = getClosedStatusBox(); + testContext.statusBoxOpen = getOpenStatusBox(); + + testContext.issueCounter.textContent = '1,001'; + }); + + it(`has the proper visible status box when ${isIssueInitiallyOpen ? 'open' : 'closed'}`, () => { + if (isIssueInitiallyOpen) { + expect(testContext.statusBoxClosed).toHaveClass('hidden'); + expect(testContext.statusBoxOpen).not.toHaveClass('hidden'); + } else { + expect(testContext.statusBoxClosed).not.toHaveClass('hidden'); + expect(testContext.statusBoxOpen).toHaveClass('hidden'); + } + }); + + describe('when vue app triggers change', () => { + beforeEach(() => { + document.dispatchEvent( + new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, { + detail: { + data: { id: 1 }, + isClosed: isIssueInitiallyOpen, + }, + }), + ); + }); + + it('displays correct status box', () => { + if (isIssueInitiallyOpen) { + expect(testContext.statusBoxClosed).not.toHaveClass('hidden'); + expect(testContext.statusBoxOpen).toHaveClass('hidden'); + } else { + expect(testContext.statusBoxClosed).toHaveClass('hidden'); + expect(testContext.statusBoxOpen).not.toHaveClass('hidden'); + } + }); + + it('updates issueCounter text', () => { + expect(testContext.issueCounter).toBeVisible(); + expect(testContext.issueCounter).toHaveText(expectedCounterText); + }); + }); + }); +}); diff --git a/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap b/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap new file mode 100644 index 00000000000..881dcda126f --- /dev/null +++ b/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Issue type info popover renders 1`] = ` +<span + id="popovercontainer" +> + <gl-icon-stub + class="gl-ml-5 gl-text-gray-500" + id="issue-type-info" + name="question-o" + size="16" + /> + + <gl-popover-stub + container="popovercontainer" + cssclasses="" + target="issue-type-info" + title="Issue types" + triggers="focus hover" + > + <ul + class="gl-list-style-none gl-p-0 gl-m-0" + > + <li + class="gl-mb-3" + > + <div + class="gl-font-weight-bold" + > + Issue + </div> + + <span> + For general work + </span> + </li> + + <li> + <div + class="gl-font-weight-bold" + > + Incident + </div> + + <span> + For investigating IT service disruptions or outages + </span> + </li> + </ul> + </gl-popover-stub> +</span> +`; diff --git a/spec/frontend/issues/new/components/title_suggestions_item_spec.js b/spec/frontend/issues/new/components/title_suggestions_item_spec.js new file mode 100644 index 00000000000..5eb30b52de5 --- /dev/null +++ b/spec/frontend/issues/new/components/title_suggestions_item_spec.js @@ -0,0 +1,132 @@ +import { GlTooltip, GlLink, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import TitleSuggestionsItem from '~/issues/new/components/title_suggestions_item.vue'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import mockData from '../mock_data'; + +describe('Issue title suggestions item component', () => { + let wrapper; + + function createComponent(suggestion = {}) { + wrapper = shallowMount(TitleSuggestionsItem, { + propsData: { + suggestion: { + ...mockData(), + ...suggestion, + }, + }, + }); + } + + const findLink = () => wrapper.findComponent(GlLink); + const findAuthorLink = () => wrapper.findAll(GlLink).at(1); + const findIcon = () => wrapper.findComponent(GlIcon); + const findTooltip = () => wrapper.findComponent(GlTooltip); + const findUserAvatar = () => wrapper.findComponent(UserAvatarImage); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders title', () => { + createComponent(); + + expect(wrapper.text()).toContain('Test issue'); + }); + + it('renders issue link', () => { + createComponent(); + + expect(findLink().attributes('href')).toBe(`${TEST_HOST}/test/issue/1`); + }); + + it('renders IID', () => { + createComponent(); + + expect(wrapper.text()).toContain('#1'); + }); + + describe('opened state', () => { + it('renders icon', () => { + createComponent(); + + expect(findIcon().props('name')).toBe('issue-open-m'); + expect(findIcon().attributes('class')).toMatch('gl-text-green-500'); + }); + + it('renders created timeago', () => { + createComponent({ + closedAt: '', + }); + + expect(findTooltip().text()).toContain('Opened'); + expect(findTooltip().text()).toContain('3 days ago'); + }); + }); + + describe('closed state', () => { + it('renders icon', () => { + createComponent({ + state: 'closed', + }); + + expect(findIcon().props('name')).toBe('issue-close'); + expect(findIcon().attributes('class')).toMatch('gl-text-blue-500'); + }); + + it('renders closed timeago', () => { + createComponent(); + + expect(findTooltip().text()).toContain('Opened'); + expect(findTooltip().text()).toContain('1 day ago'); + }); + }); + + describe('author', () => { + it('renders author info', () => { + createComponent(); + + expect(findAuthorLink().text()).toContain('Author Name'); + expect(findAuthorLink().text()).toContain('@author.username'); + }); + + it('renders author image', () => { + createComponent(); + + expect(findUserAvatar().props('imgSrc')).toBe(`${TEST_HOST}/avatar`); + }); + }); + + describe('counts', () => { + it('renders upvotes count', () => { + createComponent(); + + const count = wrapper.findAll('.suggestion-counts span').at(0); + + expect(count.text()).toContain('1'); + expect(count.find(GlIcon).props('name')).toBe('thumb-up'); + }); + + it('renders notes count', () => { + createComponent(); + + const count = wrapper.findAll('.suggestion-counts span').at(1); + + expect(count.text()).toContain('2'); + expect(count.find(GlIcon).props('name')).toBe('comment'); + }); + }); + + describe('confidential', () => { + it('renders confidential icon', () => { + createComponent({ + confidential: true, + }); + + expect(findIcon().props('name')).toBe('eye-slash'); + expect(findIcon().attributes('class')).toMatch('gl-text-orange-500'); + expect(findIcon().attributes('title')).toBe('Confidential'); + }); + }); +}); diff --git a/spec/frontend/issues/new/components/title_suggestions_spec.js b/spec/frontend/issues/new/components/title_suggestions_spec.js new file mode 100644 index 00000000000..984d0c9d25b --- /dev/null +++ b/spec/frontend/issues/new/components/title_suggestions_spec.js @@ -0,0 +1,100 @@ +import { shallowMount } from '@vue/test-utils'; +import TitleSuggestions from '~/issues/new/components/title_suggestions.vue'; +import TitleSuggestionsItem from '~/issues/new/components/title_suggestions_item.vue'; + +describe('Issue title suggestions component', () => { + let wrapper; + + function createComponent(search = 'search') { + wrapper = shallowMount(TitleSuggestions, { + propsData: { + search, + projectPath: 'project', + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('does not render with empty search', () => { + wrapper.setProps({ search: '' }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.isVisible()).toBe(false); + }); + }); + + describe('with data', () => { + let data; + + beforeEach(() => { + data = { issues: [{ id: 1 }, { id: 2 }] }; + }); + + it('renders component', () => { + wrapper.setData(data); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.findAll('li').length).toBe(data.issues.length); + }); + }); + + it('does not render with empty search', () => { + wrapper.setProps({ search: '' }); + wrapper.setData(data); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.isVisible()).toBe(false); + }); + }); + + it('does not render when loading', () => { + wrapper.setData({ + ...data, + loading: 1, + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.isVisible()).toBe(false); + }); + }); + + it('does not render with empty issues data', () => { + wrapper.setData({ issues: [] }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.isVisible()).toBe(false); + }); + }); + + it('renders list of issues', () => { + wrapper.setData(data); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.findAll(TitleSuggestionsItem).length).toBe(2); + }); + }); + + it('adds margin class to first item', () => { + wrapper.setData(data); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.findAll('li').at(0).classes()).toContain('gl-mb-3'); + }); + }); + + it('does not add margin class to last item', () => { + wrapper.setData(data); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.findAll('li').at(1).classes()).not.toContain('gl-mb-3'); + }); + }); + }); +}); diff --git a/spec/frontend/issues/new/components/type_popover_spec.js b/spec/frontend/issues/new/components/type_popover_spec.js new file mode 100644 index 00000000000..fe3d5207516 --- /dev/null +++ b/spec/frontend/issues/new/components/type_popover_spec.js @@ -0,0 +1,20 @@ +import { shallowMount } from '@vue/test-utils'; +import TypePopover from '~/issues/new/components/type_popover.vue'; + +describe('Issue type info popover', () => { + let wrapper; + + function createComponent() { + wrapper = shallowMount(TypePopover); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/issues/new/mock_data.js b/spec/frontend/issues/new/mock_data.js new file mode 100644 index 00000000000..74b569d9833 --- /dev/null +++ b/spec/frontend/issues/new/mock_data.js @@ -0,0 +1,28 @@ +import { TEST_HOST } from 'helpers/test_constants'; + +function getDate(daysMinus) { + const today = new Date(); + today.setDate(today.getDate() - daysMinus); + + return today.toISOString(); +} + +export default () => ({ + id: 1, + iid: 1, + state: 'opened', + upvotes: 1, + userNotesCount: 2, + closedAt: getDate(1), + createdAt: getDate(3), + updatedAt: getDate(2), + confidential: false, + webUrl: `${TEST_HOST}/test/issue/1`, + title: 'Test issue', + author: { + avatarUrl: `${TEST_HOST}/avatar`, + name: 'Author Name', + username: 'author.username', + webUrl: `${TEST_HOST}/author`, + }, +}); diff --git a/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js new file mode 100644 index 00000000000..4d780a674be --- /dev/null +++ b/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js @@ -0,0 +1,86 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import mockData from 'test_fixtures/issues/related_merge_requests.json'; +import axios from '~/lib/utils/axios_utils'; +import RelatedMergeRequests from '~/issues/related_merge_requests/components/related_merge_requests.vue'; +import createStore from '~/issues/related_merge_requests/store/index'; +import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; + +const API_ENDPOINT = '/api/v4/projects/2/issues/33/related_merge_requests'; +const localVue = createLocalVue(); + +describe('RelatedMergeRequests', () => { + let wrapper; + let mock; + + beforeEach((done) => { + // put the fixture in DOM as the component expects + document.body.innerHTML = `<div id="js-issuable-app"></div>`; + document.getElementById('js-issuable-app').dataset.initial = JSON.stringify(mockData); + + mock = new MockAdapter(axios); + mock.onGet(`${API_ENDPOINT}?per_page=100`).reply(200, mockData, { 'x-total': 2 }); + + wrapper = mount(localVue.extend(RelatedMergeRequests), { + localVue, + store: createStore(), + propsData: { + endpoint: API_ENDPOINT, + projectNamespace: 'gitlab-org', + projectPath: 'gitlab-ce', + }, + }); + + setImmediate(done); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('methods', () => { + describe('getAssignees', () => { + const assignees = [{ name: 'foo' }, { name: 'bar' }]; + + describe('when there is assignees array', () => { + it('should return assignees array', () => { + const mr = { assignees }; + + expect(wrapper.vm.getAssignees(mr)).toEqual(assignees); + }); + }); + + it('should return an array with single assingee', () => { + const mr = { assignee: assignees[0] }; + + expect(wrapper.vm.getAssignees(mr)).toEqual([assignees[0]]); + }); + + it('should return empty array when assignee is not set', () => { + expect(wrapper.vm.getAssignees({})).toEqual([]); + expect(wrapper.vm.getAssignees({ assignee: null })).toEqual([]); + }); + }); + }); + + describe('template', () => { + it('should render related merge request items', () => { + expect(wrapper.find('[data-testid="count"]').text()).toBe('2'); + expect(wrapper.findAll(RelatedIssuableItem)).toHaveLength(2); + + const props = wrapper.findAll(RelatedIssuableItem).at(1).props(); + const data = mockData[1]; + + expect(props.idKey).toEqual(data.id); + expect(props.pathIdSeparator).toEqual('!'); + expect(props.pipelineStatus).toBe(data.head_pipeline.detailed_status); + expect(props.assignees).toEqual([data.assignee]); + expect(props.isMergeRequest).toBe(true); + expect(props.confidential).toEqual(false); + expect(props.title).toEqual(data.title); + expect(props.state).toEqual(data.state); + expect(props.createdAt).toEqual(data.created_at); + }); + }); +}); diff --git a/spec/frontend/issues/related_merge_requests/store/actions_spec.js b/spec/frontend/issues/related_merge_requests/store/actions_spec.js new file mode 100644 index 00000000000..5f232fee09b --- /dev/null +++ b/spec/frontend/issues/related_merge_requests/store/actions_spec.js @@ -0,0 +1,113 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import createFlash 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'; + +jest.mock('~/flash'); + +describe('RelatedMergeRequest store actions', () => { + let state; + let mock; + + beforeEach(() => { + state = { + apiEndpoint: '/api/related_merge_requests', + }; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('setInitialState', () => { + it('commits types.SET_INITIAL_STATE with given props', (done) => { + const props = { a: 1, b: 2 }; + + testAction( + actions.setInitialState, + props, + {}, + [{ type: types.SET_INITIAL_STATE, payload: props }], + [], + done, + ); + }); + }); + + describe('requestData', () => { + it('commits types.REQUEST_DATA', (done) => { + testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], [], done); + }); + }); + + describe('receiveDataSuccess', () => { + it('commits types.RECEIVE_DATA_SUCCESS with data', (done) => { + const data = { a: 1, b: 2 }; + + testAction( + actions.receiveDataSuccess, + data, + {}, + [{ type: types.RECEIVE_DATA_SUCCESS, payload: data }], + [], + done, + ); + }); + }); + + describe('receiveDataError', () => { + it('commits types.RECEIVE_DATA_ERROR', (done) => { + testAction( + actions.receiveDataError, + null, + {}, + [{ type: types.RECEIVE_DATA_ERROR }], + [], + done, + ); + }); + }); + + describe('fetchMergeRequests', () => { + describe('for a successful request', () => { + it('should dispatch success action', (done) => { + const data = { a: 1 }; + mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(200, data, { 'x-total': 2 }); + + testAction( + actions.fetchMergeRequests, + null, + state, + [], + [{ type: 'requestData' }, { type: 'receiveDataSuccess', payload: { data, total: 2 } }], + done, + ); + }); + }); + + describe('for a failing request', () => { + it('should dispatch error action', (done) => { + mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(400); + + testAction( + actions.fetchMergeRequests, + null, + state, + [], + [{ type: 'requestData' }, { type: 'receiveDataError' }], + () => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('Something went wrong'), + }); + + done(); + }, + ); + }); + }); + }); +}); diff --git a/spec/frontend/issues/related_merge_requests/store/mutations_spec.js b/spec/frontend/issues/related_merge_requests/store/mutations_spec.js new file mode 100644 index 00000000000..0e3d26b3879 --- /dev/null +++ b/spec/frontend/issues/related_merge_requests/store/mutations_spec.js @@ -0,0 +1,49 @@ +import * as types from '~/issues/related_merge_requests/store/mutation_types'; +import mutations from '~/issues/related_merge_requests/store/mutations'; + +describe('RelatedMergeRequests Store Mutations', () => { + describe('SET_INITIAL_STATE', () => { + it('should set initial state according to given data', () => { + const apiEndpoint = '/api'; + const state = {}; + + mutations[types.SET_INITIAL_STATE](state, { apiEndpoint }); + + expect(state.apiEndpoint).toEqual(apiEndpoint); + }); + }); + + describe('REQUEST_DATA', () => { + it('should set loading flag', () => { + const state = {}; + + mutations[types.REQUEST_DATA](state); + + expect(state.isFetchingMergeRequests).toEqual(true); + }); + }); + + describe('RECEIVE_DATA_SUCCESS', () => { + it('should set loading flag and data', () => { + const state = {}; + const mrs = [1, 2, 3]; + + mutations[types.RECEIVE_DATA_SUCCESS](state, { data: mrs, total: mrs.length }); + + expect(state.isFetchingMergeRequests).toEqual(false); + expect(state.mergeRequests).toEqual(mrs); + expect(state.totalCount).toEqual(mrs.length); + }); + }); + + describe('RECEIVE_DATA_ERROR', () => { + it('should set loading and error flags', () => { + const state = {}; + + mutations[types.RECEIVE_DATA_ERROR](state); + + expect(state.isFetchingMergeRequests).toEqual(false); + expect(state.hasErrorFetchingMergeRequests).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js b/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js new file mode 100644 index 00000000000..5a51ae3cfe0 --- /dev/null +++ b/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js @@ -0,0 +1,82 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import Stacktrace from '~/error_tracking/components/stacktrace.vue'; +import SentryErrorStackTrace from '~/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Sentry Error Stack Trace', () => { + let actions; + let getters; + let store; + let wrapper; + + function mountComponent({ + stubs = { + stacktrace: Stacktrace, + }, + } = {}) { + wrapper = shallowMount(SentryErrorStackTrace, { + localVue, + stubs, + store, + propsData: { + issueStackTracePath: '/stacktrace', + }, + }); + } + + beforeEach(() => { + actions = { + startPollingStacktrace: () => {}, + }; + + getters = { + stacktrace: () => [{ context: [1, 2], lineNo: 53, filename: 'index.js' }], + }; + + const state = { + stacktraceData: {}, + loadingStacktrace: true, + }; + + store = new Vuex.Store({ + modules: { + details: { + namespaced: true, + actions, + getters, + state, + }, + }, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('loading', () => { + it('should show spinner while loading', () => { + mountComponent(); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find(Stacktrace).exists()).toBe(false); + }); + }); + + describe('Stack trace', () => { + beforeEach(() => { + store.state.details.loadingStacktrace = false; + }); + + it('should show stacktrace', () => { + mountComponent({ stubs: {} }); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(Stacktrace).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js new file mode 100644 index 00000000000..02db82b84dc --- /dev/null +++ b/spec/frontend/issues/show/components/app_spec.js @@ -0,0 +1,645 @@ +import { GlIntersectionObserver } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import '~/behaviors/markdown/render_gfm'; +import { IssuableStatus, IssuableStatusText } from '~/issues/constants'; +import IssuableApp from '~/issues/show/components/app.vue'; +import DescriptionComponent from '~/issues/show/components/description.vue'; +import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue'; +import PinnedLinks from '~/issues/show/components/pinned_links.vue'; +import { POLLING_DELAY } from '~/issues/show/constants'; +import eventHub from '~/issues/show/event_hub'; +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { + appProps, + initialRequest, + publishedIncidentUrl, + secondRequest, + zoomMeetingUrl, +} from '../mock_data/mock_data'; + +function formatText(text) { + return text.trim().replace(/\s\s+/g, ' '); +} + +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/issues/show/event_hub'); + +const REALTIME_REQUEST_STACK = [initialRequest, secondRequest]; + +describe('Issuable output', () => { + let mock; + let realtimeRequestCount = 0; + let wrapper; + + const findStickyHeader = () => wrapper.findByTestId('issue-sticky-header'); + const findLockedBadge = () => wrapper.findByTestId('locked'); + const findConfidentialBadge = () => wrapper.findByTestId('confidential'); + const findHiddenBadge = () => wrapper.findByTestId('hidden'); + const findAlert = () => wrapper.find('.alert'); + + const mountComponent = (props = {}, options = {}, data = {}) => { + wrapper = mountExtended(IssuableApp, { + directives: { + GlTooltip: createMockDirective(), + }, + propsData: { ...appProps, ...props }, + provide: { + fullPath: 'gitlab-org/incidents', + iid: '19', + uploadMetricsFeatureAvailable: false, + }, + stubs: { + HighlightBar: true, + IncidentTabs: true, + }, + data() { + return { + ...data, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + setFixtures(` + <div> + <title>Title</title> + <div class="detail-page-description content-block"> + <details open> + <summary>One</summary> + </details> + <details> + <summary>Two</summary> + </details> + </div> + <div class="flash-container"></div> + <span id="task_status"></span> + </div> + `); + + mock = new MockAdapter(axios); + mock + .onGet('/gitlab-org/gitlab-shell/-/issues/9/realtime_changes/realtime_changes') + .reply(() => { + const res = Promise.resolve([200, REALTIME_REQUEST_STACK[realtimeRequestCount]]); + realtimeRequestCount += 1; + return res; + }); + + mountComponent(); + + jest.advanceTimersByTime(2); + }); + + afterEach(() => { + mock.restore(); + realtimeRequestCount = 0; + wrapper.vm.poll.stop(); + wrapper.destroy(); + }); + + it('should render a title/description/edited and update title/description/edited on update', () => { + let editedText; + return axios + .waitForAll() + .then(() => { + editedText = wrapper.find('.edited-text'); + }) + .then(() => { + expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); + expect(wrapper.find('.title').text()).toContain('this is a title'); + expect(wrapper.find('.md').text()).toContain('this is a description!'); + expect(wrapper.find('.js-task-list-field').element.value).toContain( + 'this is a description', + ); + + expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/); + expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/); + expect(editedText.find('time').text()).toBeTruthy(); + expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version); + }) + .then(() => { + wrapper.vm.poll.makeRequest(); + return axios.waitForAll(); + }) + .then(() => { + expect(document.querySelector('title').innerText).toContain('2 (#1)'); + expect(wrapper.find('.title').text()).toContain('2'); + expect(wrapper.find('.md').text()).toContain('42'); + expect(wrapper.find('.js-task-list-field').element.value).toContain('42'); + expect(wrapper.find('.edited-text').text()).toBeTruthy(); + expect(formatText(wrapper.find('.edited-text').text())).toMatch( + /Edited[\s\S]+?by Other User/, + ); + + expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/); + expect(editedText.find('time').text()).toBeTruthy(); + // As the lock_version value does not differ from the server, + // we should not see an alert + expect(findAlert().exists()).toBe(false); + }); + }); + + it('shows actions if permissions are correct', () => { + wrapper.vm.showForm = true; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find('.markdown-selector').exists()).toBe(true); + }); + }); + + it('does not show actions if permissions are incorrect', () => { + wrapper.vm.showForm = true; + wrapper.setProps({ canUpdate: false }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find('.markdown-selector').exists()).toBe(false); + }); + }); + + it('does not update formState if form is already open', () => { + wrapper.vm.updateAndShowForm(); + + wrapper.vm.state.titleText = 'testing 123'; + + wrapper.vm.updateAndShowForm(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.store.formState.title).not.toBe('testing 123'); + }); + }); + + describe('Pinned links propagated', () => { + it.each` + prop | value + ${'zoomMeetingUrl'} | ${zoomMeetingUrl} + ${'publishedIncidentUrl'} | ${publishedIncidentUrl} + `('sets the $prop correctly on underlying pinned links', ({ prop, value }) => { + expect(wrapper.vm[prop]).toBe(value); + expect(wrapper.find(`[data-testid="${prop}"]`).attributes('href')).toBe(value); + }); + }); + + describe('updateIssuable', () => { + it('fetches new data after update', () => { + const updateStoreSpy = jest.spyOn(wrapper.vm, 'updateStoreState'); + const getDataSpy = jest.spyOn(wrapper.vm.service, 'getData'); + jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({ + data: { web_url: window.location.pathname }, + }); + + return wrapper.vm.updateIssuable().then(() => { + expect(updateStoreSpy).toHaveBeenCalled(); + expect(getDataSpy).toHaveBeenCalled(); + }); + }); + + it('correctly updates issuable data', () => { + const spy = jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({ + data: { web_url: window.location.pathname }, + }); + + return wrapper.vm.updateIssuable().then(() => { + expect(spy).toHaveBeenCalledWith(wrapper.vm.formState); + expect(eventHub.$emit).toHaveBeenCalledWith('close.form'); + }); + }); + + it('does not redirect if issue has not moved', () => { + jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({ + data: { + web_url: window.location.pathname, + confidential: wrapper.vm.isConfidential, + }, + }); + + return wrapper.vm.updateIssuable().then(() => { + expect(visitUrl).not.toHaveBeenCalled(); + }); + }); + + it('does not redirect if issue has not moved and user has switched tabs', () => { + jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({ + data: { + web_url: '', + confidential: wrapper.vm.isConfidential, + }, + }); + + return wrapper.vm.updateIssuable().then(() => { + expect(visitUrl).not.toHaveBeenCalled(); + }); + }); + + it('redirects if returned web_url has changed', () => { + jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({ + data: { + web_url: '/testing-issue-move', + confidential: wrapper.vm.isConfidential, + }, + }); + + wrapper.vm.updateIssuable(); + + return wrapper.vm.updateIssuable().then(() => { + expect(visitUrl).toHaveBeenCalledWith('/testing-issue-move'); + }); + }); + + describe('shows dialog when issue has unsaved changed', () => { + it('confirms on title change', () => { + wrapper.vm.showForm = true; + wrapper.vm.state.titleText = 'title has changed'; + const e = { returnValue: null }; + wrapper.vm.handleBeforeUnloadEvent(e); + + return wrapper.vm.$nextTick().then(() => { + expect(e.returnValue).not.toBeNull(); + }); + }); + + it('confirms on description change', () => { + wrapper.vm.showForm = true; + wrapper.vm.state.descriptionText = 'description has changed'; + const e = { returnValue: null }; + wrapper.vm.handleBeforeUnloadEvent(e); + + return wrapper.vm.$nextTick().then(() => { + expect(e.returnValue).not.toBeNull(); + }); + }); + + it('does nothing when nothing has changed', () => { + const e = { returnValue: null }; + wrapper.vm.handleBeforeUnloadEvent(e); + + return wrapper.vm.$nextTick().then(() => { + expect(e.returnValue).toBeNull(); + }); + }); + }); + + describe('error when updating', () => { + it('closes form on error', () => { + jest.spyOn(wrapper.vm.service, 'updateIssuable').mockRejectedValue(); + + return wrapper.vm.updateIssuable().then(() => { + expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form'); + expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( + `Error updating issue`, + ); + }); + }); + + it('returns the correct error message for issuableType', () => { + jest.spyOn(wrapper.vm.service, 'updateIssuable').mockRejectedValue(); + wrapper.setProps({ issuableType: 'merge request' }); + + return wrapper.vm + .$nextTick() + .then(wrapper.vm.updateIssuable) + .then(() => { + expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form'); + expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( + `Error updating merge request`, + ); + }); + }); + + it('shows error message from backend if exists', () => { + const msg = 'Custom error message from backend'; + jest + .spyOn(wrapper.vm.service, 'updateIssuable') + .mockRejectedValue({ response: { data: { errors: [msg] } } }); + + return wrapper.vm.updateIssuable().then(() => { + expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( + `${wrapper.vm.defaultErrorMessage}. ${msg}`, + ); + }); + }); + }); + }); + + describe('updateAndShowForm', () => { + it('shows locked warning if form is open & data is different', () => { + return wrapper.vm + .$nextTick() + .then(() => { + wrapper.vm.updateAndShowForm(); + + wrapper.vm.poll.makeRequest(); + + return new Promise((resolve) => { + wrapper.vm.$watch('formState.lockedWarningVisible', (value) => { + if (value) { + resolve(); + } + }); + }); + }) + .then(() => { + expect(wrapper.vm.formState.lockedWarningVisible).toBe(true); + expect(wrapper.vm.formState.lock_version).toBe(1); + expect(findAlert().exists()).toBe(true); + }); + }); + }); + + describe('requestTemplatesAndShowForm', () => { + let formSpy; + + beforeEach(() => { + formSpy = jest.spyOn(wrapper.vm, 'updateAndShowForm'); + }); + + it('shows the form if template names as hash request is successful', () => { + const mockData = { + test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + }; + mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData])); + + return wrapper.vm.requestTemplatesAndShowForm().then(() => { + expect(formSpy).toHaveBeenCalledWith(mockData); + }); + }); + + it('shows the form if template names as array request is successful', () => { + const mockData = [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }]; + mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData])); + + return wrapper.vm.requestTemplatesAndShowForm().then(() => { + expect(formSpy).toHaveBeenCalledWith(mockData); + }); + }); + + it('shows the form if template names request failed', () => { + mock + .onGet('/issuable-templates-path') + .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(formSpy).toHaveBeenCalledWith(); + }); + }); + }); + + describe('show inline edit button', () => { + it('should not render by default', () => { + expect(wrapper.find('.btn-edit').exists()).toBe(true); + }); + + it('should render if showInlineEditButton', () => { + wrapper.setProps({ showInlineEditButton: true }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.find('.btn-edit').exists()).toBe(true); + }); + }); + }); + + describe('updateStoreState', () => { + it('should make a request and update the state of the store', () => { + const data = { foo: 1 }; + const getDataSpy = jest.spyOn(wrapper.vm.service, 'getData').mockResolvedValue({ data }); + const updateStateSpy = jest + .spyOn(wrapper.vm.store, 'updateState') + .mockImplementation(jest.fn); + + return wrapper.vm.updateStoreState().then(() => { + expect(getDataSpy).toHaveBeenCalled(); + expect(updateStateSpy).toHaveBeenCalledWith(data); + }); + }); + + it('should show error message if store update fails', () => { + jest.spyOn(wrapper.vm.service, 'getData').mockRejectedValue(); + 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}`, + ); + }); + }); + }); + + describe('issueChanged', () => { + beforeEach(() => { + wrapper.vm.store.formState.title = ''; + wrapper.vm.store.formState.description = ''; + wrapper.setProps({ + initialDescriptionText: '', + initialTitleText: '', + }); + }); + + it('returns true when title is changed', () => { + wrapper.vm.store.formState.title = 'RandomText'; + + expect(wrapper.vm.issueChanged).toBe(true); + }); + + it('returns false when title is empty null', () => { + wrapper.vm.store.formState.title = null; + + expect(wrapper.vm.issueChanged).toBe(false); + }); + + it('returns true when description is changed', () => { + wrapper.vm.store.formState.description = 'RandomText'; + + expect(wrapper.vm.issueChanged).toBe(true); + }); + + it('returns false when description is empty null', () => { + wrapper.vm.store.formState.description = null; + + expect(wrapper.vm.issueChanged).toBe(false); + }); + + it('returns false when `initialDescriptionText` is null and `formState.description` is empty string', () => { + wrapper.vm.store.formState.description = ''; + wrapper.setProps({ initialDescriptionText: null }); + + expect(wrapper.vm.issueChanged).toBe(false); + }); + }); + + describe('sticky header', () => { + describe('when title is in view', () => { + it('is not shown', () => { + expect(findStickyHeader().exists()).toBe(false); + }); + }); + + describe('when title is not in view', () => { + beforeEach(() => { + wrapper.vm.state.titleText = 'Sticky header title'; + wrapper.find(GlIntersectionObserver).vm.$emit('disappear'); + }); + + it('shows with title', () => { + expect(findStickyHeader().text()).toContain('Sticky header title'); + }); + + it.each` + title | state + ${'shows with Open when status is opened'} | ${IssuableStatus.Open} + ${'shows with Closed when status is closed'} | ${IssuableStatus.Closed} + ${'shows with Open when status is reopened'} | ${IssuableStatus.Reopened} + `('$title', async ({ state }) => { + wrapper.setProps({ issuableStatus: state }); + + await nextTick(); + + expect(findStickyHeader().text()).toContain(IssuableStatusText[state]); + }); + + it.each` + title | isConfidential + ${'does not show confidential badge when issue is not confidential'} | ${false} + ${'shows confidential badge when issue is confidential'} | ${true} + `('$title', async ({ isConfidential }) => { + wrapper.setProps({ isConfidential }); + + await nextTick(); + + expect(findConfidentialBadge().exists()).toBe(isConfidential); + }); + + it.each` + title | isLocked + ${'does not show locked badge when issue is not locked'} | ${false} + ${'shows locked badge when issue is locked'} | ${true} + `('$title', async ({ isLocked }) => { + wrapper.setProps({ isLocked }); + + await nextTick(); + + expect(findLockedBadge().exists()).toBe(isLocked); + }); + + it.each` + title | isHidden + ${'does not show hidden badge when issue is not hidden'} | ${false} + ${'shows hidden badge when issue is hidden'} | ${true} + `('$title', async ({ isHidden }) => { + wrapper.setProps({ isHidden }); + + await nextTick(); + + const hiddenBadge = findHiddenBadge(); + + expect(hiddenBadge.exists()).toBe(isHidden); + + if (isHidden) { + expect(hiddenBadge.attributes('title')).toBe( + 'This issue is hidden because its author has been banned', + ); + expect(getBinding(hiddenBadge.element, 'gl-tooltip')).not.toBeUndefined(); + } + }); + }); + }); + + describe('Composable description component', () => { + const findIncidentTabs = () => wrapper.findComponent(IncidentTabs); + const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent); + const findPinnedLinks = () => wrapper.findComponent(PinnedLinks); + const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'; + + describe('when using description component', () => { + it('renders the description component', () => { + expect(findDescriptionComponent().exists()).toBe(true); + }); + + it('does not render incident tabs', () => { + expect(findIncidentTabs().exists()).toBe(false); + }); + + it('adds a border below the header', () => { + expect(findPinnedLinks().attributes('class')).toContain(borderClass); + }); + }); + + describe('when using incident tabs description wrapper', () => { + beforeEach(() => { + mountComponent( + { + descriptionComponent: IncidentTabs, + showTitleBorder: false, + }, + { + mocks: { + $apollo: { + queries: { + alert: { + loading: false, + }, + }, + }, + }, + }, + ); + }); + + it('renders the description component', () => { + expect(findDescriptionComponent().exists()).toBe(true); + }); + + it('renders incident tabs', () => { + expect(findIncidentTabs().exists()).toBe(true); + }); + + it('does not add a border below the header', () => { + expect(findPinnedLinks().attributes('class')).not.toContain(borderClass); + }); + }); + }); + + describe('taskListUpdateStarted', () => { + it('stops polling', () => { + jest.spyOn(wrapper.vm.poll, 'stop'); + + wrapper.vm.taskListUpdateStarted(); + + expect(wrapper.vm.poll.stop).toHaveBeenCalled(); + }); + }); + + describe('taskListUpdateSucceeded', () => { + it('enables polling', () => { + jest.spyOn(wrapper.vm.poll, 'enable'); + jest.spyOn(wrapper.vm.poll, 'makeDelayedRequest'); + + wrapper.vm.taskListUpdateSucceeded(); + + expect(wrapper.vm.poll.enable).toHaveBeenCalled(); + expect(wrapper.vm.poll.makeDelayedRequest).toHaveBeenCalledWith(POLLING_DELAY); + }); + }); + + describe('taskListUpdateFailed', () => { + it('enables polling and calls updateStoreState', () => { + jest.spyOn(wrapper.vm.poll, 'enable'); + jest.spyOn(wrapper.vm.poll, 'makeDelayedRequest'); + jest.spyOn(wrapper.vm, 'updateStoreState'); + + wrapper.vm.taskListUpdateFailed(); + + expect(wrapper.vm.poll.enable).toHaveBeenCalled(); + expect(wrapper.vm.poll.makeDelayedRequest).toHaveBeenCalledWith(POLLING_DELAY); + expect(wrapper.vm.updateStoreState).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/delete_issue_modal_spec.js b/spec/frontend/issues/show/components/delete_issue_modal_spec.js new file mode 100644 index 00000000000..97a091a1748 --- /dev/null +++ b/spec/frontend/issues/show/components/delete_issue_modal_spec.js @@ -0,0 +1,108 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('DeleteIssueModal component', () => { + let wrapper; + + const defaultProps = { + issuePath: 'gitlab-org/gitlab-test/-/issues/1', + issueType: 'issue', + modalId: 'modal-id', + title: 'Delete issue', + }; + + const findForm = () => wrapper.find('form'); + const findModal = () => wrapper.findComponent(GlModal); + + const mountComponent = (props = {}) => + shallowMount(DeleteIssueModal, { propsData: { ...defaultProps, ...props } }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('modal', () => { + it('renders', () => { + wrapper = mountComponent(); + + expect(findModal().props()).toMatchObject({ + actionCancel: DeleteIssueModal.actionCancel, + actionPrimary: { + attributes: { variant: 'danger' }, + text: defaultProps.title, + }, + modalId: defaultProps.modalId, + size: 'sm', + title: defaultProps.title, + }); + }); + + describe('when "primary" event is emitted', () => { + let formSubmitSpy; + + beforeEach(() => { + wrapper = mountComponent(); + formSubmitSpy = jest.spyOn(wrapper.vm.$refs.form, 'submit'); + findModal().vm.$emit('primary'); + }); + + it('"delete" event is emitted by DeleteIssueModal', () => { + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + + it('submits the form', () => { + expect(formSubmitSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('form', () => { + beforeEach(() => { + wrapper = mountComponent(); + }); + + it('renders with action and method', () => { + expect(findForm().attributes()).toEqual({ + action: defaultProps.issuePath, + method: 'post', + }); + }); + + it('contains form data', () => { + const formData = wrapper.findAll('input').wrappers.reduce( + (acc, input) => ({ + ...acc, + [input.element.name]: input.element.value, + }), + {}, + ); + + expect(formData).toEqual({ + _method: 'delete', + authenticity_token: 'mock-csrf-token', + destroy_confirm: 'true', + }); + }); + }); + + describe('body text', () => { + describe('when issue type is not epic', () => { + it('renders', () => { + wrapper = mountComponent(); + + expect(findForm().text()).toBe('Issue will be removed! Are you sure?'); + }); + }); + + describe('when issue type is epic', () => { + it('renders', () => { + wrapper = mountComponent({ issueType: 'epic' }); + + expect(findForm().text()).toBe('Delete this epic and all descendants?'); + }); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js new file mode 100644 index 00000000000..d39e00b9c9e --- /dev/null +++ b/spec/frontend/issues/show/components/description_spec.js @@ -0,0 +1,187 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import '~/behaviors/markdown/render_gfm'; +import { TEST_HOST } from 'helpers/test_constants'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import Description from '~/issues/show/components/description.vue'; +import TaskList from '~/task_list'; +import { descriptionProps as props } from '../mock_data/mock_data'; + +jest.mock('~/task_list'); + +describe('Description component', () => { + let vm; + let DescriptionComponent; + + beforeEach(() => { + DescriptionComponent = Vue.extend(Description); + + if (!document.querySelector('.issuable-meta')) { + const metaData = document.createElement('div'); + metaData.classList.add('issuable-meta'); + metaData.innerHTML = + '<div class="flash-container"></div><span id="task_status"></span><span id="task_status_short"></span>'; + + document.body.appendChild(metaData); + } + + vm = mountComponent(DescriptionComponent, props); + }); + + afterEach(() => { + vm.$destroy(); + }); + + afterAll(() => { + $('.issuable-meta .flash-container').remove(); + }); + + it('doesnt animate first description changes', () => { + vm.descriptionHtml = 'changed'; + + return vm.$nextTick().then(() => { + expect( + vm.$el.querySelector('.md').classList.contains('issue-realtime-pre-pulse'), + ).toBeFalsy(); + jest.runAllTimers(); + return vm.$nextTick(); + }); + }); + + it('animates description changes on live update', () => { + vm.descriptionHtml = 'changed'; + return vm + .$nextTick() + .then(() => { + vm.descriptionHtml = 'changed second time'; + return vm.$nextTick(); + }) + .then(() => { + expect( + vm.$el.querySelector('.md').classList.contains('issue-realtime-pre-pulse'), + ).toBeTruthy(); + jest.runAllTimers(); + return vm.$nextTick(); + }) + .then(() => { + expect( + vm.$el.querySelector('.md').classList.contains('issue-realtime-trigger-pulse'), + ).toBeTruthy(); + }); + }); + + it('applies syntax highlighting and math when description changed', () => { + const vmSpy = jest.spyOn(vm, 'renderGFM'); + const prototypeSpy = jest.spyOn($.prototype, 'renderGFM'); + vm.descriptionHtml = 'changed'; + + return vm.$nextTick().then(() => { + expect(vm.$refs['gfm-content']).toBeDefined(); + expect(vmSpy).toHaveBeenCalled(); + expect(prototypeSpy).toHaveBeenCalled(); + expect($.prototype.renderGFM).toHaveBeenCalled(); + }); + }); + + it('sets data-update-url', () => { + expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(TEST_HOST); + }); + + describe('TaskList', () => { + beforeEach(() => { + vm.$destroy(); + TaskList.mockClear(); + vm = mountComponent(DescriptionComponent, { ...props, issuableType: 'issuableType' }); + }); + + it('re-inits the TaskList when description changed', () => { + vm.descriptionHtml = 'changed'; + + expect(TaskList).toHaveBeenCalled(); + }); + + it('does not re-init the TaskList when canUpdate is false', () => { + vm.canUpdate = false; + vm.descriptionHtml = 'changed'; + + expect(TaskList).toHaveBeenCalledTimes(1); + }); + + it('calls with issuableType dataType', () => { + vm.descriptionHtml = 'changed'; + + expect(TaskList).toHaveBeenCalledWith({ + dataType: 'issuableType', + fieldName: 'description', + selector: '.detail-page-description', + onUpdate: expect.any(Function), + onSuccess: expect.any(Function), + onError: expect.any(Function), + lockVersion: 0, + }); + }); + }); + + describe('taskStatus', () => { + it('adds full taskStatus', () => { + vm.taskStatus = '1 of 1'; + + return vm.$nextTick().then(() => { + expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe( + '1 of 1', + ); + }); + }); + + it('adds short taskStatus', () => { + vm.taskStatus = '1 of 1'; + + return vm.$nextTick().then(() => { + expect(document.querySelector('.issuable-meta #task_status_short').textContent.trim()).toBe( + '1/1 task', + ); + }); + }); + + it('clears task status text when no tasks are present', () => { + vm.taskStatus = '0 of 0'; + + return vm.$nextTick().then(() => { + expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe(''); + }); + }); + }); + + describe('taskListUpdateStarted', () => { + it('emits event to parent', () => { + const spy = jest.spyOn(vm, '$emit'); + + vm.taskListUpdateStarted(); + + expect(spy).toHaveBeenCalledWith('taskListUpdateStarted'); + }); + }); + + describe('taskListUpdateSuccess', () => { + it('emits event to parent', () => { + const spy = jest.spyOn(vm, '$emit'); + + vm.taskListUpdateSuccess(); + + expect(spy).toHaveBeenCalledWith('taskListUpdateSucceeded'); + }); + }); + + describe('taskListUpdateError', () => { + it('should create flash notification and emit an event to parent', () => { + const msg = + 'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.'; + const spy = jest.spyOn(vm, '$emit'); + + vm.taskListUpdateError(); + + expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg); + expect(spy).toHaveBeenCalledWith('taskListUpdateFailed'); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/edit_actions_spec.js b/spec/frontend/issues/show/components/edit_actions_spec.js new file mode 100644 index 00000000000..79368023d76 --- /dev/null +++ b/spec/frontend/issues/show/components/edit_actions_spec.js @@ -0,0 +1,181 @@ +import { GlButton } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import IssuableEditActions from '~/issues/show/components/edit_actions.vue'; +import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; +import eventHub from '~/issues/show/event_hub'; +import { + getIssueStateQueryResponse, + updateIssueStateQueryResponse, +} from '../mock_data/apollo_mock'; + +describe('Edit Actions component', () => { + let wrapper; + let fakeApollo; + let mockIssueStateData; + + Vue.use(VueApollo); + + const mockResolvers = { + Query: { + issueState() { + return { + __typename: 'IssueState', + rawData: mockIssueStateData(), + }; + }, + }, + }; + + const modalId = 'delete-issuable-modal-1'; + + const createComponent = ({ props, data } = {}) => { + fakeApollo = createMockApollo([], mockResolvers); + + wrapper = shallowMountExtended(IssuableEditActions, { + apolloProvider: fakeApollo, + propsData: { + formState: { + title: 'GitLab Issue', + }, + canDestroy: true, + endpoint: 'gitlab-org/gitlab-test/-/issues/1', + issuableType: 'issue', + ...props, + }, + data() { + return { + issueState: {}, + modalId, + ...data, + }; + }, + }); + }; + + const findModal = () => wrapper.findComponent(DeleteIssueModal); + const findEditButtons = () => wrapper.findAllComponents(GlButton); + const findDeleteButton = () => wrapper.findByTestId('issuable-delete-button'); + const findSaveButton = () => wrapper.findByTestId('issuable-save-button'); + const findCancelButton = () => wrapper.findByTestId('issuable-cancel-button'); + + beforeEach(() => { + mockIssueStateData = jest.fn(); + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all buttons as enabled', () => { + const buttons = findEditButtons().wrappers; + buttons.forEach((button) => { + expect(button.attributes('disabled')).toBeFalsy(); + }); + }); + + it('does not render the delete button if canDestroy is false', () => { + createComponent({ props: { canDestroy: false } }); + expect(findDeleteButton().exists()).toBe(false); + }); + + it('disables save button when title is blank', () => { + createComponent({ props: { formState: { title: '', issue_type: '' } } }); + + expect(findSaveButton().attributes('disabled')).toBe('true'); + }); + + it('does not render the delete button if showDeleteButton is false', () => { + createComponent({ props: { showDeleteButton: false } }); + + expect(findDeleteButton().exists()).toBe(false); + }); + + describe('updateIssuable', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + it('sends update.issauble event when clicking save button', () => { + findSaveButton().vm.$emit('click', { preventDefault: jest.fn() }); + + expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + }); + }); + + describe('closeForm', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + it('emits close.form when clicking cancel', () => { + findCancelButton().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('close.form'); + }); + }); + + describe('delete issue button', () => { + let trackingSpy; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + it('tracks clicking on button', () => { + findDeleteButton().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'delete_issue', + }); + }); + }); + + describe('delete issue modal', () => { + it('renders', () => { + expect(findModal().props()).toEqual({ + issuePath: 'gitlab-org/gitlab-test/-/issues/1', + issueType: 'Issue', + modalId, + title: 'Delete issue', + }); + }); + }); + + describe('deleteIssuable', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + it('does not send the `delete.issuable` event when clicking delete button', () => { + findDeleteButton().vm.$emit('click'); + expect(eventHub.$emit).not.toHaveBeenCalled(); + }); + + it('sends the `delete.issuable` event when clicking the delete confirm button', async () => { + expect(eventHub.$emit).toHaveBeenCalledTimes(0); + findModal().vm.$emit('delete'); + expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable'); + expect(eventHub.$emit).toHaveBeenCalledTimes(1); + }); + }); + + describe('with Apollo cache mock', () => { + it('renders the right delete button text per apollo cache type', async () => { + mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse); + await waitForPromises(); + expect(findDeleteButton().text()).toBe('Delete issue'); + }); + + it('should not change the delete button text per apollo cache mutation', async () => { + mockIssueStateData.mockResolvedValue(updateIssueStateQueryResponse); + await waitForPromises(); + expect(findDeleteButton().text()).toBe('Delete issue'); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/edited_spec.js b/spec/frontend/issues/show/components/edited_spec.js new file mode 100644 index 00000000000..8a8fe23230a --- /dev/null +++ b/spec/frontend/issues/show/components/edited_spec.js @@ -0,0 +1,49 @@ +import Vue from 'vue'; +import edited from '~/issues/show/components/edited.vue'; + +function formatText(text) { + return text.trim().replace(/\s\s+/g, ' '); +} + +describe('edited', () => { + const EditedComponent = Vue.extend(edited); + + it('should render an edited at+by string', () => { + const editedComponent = new EditedComponent({ + propsData: { + updatedAt: '2017-05-15T12:31:04.428Z', + updatedByName: 'Some User', + updatedByPath: '/some_user', + }, + }).$mount(); + + expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited[\s\S]+?by Some User/); + expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/); + expect(editedComponent.$el.querySelector('time')).toBeTruthy(); + }); + + it('if no updatedAt is provided, no time element will be rendered', () => { + const editedComponent = new EditedComponent({ + propsData: { + updatedByName: 'Some User', + updatedByPath: '/some_user', + }, + }).$mount(); + + expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited by Some User/); + expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/); + expect(editedComponent.$el.querySelector('time')).toBeFalsy(); + }); + + it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => { + const editedComponent = new EditedComponent({ + propsData: { + updatedAt: '2017-05-15T12:31:04.428Z', + }, + }).$mount(); + + expect(formatText(editedComponent.$el.innerText)).not.toMatch(/by Some User/); + expect(editedComponent.$el.querySelector('.author-link')).toBeFalsy(); + expect(editedComponent.$el.querySelector('time')).toBeTruthy(); + }); +}); diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js new file mode 100644 index 00000000000..3043c4c3673 --- /dev/null +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -0,0 +1,70 @@ +import { shallowMount } from '@vue/test-utils'; +import DescriptionField from '~/issues/show/components/fields/description.vue'; +import eventHub from '~/issues/show/event_hub'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; + +describe('Description field component', () => { + let wrapper; + + const findTextarea = () => wrapper.find({ ref: 'textarea' }); + + const mountComponent = (description = 'test') => + shallowMount(DescriptionField, { + attachTo: document.body, + propsData: { + markdownPreviewPath: '/', + markdownDocsPath: '/', + formState: { + description, + }, + }, + stubs: { + MarkdownField, + }, + }); + + beforeEach(() => { + jest.spyOn(eventHub, '$emit'); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders markdown field with description', () => { + wrapper = mountComponent(); + + expect(findTextarea().element.value).toBe('test'); + }); + + it('renders markdown field with a markdown description', () => { + const markdown = '**test**'; + + wrapper = mountComponent(markdown); + + expect(findTextarea().element.value).toBe(markdown); + }); + + it('focuses field when mounted', () => { + wrapper = mountComponent(); + + expect(document.activeElement).toBe(findTextarea().element); + }); + + it('triggers update with meta+enter', () => { + wrapper = mountComponent(); + + findTextarea().trigger('keydown.enter', { metaKey: true }); + + expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + }); + + it('triggers update with ctrl+enter', () => { + wrapper = mountComponent(); + + findTextarea().trigger('keydown.enter', { ctrlKey: true }); + + expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + }); +}); diff --git a/spec/frontend/issues/show/components/fields/description_template_spec.js b/spec/frontend/issues/show/components/fields/description_template_spec.js new file mode 100644 index 00000000000..abe2805e5b2 --- /dev/null +++ b/spec/frontend/issues/show/components/fields/description_template_spec.js @@ -0,0 +1,74 @@ +import Vue from 'vue'; +import descriptionTemplate from '~/issues/show/components/fields/description_template.vue'; + +describe('Issue description template component with templates as hash', () => { + let vm; + let formState; + + beforeEach(() => { + const Component = Vue.extend(descriptionTemplate); + formState = { + description: 'test', + }; + + vm = new Component({ + propsData: { + formState, + issuableTemplates: { + test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + }, + projectId: 1, + projectPath: '/', + namespacePath: '/', + projectNamespace: '/', + }, + }).$mount(); + }); + + it('renders templates as JSON hash in data attribute', () => { + expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe( + '{"test":[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]}', + ); + }); + + it('updates formState when changing template', () => { + vm.issuableTemplate.editor.setValue('test new template'); + + expect(formState.description).toBe('test new template'); + }); + + it('returns formState description with editor getValue', () => { + formState.description = 'testing new template'; + + expect(vm.issuableTemplate.editor.getValue()).toBe('testing new template'); + }); +}); + +describe('Issue description template component with templates as array', () => { + let vm; + let formState; + + beforeEach(() => { + const Component = Vue.extend(descriptionTemplate); + formState = { + description: 'test', + }; + + vm = new Component({ + propsData: { + formState, + issuableTemplates: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + projectId: 1, + projectPath: '/', + namespacePath: '/', + projectNamespace: '/', + }, + }).$mount(); + }); + + it('renders templates as JSON array in data attribute', () => { + expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe( + '[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]', + ); + }); +}); diff --git a/spec/frontend/issues/show/components/fields/title_spec.js b/spec/frontend/issues/show/components/fields/title_spec.js new file mode 100644 index 00000000000..efd0b6fbd30 --- /dev/null +++ b/spec/frontend/issues/show/components/fields/title_spec.js @@ -0,0 +1,42 @@ +import { shallowMount } from '@vue/test-utils'; +import TitleField from '~/issues/show/components/fields/title.vue'; +import eventHub from '~/issues/show/event_hub'; + +describe('Title field component', () => { + let wrapper; + + const findInput = () => wrapper.find({ ref: 'input' }); + + beforeEach(() => { + jest.spyOn(eventHub, '$emit'); + + wrapper = shallowMount(TitleField, { + propsData: { + formState: { + title: 'test', + }, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders form control with formState title', () => { + expect(findInput().element.value).toBe('test'); + }); + + it('triggers update with meta+enter', () => { + findInput().trigger('keydown.enter', { metaKey: true }); + + expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + }); + + it('triggers update with ctrl+enter', () => { + findInput().trigger('keydown.enter', { ctrlKey: true }); + + expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + }); +}); diff --git a/spec/frontend/issues/show/components/fields/type_spec.js b/spec/frontend/issues/show/components/fields/type_spec.js new file mode 100644 index 00000000000..3ece10e70db --- /dev/null +++ b/spec/frontend/issues/show/components/fields/type_spec.js @@ -0,0 +1,120 @@ +import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import IssueTypeField, { i18n } from '~/issues/show/components/fields/type.vue'; +import { IssuableTypes } from '~/issues/show/constants'; +import { + getIssueStateQueryResponse, + updateIssueStateQueryResponse, +} from '../../mock_data/apollo_mock'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Issue type field component', () => { + let wrapper; + let fakeApollo; + let mockIssueStateData; + + const mockResolvers = { + Query: { + issueState() { + return { + __typename: 'IssueState', + rawData: mockIssueStateData(), + }; + }, + }, + Mutation: { + updateIssueState: jest.fn().mockResolvedValue(updateIssueStateQueryResponse), + }, + }; + + const findTypeFromGroup = () => wrapper.findComponent(GlFormGroup); + const findTypeFromDropDown = () => wrapper.findComponent(GlDropdown); + const findTypeFromDropDownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findTypeFromDropDownItemAt = (at) => findTypeFromDropDownItems().at(at); + const findTypeFromDropDownItemIconAt = (at) => + findTypeFromDropDownItems().at(at).findComponent(GlIcon); + + const createComponent = ({ data } = {}, provide) => { + fakeApollo = createMockApollo([], mockResolvers); + + wrapper = shallowMount(IssueTypeField, { + localVue, + apolloProvider: fakeApollo, + data() { + return { + issueState: {}, + ...data, + }; + }, + provide: { + canCreateIncident: true, + ...provide, + }, + }); + }; + + beforeEach(() => { + mockIssueStateData = jest.fn(); + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + at | text | icon + ${0} | ${IssuableTypes[0].text} | ${IssuableTypes[0].icon} + ${1} | ${IssuableTypes[1].text} | ${IssuableTypes[1].icon} + `(`renders the issue type $text with an icon in the dropdown`, ({ at, text, icon }) => { + expect(findTypeFromDropDownItemIconAt(at).attributes('name')).toBe(icon); + expect(findTypeFromDropDownItemAt(at).text()).toBe(text); + }); + + it('renders a form group with the correct label', () => { + expect(findTypeFromGroup().attributes('label')).toBe(i18n.label); + }); + + it('renders a form select with the `issue_type` value', () => { + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + }); + + describe('with Apollo cache mock', () => { + it('renders the selected issueType', async () => { + mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse); + await waitForPromises(); + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + }); + + it('updates the `issue_type` in the apollo cache when the value is changed', async () => { + findTypeFromDropDownItems().at(1).vm.$emit('click', IssuableTypes.incident); + await wrapper.vm.$nextTick(); + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident); + }); + + describe('when user is a guest', () => { + it('hides the incident type from the dropdown', async () => { + createComponent({}, { canCreateIncident: false, issueType: 'issue' }); + await waitForPromises(); + + expect(findTypeFromDropDownItemAt(0).isVisible()).toBe(true); + expect(findTypeFromDropDownItemAt(1).isVisible()).toBe(false); + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + }); + + it('and incident is selected, includes incident in the dropdown', async () => { + createComponent({}, { canCreateIncident: false, issueType: 'incident' }); + await waitForPromises(); + + expect(findTypeFromDropDownItemAt(0).isVisible()).toBe(true); + expect(findTypeFromDropDownItemAt(1).isVisible()).toBe(true); + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident); + }); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/form_spec.js b/spec/frontend/issues/show/components/form_spec.js new file mode 100644 index 00000000000..db49d2635ba --- /dev/null +++ b/spec/frontend/issues/show/components/form_spec.js @@ -0,0 +1,156 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Autosave from '~/autosave'; +import DescriptionTemplate from '~/issues/show/components/fields/description_template.vue'; +import IssueTypeField from '~/issues/show/components/fields/type.vue'; +import formComponent from '~/issues/show/components/form.vue'; +import LockedWarning from '~/issues/show/components/locked_warning.vue'; +import eventHub from '~/issues/show/event_hub'; + +jest.mock('~/autosave'); + +describe('Inline edit form component', () => { + let wrapper; + const defaultProps = { + canDestroy: true, + endpoint: 'gitlab-org/gitlab-test/-/issues/1', + formState: { + title: 'b', + description: 'a', + lockedWarningVisible: false, + }, + issuableType: 'issue', + markdownPreviewPath: '/', + markdownDocsPath: '/', + projectPath: '/', + projectId: 1, + projectNamespace: '/', + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const createComponent = (props) => { + wrapper = shallowMount(formComponent, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findDescriptionTemplate = () => wrapper.findComponent(DescriptionTemplate); + const findIssuableTypeField = () => wrapper.findComponent(IssueTypeField); + const findLockedWarning = () => wrapper.findComponent(LockedWarning); + const findAlert = () => wrapper.findComponent(GlAlert); + + it('does not render template selector if no templates exist', () => { + createComponent(); + + expect(findDescriptionTemplate().exists()).toBe(false); + }); + + it('renders template selector when templates as array exists', () => { + createComponent({ + issuableTemplates: [ + { name: 'test', id: 'test', project_path: 'test', namespace_path: 'test' }, + ], + }); + + expect(findDescriptionTemplate().exists()).toBe(true); + }); + + it('renders template selector when templates as hash exists', () => { + createComponent({ + issuableTemplates: { + test: [{ name: 'test', id: 'test', project_path: 'test', namespace_path: 'test' }], + }, + }); + + expect(findDescriptionTemplate().exists()).toBe(true); + }); + + it.each` + issuableType | value + ${'issue'} | ${true} + ${'epic'} | ${false} + `( + 'when `issue_type` is set to "$issuableType" rendering the type select will be "$value"', + ({ issuableType, value }) => { + createComponent({ + issuableType, + }); + + expect(findIssuableTypeField().exists()).toBe(value); + }, + ); + + it('hides locked warning by default', () => { + createComponent(); + + expect(findLockedWarning().exists()).toBe(false); + }); + + it('shows locked warning if formState is different', () => { + createComponent({ formState: { ...defaultProps.formState, lockedWarningVisible: true } }); + + expect(findLockedWarning().exists()).toBe(true); + }); + + it('hides locked warning when currently saving', () => { + createComponent({ + formState: { ...defaultProps.formState, updateLoading: true, lockedWarningVisible: true }, + }); + + expect(findLockedWarning().exists()).toBe(false); + }); + + describe('autosave', () => { + let spy; + + beforeEach(() => { + spy = jest.spyOn(Autosave.prototype, 'reset'); + }); + + it('initialized Autosave on mount', () => { + createComponent(); + + expect(Autosave).toHaveBeenCalledTimes(2); + }); + + it('calls reset on autosave when eventHub emits appropriate events', () => { + createComponent(); + + eventHub.$emit('close.form'); + + expect(spy).toHaveBeenCalledTimes(2); + + eventHub.$emit('delete.issuable'); + + expect(spy).toHaveBeenCalledTimes(4); + + eventHub.$emit('update.issuable'); + + expect(spy).toHaveBeenCalledTimes(6); + }); + + describe('outdated description', () => { + it('does not show warning if lock version from server is the same as the local lock version', () => { + createComponent(); + expect(findAlert().exists()).toBe(false); + }); + + it('shows warning if lock version from server differs than the local lock version', async () => { + Autosave.prototype.getSavedLockVersion.mockResolvedValue('lock version from local storage'); + + createComponent({ + formState: { ...defaultProps.formState, lock_version: 'lock version from server' }, + }); + + await wrapper.vm.$nextTick(); + expect(findAlert().exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js new file mode 100644 index 00000000000..2a16c699c4d --- /dev/null +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -0,0 +1,382 @@ +import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui'; +import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { mockTracking } from 'helpers/tracking_helper'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import { IssuableType } from '~/vue_shared/issuable/show/constants'; +import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; +import HeaderActions from '~/issues/show/components/header_actions.vue'; +import { IssuableStatus } from '~/issues/constants'; +import { IssueStateEvent } from '~/issues/show/constants'; +import promoteToEpicMutation from '~/issues/show/queries/promote_to_epic.mutation.graphql'; +import * as urlUtility from '~/lib/utils/url_utility'; +import eventHub from '~/notes/event_hub'; +import createStore from '~/notes/stores'; + +jest.mock('~/flash'); + +describe('HeaderActions component', () => { + let dispatchEventSpy; + let mutateMock; + let wrapper; + let visitUrlSpy; + + Vue.use(Vuex); + + const store = createStore(); + + const defaultProps = { + canCreateIssue: true, + canDestroyIssue: true, + canPromoteToEpic: true, + canReopenIssue: true, + canReportSpam: true, + canUpdateIssue: true, + iid: '32', + isIssueAuthor: true, + issuePath: 'gitlab-org/gitlab-test/-/issues/1', + issueType: IssuableType.Issue, + newIssuePath: 'gitlab-org/gitlab-test/-/issues/new', + projectPath: 'gitlab-org/gitlab-test', + reportAbusePath: + '-/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%2Fgitlab-org%2Fgitlab-test%2F-%2Fissues%2F32&user_id=1', + submitAsSpamPath: 'gitlab-org/gitlab-test/-/issues/32/submit_as_spam', + }; + + const updateIssueMutationResponse = { data: { updateIssue: { errors: [] } } }; + + const promoteToEpicMutationResponse = { + data: { + promoteToEpic: { + errors: [], + epic: { + webPath: '/groups/gitlab-org/-/epics/1', + }, + }, + }, + }; + + const promoteToEpicMutationErrorResponse = { + data: { + promoteToEpic: { + errors: ['The issue has already been promoted to an epic.'], + epic: {}, + }, + }, + }; + + const findToggleIssueStateButton = () => wrapper.findComponent(GlButton); + const findDropdownAt = (index) => wrapper.findAllComponents(GlDropdown).at(index); + const findMobileDropdownItems = () => findDropdownAt(0).findAllComponents(GlDropdownItem); + const findDesktopDropdownItems = () => findDropdownAt(1).findAllComponents(GlDropdownItem); + const findModal = () => wrapper.findComponent(GlModal); + const findModalLinkAt = (index) => findModal().findAllComponents(GlLink).at(index); + + const mountComponent = ({ + props = {}, + issueState = IssuableStatus.Open, + blockedByIssues = [], + mutateResponse = {}, + } = {}) => { + mutateMock = jest.fn().mockResolvedValue(mutateResponse); + + store.dispatch('setNoteableData', { + blocked_by_issues: blockedByIssues, + state: issueState, + }); + + return shallowMount(HeaderActions, { + store, + provide: { + ...defaultProps, + ...props, + }, + mocks: { + $apollo: { + mutate: mutateMock, + }, + }, + }); + }; + + afterEach(() => { + if (dispatchEventSpy) { + dispatchEventSpy.mockRestore(); + } + if (visitUrlSpy) { + visitUrlSpy.mockRestore(); + } + wrapper.destroy(); + }); + + describe.each` + issueType + ${IssuableType.Issue} + ${IssuableType.Incident} + `('when issue type is $issueType', ({ issueType }) => { + describe('close/reopen button', () => { + describe.each` + description | issueState | buttonText | newIssueState + ${`when the ${issueType} is open`} | ${IssuableStatus.Open} | ${`Close ${issueType}`} | ${IssueStateEvent.Close} + ${`when the ${issueType} is closed`} | ${IssuableStatus.Closed} | ${`Reopen ${issueType}`} | ${IssueStateEvent.Reopen} + `('$description', ({ issueState, buttonText, newIssueState }) => { + beforeEach(() => { + dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); + + wrapper = mountComponent({ + props: { issueType }, + issueState, + mutateResponse: updateIssueMutationResponse, + }); + }); + + it(`has text "${buttonText}"`, () => { + expect(findToggleIssueStateButton().text()).toBe(buttonText); + }); + + it('calls apollo mutation', () => { + findToggleIssueStateButton().vm.$emit('click'); + + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + iid: defaultProps.iid, + projectPath: defaultProps.projectPath, + stateEvent: newIssueState, + }, + }, + }), + ); + }); + + it('dispatches a custom event to update the issue page', async () => { + findToggleIssueStateButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe.each` + description | isCloseIssueItemVisible | findDropdownItems + ${'mobile dropdown'} | ${true} | ${findMobileDropdownItems} + ${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems} + `('$description', ({ isCloseIssueItemVisible, findDropdownItems }) => { + 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 ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot create ${issueType}`} | ${`New ${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, + }) => { + beforeEach(() => { + wrapper = mountComponent({ + props: { + canUpdateIssue, + canCreateIssue, + isIssueAuthor, + issueType, + canReportSpam, + canPromoteToEpic, + canDestroyIssue, + }, + }); + }); + + it(`${isItemVisible ? 'shows' : 'hides'} "${itemText}" item`, () => { + expect( + findDropdownItems() + .filter((item) => item.text() === itemText) + .exists(), + ).toBe(isItemVisible); + }); + }, + ); + }); + }); + + describe('delete issue button', () => { + let trackingSpy; + + beforeEach(() => { + wrapper = mountComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + it('tracks clicking on button', () => { + findDesktopDropdownItems().at(3).vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_dropdown', { + label: 'delete_issue', + }); + }); + }); + + describe('when "Promote to epic" button is clicked', () => { + describe('when response is successful', () => { + beforeEach(() => { + visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({}); + + wrapper = mountComponent({ + mutateResponse: promoteToEpicMutationResponse, + }); + + wrapper.find('[data-testid="promote-button"]').vm.$emit('click'); + }); + + it('invokes GraphQL mutation when clicked', () => { + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + mutation: promoteToEpicMutation, + variables: { + input: { + iid: defaultProps.iid, + projectPath: defaultProps.projectPath, + }, + }, + }), + ); + }); + + it('shows a success message and tells the user they are being redirected', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'The issue was successfully promoted to an epic. Redirecting to epic...', + type: FLASH_TYPES.SUCCESS, + }); + }); + + it('redirects to newly created epic path', () => { + expect(visitUrlSpy).toHaveBeenCalledWith( + promoteToEpicMutationResponse.data.promoteToEpic.epic.webPath, + ); + }); + }); + + describe('when response contains errors', () => { + beforeEach(() => { + visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({}); + + wrapper = mountComponent({ + mutateResponse: promoteToEpicMutationErrorResponse, + }); + + wrapper.find('[data-testid="promote-button"]').vm.$emit('click'); + }); + + it('shows an error message', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: HeaderActions.i18n.promoteErrorMessage, + }); + }); + }); + }); + + describe('when `toggle.issuable.state` event is emitted', () => { + it('invokes a method to toggle the issue state', () => { + wrapper = mountComponent({ mutateResponse: updateIssueMutationResponse }); + + eventHub.$emit('toggle.issuable.state'); + + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + iid: defaultProps.iid, + projectPath: defaultProps.projectPath, + stateEvent: IssueStateEvent.Close, + }, + }, + }), + ); + }); + }); + + describe('blocked by issues modal', () => { + const blockedByIssues = [ + { iid: 13, web_url: 'gitlab-org/gitlab-test/-/issues/13' }, + { iid: 79, web_url: 'gitlab-org/gitlab-test/-/issues/79' }, + ]; + + beforeEach(() => { + wrapper = mountComponent({ blockedByIssues }); + }); + + it('has title text', () => { + expect(findModal().attributes('title')).toBe( + 'Are you sure you want to close this blocked issue?', + ); + }); + + it('has body text', () => { + expect(findModal().text()).toContain( + 'This issue is currently blocked by the following issues:', + ); + }); + + it('calls apollo mutation when primary button is clicked', () => { + findModal().vm.$emit('primary'); + + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + iid: defaultProps.iid.toString(), + projectPath: defaultProps.projectPath, + stateEvent: IssueStateEvent.Close, + }, + }, + }), + ); + }); + + describe.each` + ordinal | index + ${'first'} | ${0} + ${'second'} | ${1} + `('$ordinal blocked-by issue link', ({ index }) => { + it('has link text', () => { + expect(findModalLinkAt(index).text()).toBe(`#${blockedByIssues[index].iid}`); + }); + + it('has url', () => { + expect(findModalLinkAt(index).attributes('href')).toBe(blockedByIssues[index].web_url); + }); + }); + }); + + describe('delete issue modal', () => { + it('renders', () => { + wrapper = mountComponent(); + + expect(wrapper.findComponent(DeleteIssueModal).props()).toEqual({ + issuePath: defaultProps.issuePath, + issueType: defaultProps.issueType, + modalId: HeaderActions.deleteModalId, + title: 'Delete issue', + }); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js b/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js new file mode 100644 index 00000000000..a4910d63bb5 --- /dev/null +++ b/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js @@ -0,0 +1,94 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import merge from 'lodash/merge'; +import HighlightBar from '~/issues/show/components/incidents/highlight_bar.vue'; +import { formatDate } from '~/lib/utils/datetime_utility'; + +jest.mock('~/lib/utils/datetime_utility'); + +describe('Highlight Bar', () => { + let wrapper; + + const alert = { + iid: 1, + startedAt: '2020-05-29T10:39:22Z', + detailsUrl: 'http://127.0.0.1:3000/root/unique-alerts/-/alert_management/1/details', + eventCount: 1, + title: 'Alert 1', + }; + + const mountComponent = (options) => { + wrapper = shallowMount( + HighlightBar, + merge( + { + propsData: { alert }, + provide: { fullPath: 'test', iid: 1, slaFeatureAvailable: true }, + }, + options, + ), + ); + }; + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findLink = () => wrapper.find(GlLink); + + describe('empty state', () => { + beforeEach(() => { + mountComponent({ propsData: { alert: null } }); + }); + + it('renders a empty component', () => { + expect(wrapper.isVisible()).toBe(false); + }); + }); + + describe('alert present', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders a link to the alert page', () => { + expect(findLink().exists()).toBe(true); + expect(findLink().attributes('href')).toBe(alert.detailsUrl); + expect(findLink().attributes('title')).toBe(alert.title); + expect(findLink().text()).toBe(`#${alert.iid}`); + }); + + it('renders formatted start time of the alert', () => { + const formattedDate = '2020-05-29 UTC'; + formatDate.mockReturnValueOnce(formattedDate); + mountComponent(); + expect(formatDate).toHaveBeenCalledWith(alert.startedAt, 'yyyy-mm-dd Z'); + expect(wrapper.text()).toContain(formattedDate); + }); + + it('renders a number of alert events', () => { + expect(wrapper.text()).toContain(alert.eventCount); + }); + }); + + describe('when child data is present', () => { + beforeEach(() => { + mountComponent({ + data() { + return { hasChildData: true }; + }, + }); + }); + + it('renders the highlight bar component', () => { + expect(wrapper.isVisible()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js new file mode 100644 index 00000000000..9bf0e106194 --- /dev/null +++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js @@ -0,0 +1,143 @@ +import { GlTab } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import merge from 'lodash/merge'; +import waitForPromises from 'helpers/wait_for_promises'; +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 INVALID_URL from '~/lib/utils/invalid_url'; +import Tracking from '~/tracking'; +import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import { descriptionProps } from '../../mock_data/mock_data'; + +const mockAlert = { + __typename: 'AlertManagementAlert', + detailsUrl: INVALID_URL, + iid: '1', +}; + +describe('Incident Tabs component', () => { + let wrapper; + + const mountComponent = (data = {}, options = {}) => { + wrapper = shallowMount( + IncidentTabs, + merge( + { + propsData: { + ...descriptionProps, + }, + stubs: { + DescriptionComponent: true, + MetricsTab: true, + }, + provide: { + fullPath: '', + iid: '', + uploadMetricsFeatureAvailable: true, + }, + data() { + return { alert: mockAlert, ...data }; + }, + mocks: { + $apollo: { + queries: { + alert: { + loading: true, + }, + }, + }, + }, + }, + options, + ), + ); + }; + + const findTabs = () => wrapper.findAll(GlTab); + const findSummaryTab = () => findTabs().at(0); + const findMetricsTab = () => wrapper.find('[data-testid="metrics-tab"]'); + const findAlertDetailsTab = () => wrapper.find('[data-testid="alert-details-tab"]'); + const findAlertDetailsComponent = () => wrapper.find(AlertDetailsTable); + const findDescriptionComponent = () => wrapper.find(DescriptionComponent); + const findHighlightBarComponent = () => wrapper.find(HighlightBar); + + describe('empty state', () => { + beforeEach(() => { + mountComponent({ alert: null }); + }); + + it('does not show the alert details tab', () => { + expect(findAlertDetailsComponent().exists()).toBe(false); + }); + }); + + describe('with an alert present', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders the summary tab', () => { + expect(findSummaryTab().exists()).toBe(true); + expect(findSummaryTab().attributes('title')).toBe('Summary'); + }); + + it('renders the alert details tab', () => { + expect(findAlertDetailsTab().exists()).toBe(true); + expect(findAlertDetailsTab().attributes('title')).toBe('Alert details'); + }); + + it('renders the alert details table with the correct props', () => { + const alert = { iid: mockAlert.iid }; + + expect(findAlertDetailsComponent().props('alert')).toMatchObject(alert); + expect(findAlertDetailsComponent().props('loading')).toBe(true); + }); + + it('renders the description component with highlight bar', () => { + expect(findDescriptionComponent().exists()).toBe(true); + expect(findHighlightBarComponent().exists()).toBe(true); + }); + + it('renders the highlight bar component with the correct props', () => { + const alert = { detailsUrl: mockAlert.detailsUrl }; + + expect(findHighlightBarComponent().props('alert')).toMatchObject(alert); + }); + + it('passes all props to the description component', () => { + expect(findDescriptionComponent().props()).toMatchObject(descriptionProps); + }); + }); + + describe('upload metrics feature available', () => { + it('shows the metric tab when metrics are available', async () => { + mountComponent({}, { provide: { uploadMetricsFeatureAvailable: true } }); + + await waitForPromises(); + + expect(findMetricsTab().exists()).toBe(true); + }); + + it('hides the tab when metrics are not available', async () => { + mountComponent({}, { provide: { uploadMetricsFeatureAvailable: false } }); + + await waitForPromises(); + + expect(findMetricsTab().exists()).toBe(false); + }); + }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent(); + }); + + it('should track incident details views', () => { + const { category, action } = trackIncidentDetailsViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/pinned_links_spec.js b/spec/frontend/issues/show/components/pinned_links_spec.js new file mode 100644 index 00000000000..aac720df6e9 --- /dev/null +++ b/spec/frontend/issues/show/components/pinned_links_spec.js @@ -0,0 +1,48 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PinnedLinks from '~/issues/show/components/pinned_links.vue'; +import { STATUS_PAGE_PUBLISHED, JOIN_ZOOM_MEETING } from '~/issues/show/constants'; + +const plainZoomUrl = 'https://zoom.us/j/123456789'; +const plainStatusUrl = 'https://status.com'; + +describe('PinnedLinks', () => { + let wrapper; + + const findButtons = () => wrapper.findAll(GlButton); + + const createComponent = (props) => { + wrapper = shallowMount(PinnedLinks, { + propsData: { + zoomMeetingUrl: '', + publishedIncidentUrl: '', + ...props, + }, + }); + }; + + it('displays Zoom link', () => { + createComponent({ + zoomMeetingUrl: `<a href="${plainZoomUrl}">Zoom</a>`, + }); + + expect(findButtons().at(0).text()).toBe(JOIN_ZOOM_MEETING); + }); + + it('displays Status link', () => { + createComponent({ + publishedIncidentUrl: `<a href="${plainStatusUrl}">Status</a>`, + }); + + expect(findButtons().at(0).text()).toBe(STATUS_PAGE_PUBLISHED); + }); + + it('does not render if there are no links', () => { + createComponent({ + zoomMeetingUrl: '', + publishedIncidentUrl: '', + }); + + expect(findButtons()).toHaveLength(0); + }); +}); diff --git a/spec/frontend/issues/show/components/title_spec.js b/spec/frontend/issues/show/components/title_spec.js new file mode 100644 index 00000000000..f9026557be2 --- /dev/null +++ b/spec/frontend/issues/show/components/title_spec.js @@ -0,0 +1,95 @@ +import Vue from 'vue'; +import titleComponent from '~/issues/show/components/title.vue'; +import eventHub from '~/issues/show/event_hub'; +import Store from '~/issues/show/stores'; + +describe('Title component', () => { + let vm; + beforeEach(() => { + setFixtures(`<title />`); + + const Component = Vue.extend(titleComponent); + const store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + vm = new Component({ + propsData: { + issuableRef: '#1', + titleHtml: 'Testing <img />', + titleText: 'Testing', + showForm: false, + formState: store.formState, + }, + }).$mount(); + }); + + it('renders title HTML', () => { + expect(vm.$el.querySelector('.title').innerHTML.trim()).toBe('Testing <img>'); + }); + + it('updates page title when changing titleHtml', () => { + const spy = jest.spyOn(vm, 'setPageTitle'); + vm.titleHtml = 'test'; + + return vm.$nextTick().then(() => { + expect(spy).toHaveBeenCalled(); + }); + }); + + it('animates title changes', () => { + vm.titleHtml = 'test'; + return vm + .$nextTick() + .then(() => { + expect(vm.$el.querySelector('.title').classList).toContain('issue-realtime-pre-pulse'); + jest.runAllTimers(); + return vm.$nextTick(); + }) + .then(() => { + expect(vm.$el.querySelector('.title').classList).toContain('issue-realtime-trigger-pulse'); + }); + }); + + it('updates page title after changing title', () => { + vm.titleHtml = 'changed'; + vm.titleText = 'changed'; + + return vm.$nextTick().then(() => { + expect(document.querySelector('title').textContent.trim()).toContain('changed'); + }); + }); + + describe('inline edit button', () => { + it('should not show by default', () => { + expect(vm.$el.querySelector('.btn-edit')).toBeNull(); + }); + + it('should not show if canUpdate is false', () => { + vm.showInlineEditButton = true; + vm.canUpdate = false; + + expect(vm.$el.querySelector('.btn-edit')).toBeNull(); + }); + + it('should show if showInlineEditButton and canUpdate', () => { + vm.showInlineEditButton = true; + vm.canUpdate = true; + + expect(vm.$el.querySelector('.btn-edit')).toBeDefined(); + }); + + it('should trigger open.form event when clicked', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + vm.showInlineEditButton = true; + vm.canUpdate = true; + + Vue.nextTick(() => { + vm.$el.querySelector('.btn-edit').click(); + + expect(eventHub.$emit).toHaveBeenCalledWith('open.form'); + }); + }); + }); +}); diff --git a/spec/frontend/issues/show/issue_spec.js b/spec/frontend/issues/show/issue_spec.js new file mode 100644 index 00000000000..6d7a31a6c8c --- /dev/null +++ b/spec/frontend/issues/show/issue_spec.js @@ -0,0 +1,40 @@ +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { initIssuableApp } from '~/issues/show/issue'; +import * as parseData from '~/issues/show/utils/parse_data'; +import axios from '~/lib/utils/axios_utils'; +import createStore from '~/notes/stores'; +import { appProps } from './mock_data/mock_data'; + +const mock = new MockAdapter(axios); +mock.onGet().reply(200); + +jest.mock('~/lib/utils/poll'); + +const setupHTML = (initialData) => { + document.body.innerHTML = `<div id="js-issuable-app"></div>`; + document.getElementById('js-issuable-app').dataset.initial = JSON.stringify(initialData); +}; + +describe('Issue show index', () => { + describe('initIssuableApp', () => { + it('should initialize app with no potential XSS attack', async () => { + const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); + const parseDataSpy = jest.spyOn(parseData, 'parseIssuableData'); + + setupHTML({ + ...appProps, + initialDescriptionHtml: '<svg onload=window.alert(1)>', + }); + + const initialDataEl = document.getElementById('js-issuable-app'); + const issuableData = parseData.parseIssuableData(initialDataEl); + initIssuableApp(issuableData, createStore()); + + await waitForPromises(); + + expect(parseDataSpy).toHaveBeenCalled(); + expect(alertSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/issues/show/mock_data/apollo_mock.js b/spec/frontend/issues/show/mock_data/apollo_mock.js new file mode 100644 index 00000000000..bfd31e74393 --- /dev/null +++ b/spec/frontend/issues/show/mock_data/apollo_mock.js @@ -0,0 +1,9 @@ +export const getIssueStateQueryResponse = { + issueType: 'issue', + isDirty: false, +}; + +export const updateIssueStateQueryResponse = { + issueType: 'incident', + isDirty: true, +}; diff --git a/spec/frontend/issues/show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js new file mode 100644 index 00000000000..a73826954c3 --- /dev/null +++ b/spec/frontend/issues/show/mock_data/mock_data.js @@ -0,0 +1,60 @@ +import { TEST_HOST } from 'helpers/test_constants'; + +export const initialRequest = { + title: '<p>this is a title</p>', + title_text: 'this is a title', + description: '<p>this is a description!</p>', + description_text: 'this is a description', + task_status: '2 of 4 completed', + updated_at: '2015-05-15T12:31:04.428Z', + updated_by_name: 'Some User', + updated_by_path: '/some_user', + lock_version: 1, +}; + +export const secondRequest = { + title: '<p>2</p>', + title_text: '2', + description: '<p>42</p>', + description_text: '42', + task_status: '0 of 0 completed', + updated_at: '2016-05-15T12:31:04.428Z', + updated_by_name: 'Other User', + updated_by_path: '/other_user', + lock_version: 2, +}; + +export const descriptionProps = { + canUpdate: true, + descriptionHtml: 'test', + descriptionText: 'test', + taskStatus: '', + updateUrl: TEST_HOST, +}; + +export const publishedIncidentUrl = 'https://status.com/'; + +export const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811'; + +export const appProps = { + canUpdate: true, + canDestroy: true, + endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes', + updateEndpoint: TEST_HOST, + issuableRef: '#1', + issuableStatus: 'opened', + initialTitleHtml: '', + initialTitleText: '', + initialDescriptionHtml: 'test', + initialDescriptionText: 'test', + lockVersion: 1, + issueType: 'issue', + markdownPreviewPath: '/', + markdownDocsPath: '/', + projectNamespace: '/', + projectPath: '/', + projectId: 1, + issuableTemplateNamesPath: '/issuable-templates-path', + zoomMeetingUrl, + publishedIncidentUrl, +}; diff --git a/spec/frontend/issues/show/store_spec.js b/spec/frontend/issues/show/store_spec.js new file mode 100644 index 00000000000..20d3a6cdaae --- /dev/null +++ b/spec/frontend/issues/show/store_spec.js @@ -0,0 +1,39 @@ +import Store from '~/issues/show/stores'; +import updateDescription from '~/issues/show/utils/update_description'; + +jest.mock('~/issues/show/utils/update_description'); + +describe('Store', () => { + let store; + + beforeEach(() => { + store = new Store({ + descriptionHtml: '<p>This is a description</p>', + }); + }); + + describe('updateState', () => { + beforeEach(() => { + document.body.innerHTML = ` + <div class="detail-page-description content-block"> + <details open> + <summary>One</summary> + </details> + <details> + <summary>Two</summary> + </details> + </div> + `; + }); + + afterEach(() => { + document.getElementsByTagName('html')[0].innerHTML = ''; + }); + + it('calls updateDetailsState', () => { + store.updateState({ description: '' }); + + expect(updateDescription).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/spec/frontend/issues/show/utils/update_description_spec.js b/spec/frontend/issues/show/utils/update_description_spec.js new file mode 100644 index 00000000000..f4afef8af12 --- /dev/null +++ b/spec/frontend/issues/show/utils/update_description_spec.js @@ -0,0 +1,24 @@ +import updateDescription from '~/issues/show/utils/update_description'; + +describe('updateDescription', () => { + it('returns the correct value to be set as descriptionHtml', () => { + const actual = updateDescription( + '<details><summary>One</summary></details><details><summary>Two</summary></details>', + [{ open: true }, { open: false }], // mocking NodeList from the dom. + ); + + expect(actual).toEqual( + '<details open="true"><summary>One</summary></details><details><summary>Two</summary></details>', + ); + }); + + describe('when description details returned from api is different then whats currently on the dom', () => { + it('returns the description from the api', () => { + const dataDescription = '<details><summary>One</summary></details>'; + + const actual = updateDescription(dataDescription, []); + + expect(actual).toEqual(dataDescription); + }); + }); +}); |