summaryrefslogtreecommitdiff
path: root/spec/frontend/issues
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/issues')
-rw-r--r--spec/frontend/issues/issue_spec.js91
-rw-r--r--spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap52
-rw-r--r--spec/frontend/issues/new/components/title_suggestions_item_spec.js132
-rw-r--r--spec/frontend/issues/new/components/title_suggestions_spec.js100
-rw-r--r--spec/frontend/issues/new/components/type_popover_spec.js20
-rw-r--r--spec/frontend/issues/new/mock_data.js28
-rw-r--r--spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js86
-rw-r--r--spec/frontend/issues/related_merge_requests/store/actions_spec.js113
-rw-r--r--spec/frontend/issues/related_merge_requests/store/mutations_spec.js49
-rw-r--r--spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js82
-rw-r--r--spec/frontend/issues/show/components/app_spec.js645
-rw-r--r--spec/frontend/issues/show/components/delete_issue_modal_spec.js108
-rw-r--r--spec/frontend/issues/show/components/description_spec.js187
-rw-r--r--spec/frontend/issues/show/components/edit_actions_spec.js181
-rw-r--r--spec/frontend/issues/show/components/edited_spec.js49
-rw-r--r--spec/frontend/issues/show/components/fields/description_spec.js70
-rw-r--r--spec/frontend/issues/show/components/fields/description_template_spec.js74
-rw-r--r--spec/frontend/issues/show/components/fields/title_spec.js42
-rw-r--r--spec/frontend/issues/show/components/fields/type_spec.js120
-rw-r--r--spec/frontend/issues/show/components/form_spec.js156
-rw-r--r--spec/frontend/issues/show/components/header_actions_spec.js382
-rw-r--r--spec/frontend/issues/show/components/incidents/highlight_bar_spec.js94
-rw-r--r--spec/frontend/issues/show/components/incidents/incident_tabs_spec.js143
-rw-r--r--spec/frontend/issues/show/components/pinned_links_spec.js48
-rw-r--r--spec/frontend/issues/show/components/title_spec.js95
-rw-r--r--spec/frontend/issues/show/issue_spec.js40
-rw-r--r--spec/frontend/issues/show/mock_data/apollo_mock.js9
-rw-r--r--spec/frontend/issues/show/mock_data/mock_data.js60
-rw-r--r--spec/frontend/issues/show/store_spec.js39
-rw-r--r--spec/frontend/issues/show/utils/update_description_spec.js24
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);
+ });
+ });
+});