diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-20 13:37:47 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-20 13:37:47 +0000 |
commit | aee0a117a889461ce8ced6fcf73207fe017f1d99 (patch) | |
tree | 891d9ef189227a8445d83f35c1b0fc99573f4380 /spec/frontend/issues/show/components | |
parent | 8d46af3258650d305f53b819eabf7ab18d22f59e (diff) | |
download | gitlab-ce-aee0a117a889461ce8ced6fcf73207fe017f1d99.tar.gz |
Add latest changes from gitlab-org/gitlab@14-6-stable-eev14.6.0-rc42
Diffstat (limited to 'spec/frontend/issues/show/components')
15 files changed, 2394 insertions, 0 deletions
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'); + }); + }); + }); +}); |