diff options
Diffstat (limited to 'spec/frontend/work_items/components')
18 files changed, 862 insertions, 572 deletions
diff --git a/spec/frontend/work_items/components/notes/system_note_spec.js b/spec/frontend/work_items/components/notes/system_note_spec.js index fd5f373d076..03f1aa356ad 100644 --- a/spec/frontend/work_items/components/notes/system_note_spec.js +++ b/spec/frontend/work_items/components/notes/system_note_spec.js @@ -1,54 +1,32 @@ import { GlIcon } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; import { shallowMount } from '@vue/test-utils'; -import waitForPromises from 'helpers/wait_for_promises'; -import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import MockAdapter from 'axios-mock-adapter'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import WorkItemSystemNote from '~/work_items/components/notes/system_note.vue'; -import NoteHeader from '~/notes/components/note_header.vue'; +import { workItemSystemNoteWithMetadata } from 'jest/work_items/mock_data'; import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; jest.mock('~/behaviors/markdown/render_gfm'); -describe('system note component', () => { +describe('Work Items system note component', () => { let wrapper; - let props; let mock; - const findTimelineIcon = () => wrapper.findComponent(GlIcon); - const findSystemNoteMessage = () => wrapper.findComponent(NoteHeader); - const findOutdatedLineButton = () => - wrapper.findComponent('[data-testid="outdated-lines-change-btn"]'); - const findOutdatedLines = () => wrapper.findComponent('[data-testid="outdated-lines"]'); + const createComponent = ({ note = workItemSystemNoteWithMetadata } = {}) => { + mock = new MockAdapter(axios); - const createComponent = (propsData = {}) => { wrapper = shallowMount(WorkItemSystemNote, { - propsData, - slots: { - 'extra-controls': - '<gl-button data-testid="outdated-lines-change-btn">Compare with last version</gl-button>', + propsData: { + note, }, }); }; - beforeEach(() => { - props = { - note: { - id: '1424', - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatarUrl: 'path', - path: '/root', - }, - bodyHtml: '<p dir="auto">closed</p>', - systemNoteIconName: 'status_closed', - createdAt: '2017-08-02T10:51:58.559Z', - }, - }; + const findTimelineIcon = () => wrapper.findComponent(GlIcon); + const findComparePreviousVersionButton = () => wrapper.find('[data-testid="compare-btn"]'); + beforeEach(() => { + createComponent(); mock = new MockAdapter(axios); }); @@ -57,56 +35,16 @@ describe('system note component', () => { }); it('should render a list item with correct id', () => { - createComponent(props); - - expect(wrapper.attributes('id')).toBe(`note_${props.note.id}`); - }); - - // Note: The test case below is to handle a use case related to vuex store but since this does not - // have a vuex store , disabling it now will be fixing it in the next iteration - // eslint-disable-next-line jest/no-disabled-tests - it.skip('should render target class is note is target note', () => { - createComponent(props); - - expect(wrapper.classes()).toContain('target'); + expect(wrapper.attributes('id')).toBe( + `note_${getIdFromGraphQLId(workItemSystemNoteWithMetadata.id)}`, + ); }); it('should render svg icon', () => { - createComponent(props); - expect(findTimelineIcon().exists()).toBe(true); }); - // Redcarpet Markdown renderer wraps text in `<p>` tags - // we need to strip them because they break layout of commit lists in system notes: - // https://gitlab.com/gitlab-org/gitlab-foss/uploads/b07a10670919254f0220d3ff5c1aa110/jqzI.png - it('removes wrapping paragraph from note HTML', () => { - createComponent(props); - - expect(findSystemNoteMessage().html()).toContain('<span>closed</span>'); - }); - - it('should renderGFM onMount', () => { - createComponent(props); - - expect(renderGFM).toHaveBeenCalled(); - }); - - // eslint-disable-next-line jest/no-disabled-tests - it.skip('renders outdated code lines', async () => { - mock - .onGet('/outdated_line_change_path') - .reply(HTTP_STATUS_OK, [ - { rich_text: 'console.log', type: 'new', line_code: '123', old_line: null, new_line: 1 }, - ]); - - createComponent({ - note: { ...props.note, outdated_line_change_path: '/outdated_line_change_path' }, - }); - - await findOutdatedLineButton().vm.$emit('click'); - await waitForPromises(); - - expect(findOutdatedLines().exists()).toBe(true); + it('should not show compare previous version for FOSS', () => { + expect(findComparePreviousVersionButton().exists()).toBe(false); }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js index 739340f4936..e6d20dcb0d9 100644 --- a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js @@ -32,15 +32,18 @@ describe('Work item add note', () => { const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm); const findTextarea = () => wrapper.findByTestId('note-reply-textarea'); + const findWorkItemLockedComponent = () => wrapper.findComponent(WorkItemCommentLocked); const createComponent = async ({ mutationHandler = mutationSuccessHandler, canUpdate = true, + canCreateNote = true, workItemIid = '1', - workItemResponse = workItemByIidResponseFactory({ canUpdate }), + workItemResponse = workItemByIidResponseFactory({ canUpdate, canCreateNote }), signedIn = true, isEditing = true, workItemType = 'Task', + isInternalThread = false, } = {}) => { workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); if (signedIn) { @@ -65,6 +68,7 @@ describe('Work item add note', () => { workItemType, markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem', autocompleteDataSources: {}, + isInternalThread, }, stubs: { WorkItemCommentLocked, @@ -79,142 +83,170 @@ describe('Work item add note', () => { }; describe('adding a comment', () => { - it('calls update widgets mutation', async () => { - const noteText = 'updated desc'; - - await createComponent({ - isEditing: true, - signedIn: true, + describe.each` + isInternalComment + ${false} + ${true} + `('when internal comment is $isInternalComment', ({ isInternalComment }) => { + it('calls update widgets mutation', async () => { + const noteText = 'updated desc'; + + await createComponent({ + isEditing: true, + signedIn: true, + }); + + findCommentForm().vm.$emit('submitForm', { + commentText: noteText, + isNoteInternal: isInternalComment, + }); + + await waitForPromises(); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + noteableId: workItemId, + body: noteText, + discussionId: null, + internal: isInternalComment, + }, + }); }); - findCommentForm().vm.$emit('submitForm', noteText); + it('tracks adding comment', async () => { + await createComponent(); + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - await waitForPromises(); + findCommentForm().vm.$emit('submitForm', { + commentText: 'test', + isNoteInternal: isInternalComment, + }); - expect(mutationSuccessHandler).toHaveBeenCalledWith({ - input: { - noteableId: workItemId, - body: noteText, - discussionId: null, - }, - }); - }); + await waitForPromises(); - it('tracks adding comment', async () => { - await createComponent(); - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'add_work_item_comment', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_comment', + property: 'type_Task', + }); + }); - findCommentForm().vm.$emit('submitForm', 'test'); + it('emits `replied` event and hides form after successful mutation', async () => { + await createComponent({ isEditing: true, signedIn: true }); - await waitForPromises(); + findCommentForm().vm.$emit('submitForm', { + commentText: 'some text', + isNoteInternal: isInternalComment, + }); + await waitForPromises(); - expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'add_work_item_comment', { - category: TRACKING_CATEGORY_SHOW, - label: 'item_comment', - property: 'type_Task', + expect(wrapper.emitted('replied')).toEqual([[]]); }); - }); - - it('emits `replied` event and hides form after successful mutation', async () => { - await createComponent({ isEditing: true, signedIn: true }); - findCommentForm().vm.$emit('submitForm', 'some text'); - await waitForPromises(); + it('clears a draft after successful mutation', async () => { + await createComponent({ + isEditing: true, + signedIn: true, + }); - expect(wrapper.emitted('replied')).toEqual([[]]); - }); + findCommentForm().vm.$emit('submitForm', { + commentText: 'some text', + isNoteInternal: isInternalComment, + }); + await waitForPromises(); - it('clears a draft after successful mutation', async () => { - await createComponent({ - isEditing: true, - signedIn: true, + expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment'); }); - findCommentForm().vm.$emit('submitForm', 'some text'); - await waitForPromises(); - - expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment'); - }); + it('emits error when mutation returns error', async () => { + const error = 'eror'; - it('emits error when mutation returns error', async () => { - const error = 'eror'; - - await createComponent({ - isEditing: true, - mutationHandler: jest.fn().mockResolvedValue({ - data: { - createNote: { - note: { - id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', - discussion: { + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockResolvedValue({ + data: { + createNote: { + note: { id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', - notes: { - nodes: [], - __typename: 'NoteConnection', + discussion: { + id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', + notes: { + nodes: [], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', }, - __typename: 'Discussion', + __typename: 'Note', }, - __typename: 'Note', + __typename: 'CreateNotePayload', + errors: [error], }, - __typename: 'CreateNotePayload', - errors: [error], }, - }, - }), - }); + }), + }); - findCommentForm().vm.$emit('submitForm', 'updated desc'); + findCommentForm().vm.$emit('submitForm', { + commentText: 'updated desc', + isNoteInternal: isInternalComment, + }); - await waitForPromises(); + await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[error]]); - }); + expect(wrapper.emitted('error')).toEqual([[error]]); + }); - it('emits error when mutation fails', async () => { - const error = 'eror'; + it('emits error when mutation fails', async () => { + const error = 'eror'; - await createComponent({ - isEditing: true, - mutationHandler: jest.fn().mockRejectedValue(new Error(error)), - }); + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockRejectedValue(new Error(error)), + }); - findCommentForm().vm.$emit('submitForm', 'updated desc'); + findCommentForm().vm.$emit('submitForm', { + commentText: 'updated desc', + isNoteInternal: isInternalComment, + }); - await waitForPromises(); + await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[error]]); - }); + expect(wrapper.emitted('error')).toEqual([[error]]); + }); - it('ignores errors when mutation returns additional information as errors for quick actions', async () => { - await createComponent({ - isEditing: true, - mutationHandler: jest.fn().mockResolvedValue({ - data: { - createNote: { - note: { - id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', - discussion: { + it('ignores errors when mutation returns additional information as errors for quick actions', async () => { + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockResolvedValue({ + data: { + createNote: { + note: { id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', - notes: { - nodes: [], - __typename: 'NoteConnection', + discussion: { + id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', + notes: { + nodes: [], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', }, - __typename: 'Discussion', + __typename: 'Note', }, - __typename: 'Note', + __typename: 'CreateNotePayload', + errors: ['Commands only Removed assignee @foobar.', 'Command names ["unassign"]'], }, - __typename: 'CreateNotePayload', - errors: ['Commands only Removed assignee @foobar.', 'Command names ["unassign"]'], }, - }, - }), - }); + }), + }); - findCommentForm().vm.$emit('submitForm', 'updated desc'); + findCommentForm().vm.$emit('submitForm', { + commentText: 'updated desc', + isNoteInternal: isInternalComment, + }); - await waitForPromises(); + await waitForPromises(); - expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment'); + expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment'); + }); }); }); @@ -225,8 +257,23 @@ describe('Work item add note', () => { }); it('skips calling the work item query when missing workItemIid', async () => { - await createComponent({ workItemIid: null, isEditing: false }); + await createComponent({ workItemIid: '', isEditing: false }); expect(workItemResponseHandler).not.toHaveBeenCalled(); }); + + it('wrapper adds `internal-note` class when internal thread', async () => { + await createComponent({ isInternalThread: true }); + + expect(wrapper.attributes('class')).toContain('internal-note'); + }); + + describe('when work item`createNote` permission false', () => { + it('cannot add comment', async () => { + await createComponent({ isEditing: false, canCreateNote: false }); + + expect(findWorkItemLockedComponent().exists()).toBe(true); + expect(findCommentForm().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js index 147f2904761..6c00d52aac5 100644 --- a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js @@ -1,6 +1,8 @@ +import { GlFormCheckbox, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import { createMockDirective } from 'helpers/vue_mock_directive'; import waitForPromises from 'helpers/wait_for_promises'; import * as autosave from '~/lib/utils/autosave'; import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys'; @@ -40,6 +42,8 @@ describe('Work item comment form component', () => { const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); const findConfirmButton = () => wrapper.find('[data-testid="confirm-button"]'); + const findInternalNoteCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findInternalNoteTooltipIcon = () => wrapper.findComponent(GlIcon); const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); @@ -68,6 +72,9 @@ describe('Work item comment form component', () => { provide: { fullPath: 'test-project-path', }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, }); }; @@ -168,7 +175,9 @@ describe('Work item comment form component', () => { createComponent(); findConfirmButton().vm.$emit('click'); - expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]); + expect(wrapper.emitted('submitForm')).toEqual([ + [{ commentText: draftComment, isNoteInternal: false }], + ]); }); it('emits `submitForm` event on pressing enter with meta key on markdown editor', () => { @@ -178,7 +187,9 @@ describe('Work item comment form component', () => { new KeyboardEvent('keydown', { key: ENTER_KEY, metaKey: true }), ); - expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]); + expect(wrapper.emitted('submitForm')).toEqual([ + [{ commentText: draftComment, isNoteInternal: false }], + ]); }); it('emits `submitForm` event on pressing ctrl+enter on markdown editor', () => { @@ -188,7 +199,9 @@ describe('Work item comment form component', () => { new KeyboardEvent('keydown', { key: ENTER_KEY, ctrlKey: true }), ); - expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]); + expect(wrapper.emitted('submitForm')).toEqual([ + [{ commentText: draftComment, isNoteInternal: false }], + ]); }); describe('when used as a top level/is a new discussion', () => { @@ -249,4 +262,36 @@ describe('Work item comment form component', () => { }); }); }); + + describe('internal note', () => { + it('internal note checkbox should not be visible by default', () => { + createComponent(); + + expect(findInternalNoteCheckbox().exists()).toBe(false); + }); + + describe('when used as a new discussion', () => { + beforeEach(() => { + createComponent({ isNewDiscussion: true }); + }); + + it('should have the add as internal note capability', () => { + expect(findInternalNoteCheckbox().exists()).toBe(true); + }); + + it('should have the tooltip explaining the internal note capabilities', () => { + expect(findInternalNoteTooltipIcon().exists()).toBe(true); + expect(findInternalNoteTooltipIcon().attributes('title')).toBe( + WorkItemCommentForm.i18n.internalVisibility, + ); + }); + + it('should change the submit button text on change of value', async () => { + findInternalNoteCheckbox().vm.$emit('input', true); + await nextTick(); + + expect(findConfirmButton().text()).toBe(WorkItemCommentForm.i18n.addInternalNote); + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js index fac5011b6af..9d22a64f2cb 100644 --- a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js @@ -90,6 +90,16 @@ describe('Work Item Discussion', () => { expect(findWorkItemAddNote().exists()).toBe(true); expect(findWorkItemAddNote().props('autofocus')).toBe(true); }); + + it('should send the correct props is when the main comment is internal', async () => { + const mainComment = findThreadAtIndex(0); + + mainComment.vm.$emit('startReplying'); + await nextTick(); + expect(findWorkItemAddNote().props('isInternalThread')).toBe( + mockWorkItemNotesWidgetResponseWithComments.discussions.nodes[0].notes.nodes[0].internal, + ); + }); }); describe('When replying to any comment', () => { @@ -115,6 +125,13 @@ describe('Work Item Discussion', () => { expect(findToggleRepliesWidget().exists()).toBe(true); expect(findToggleRepliesWidget().props('collapsed')).toBe(false); }); + + it('should pass `is-internal-note` props to make sure the correct background is set', () => { + expect(findWorkItemNoteReplying().exists()).toBe(true); + expect(findWorkItemNoteReplying().props('isInternalNote')).toBe( + mockWorkItemNotesWidgetResponseWithComments.discussions.nodes[0].notes.nodes[0].internal, + ); + }); }); it('emits `deleteNote` event with correct parameter when child note component emits `deleteNote` event', () => { diff --git a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js index 99bf391e261..2e901783e07 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js @@ -1,8 +1,9 @@ -import { GlDropdown } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { createMockDirective } from 'helpers/vue_mock_directive'; import EmojiPicker from '~/emoji/components/picker.vue'; import waitForPromises from 'helpers/wait_for_promises'; import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; @@ -18,11 +19,14 @@ describe('Work Item Note Actions', () => { const findReplyButton = () => wrapper.findComponent(ReplyButton); const findEditButton = () => wrapper.find('[data-testid="edit-work-item-note"]'); const findEmojiButton = () => wrapper.find('[data-testid="note-emoji-button"]'); - const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]'); const findCopyLinkButton = () => wrapper.find('[data-testid="copy-link-action"]'); const findAssignUnassignButton = () => wrapper.find('[data-testid="assign-note-action"]'); const findReportAbuseToAdminButton = () => wrapper.find('[data-testid="abuse-note-action"]'); + const findAuthorBadge = () => wrapper.find('[data-testid="author-badge"]'); + const findMaxAccessLevelBadge = () => wrapper.find('[data-testid="max-access-level-badge"]'); + const findContributorBadge = () => wrapper.find('[data-testid="contributor-badge"]'); const addEmojiMutationResolver = jest.fn().mockResolvedValue({ data: { @@ -41,6 +45,11 @@ describe('Work Item Note Actions', () => { showAwardEmoji = true, showAssignUnassign = false, canReportAbuse = false, + workItemType = 'Task', + isWorkItemAuthor = false, + isAuthorContributor = false, + maxAccessLevelOfAuthor = '', + projectName = 'Project name', } = {}) => { wrapper = shallowMount(WorkItemNoteActions, { propsData: { @@ -50,6 +59,11 @@ describe('Work Item Note Actions', () => { showAwardEmoji, showAssignUnassign, canReportAbuse, + workItemType, + isWorkItemAuthor, + isAuthorContributor, + maxAccessLevelOfAuthor, + projectName, }, provide: { glFeatures: { @@ -60,7 +74,11 @@ describe('Work Item Note Actions', () => { EmojiPicker: EmojiPickerStub, }, apolloProvider: createMockApollo([[addAwardEmojiMutation, addEmojiMutationResolver]]), + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, }); + wrapper.vm.$refs.dropdown.close = jest.fn(); }; describe('reply button', () => { @@ -152,7 +170,7 @@ describe('Work Item Note Actions', () => { showEdit: true, }); - findDeleteNoteButton().vm.$emit('click'); + findDeleteNoteButton().vm.$emit('action'); expect(wrapper.emitted('deleteNote')).toEqual([[]]); }); @@ -167,7 +185,7 @@ describe('Work Item Note Actions', () => { }); it('should emit `notifyCopyDone` event when copy link note action is clicked', () => { - findCopyLinkButton().vm.$emit('click'); + findCopyLinkButton().vm.$emit('action'); expect(wrapper.emitted('notifyCopyDone')).toEqual([[]]); }); @@ -193,7 +211,7 @@ describe('Work Item Note Actions', () => { showAssignUnassign: true, }); - findAssignUnassignButton().vm.$emit('click'); + findAssignUnassignButton().vm.$emit('action'); expect(wrapper.emitted('assignUser')).toEqual([[]]); }); @@ -219,9 +237,63 @@ describe('Work Item Note Actions', () => { canReportAbuse: true, }); - findReportAbuseToAdminButton().vm.$emit('click'); + findReportAbuseToAdminButton().vm.$emit('action'); expect(wrapper.emitted('reportAbuse')).toEqual([[]]); }); }); + + describe('user role badges', () => { + describe('author badge', () => { + it('does not show the author badge by default', () => { + createComponent(); + + expect(findAuthorBadge().exists()).toBe(false); + }); + + it('shows the author badge when the work item is author by the current User', () => { + createComponent({ isWorkItemAuthor: true }); + + expect(findAuthorBadge().exists()).toBe(true); + expect(findAuthorBadge().text()).toBe('Author'); + expect(findAuthorBadge().attributes('title')).toBe('This user is the author of this task.'); + }); + }); + + describe('Max access level badge', () => { + it('does not show the access level badge by default', () => { + createComponent(); + + expect(findMaxAccessLevelBadge().exists()).toBe(false); + }); + + it('shows the access badge when we have a valid value', () => { + createComponent({ maxAccessLevelOfAuthor: 'Owner' }); + + expect(findMaxAccessLevelBadge().exists()).toBe(true); + expect(findMaxAccessLevelBadge().text()).toBe('Owner'); + expect(findMaxAccessLevelBadge().attributes('title')).toBe( + 'This user has the owner role in the Project name project.', + ); + }); + }); + + describe('Contributor badge', () => { + it('does not show the contributor badge by default', () => { + createComponent(); + + expect(findContributorBadge().exists()).toBe(false); + }); + + it('shows the contributor badge the note author is a contributor', () => { + createComponent({ isAuthorContributor: true }); + + expect(findContributorBadge().exists()).toBe(true); + expect(findContributorBadge().text()).toBe('Contributor'); + expect(findContributorBadge().attributes('title')).toBe( + 'This user has previously committed to the Project name project.', + ); + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_note_replying_spec.js b/spec/frontend/work_items/components/notes/work_item_note_replying_spec.js index 225cc3bacaf..5a6894400b6 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_replying_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_replying_spec.js @@ -10,10 +10,11 @@ describe('Work Item Note Replying', () => { const findTimelineEntry = () => wrapper.findComponent(TimelineEntryItem); const findNoteHeader = () => wrapper.findComponent(NoteHeader); - const createComponent = ({ body = mockNoteBody } = {}) => { + const createComponent = ({ body = mockNoteBody, isInternalNote = false } = {}) => { wrapper = shallowMount(WorkItemNoteReplying, { propsData: { body, + isInternalNote, }, }); @@ -31,4 +32,9 @@ describe('Work Item Note Replying', () => { expect(findTimelineEntry().exists()).toBe(true); expect(findNoteHeader().html()).toMatchSnapshot(); }); + + it('should have the correct class when internal note', () => { + createComponent({ isInternalNote: true }); + expect(findTimelineEntry().classes()).toContain('internal-note'); + }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_note_spec.js b/spec/frontend/work_items/components/notes/work_item_note_spec.js index f2cf5171cc1..8dbd2818fc5 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js @@ -20,6 +20,8 @@ import { updateWorkItemMutationResponse, workItemByIidResponseFactory, workItemQueryResponse, + mockWorkItemCommentNoteByContributor, + mockWorkItemCommentByMaintainer, } from 'jest/work_items/mock_data'; import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import { mockTracking } from 'helpers/tracking_helper'; @@ -33,6 +35,23 @@ describe('Work Item Note', () => { const updatedNoteBody = '<h1 data-sourcepos="1:1-1:12" dir="auto">Some title</h1>'; const mockWorkItemId = workItemQueryResponse.data.workItem.id; + const mockWorkItemByDifferentUser = { + data: { + workItem: { + ...workItemQueryResponse.data.workItem, + author: { + avatarUrl: + 'http://127.0.0.1:3000/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/2', + name: 'User 1', + username: 'user1', + webUrl: 'http://127.0.0.1:3000/user1', + __typename: 'UserCore', + }, + }, + }, + }; + const successHandler = jest.fn().mockResolvedValue({ data: { updateNote: { @@ -47,6 +66,9 @@ describe('Work Item Note', () => { }); const workItemResponseHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory()); + const workItemByAuthoredByDifferentUser = jest + .fn() + .mockResolvedValue(mockWorkItemByDifferentUser); const updateWorkItemMutationSuccessHandler = jest .fn() @@ -69,6 +91,7 @@ describe('Work Item Note', () => { workItemId = mockWorkItemId, updateWorkItemMutationHandler = updateWorkItemMutationSuccessHandler, assignees = mockAssignees, + workItemByIidResponseHandler = workItemResponseHandler, } = {}) => { wrapper = shallowMount(WorkItemNote, { provide: { @@ -85,7 +108,7 @@ describe('Work Item Note', () => { assignees, }, apolloProvider: mockApollo([ - [workItemByIidQuery, workItemResponseHandler], + [workItemByIidQuery, workItemByIidResponseHandler], [updateWorkItemNoteMutation, updateNoteMutationHandler], [updateWorkItemMutation, updateWorkItemMutationHandler], ]), @@ -133,7 +156,7 @@ describe('Work Item Note', () => { findNoteActions().vm.$emit('startEditing'); await nextTick(); - findCommentForm().vm.$emit('submitForm', updatedNoteText); + findCommentForm().vm.$emit('submitForm', { commentText: updatedNoteText }); expect(successHandler).toHaveBeenCalledWith({ input: { @@ -148,7 +171,7 @@ describe('Work Item Note', () => { findNoteActions().vm.$emit('startEditing'); await nextTick(); - findCommentForm().vm.$emit('submitForm', updatedNoteText); + findCommentForm().vm.$emit('submitForm', { commentText: updatedNoteText }); await waitForPromises(); expect(findCommentForm().exists()).toBe(false); @@ -161,7 +184,7 @@ describe('Work Item Note', () => { findNoteActions().vm.$emit('startEditing'); await nextTick(); - findCommentForm().vm.$emit('submitForm', updatedNoteText); + findCommentForm().vm.$emit('submitForm', { commentText: updatedNoteText }); await waitForPromises(); }); @@ -215,8 +238,9 @@ describe('Work Item Note', () => { }); describe('main comment', () => { - beforeEach(() => { + beforeEach(async () => { createComponent({ isFirstNote: true }); + await waitForPromises(); }); it('should have the note header, actions and body', () => { @@ -229,6 +253,10 @@ describe('Work Item Note', () => { it('should have the reply button props', () => { expect(findNoteActions().props('showReply')).toBe(true); }); + + it('should have the project name', () => { + expect(findNoteActions().props('projectName')).toBe('Project name'); + }); }); describe('comment threads', () => { @@ -318,5 +346,63 @@ describe('Work Item Note', () => { }, ); }); + + describe('internal note', () => { + it('does not have the internal note class set by default', () => { + createComponent(); + expect(findTimelineEntryItem().classes()).not.toContain('internal-note'); + }); + + it('timeline entry item and note header has the class for internal notes', () => { + createComponent({ + note: { + ...mockWorkItemCommentNote, + internal: true, + }, + }); + expect(findTimelineEntryItem().classes()).toContain('internal-note'); + expect(findNoteHeader().props('isInternalNote')).toBe(true); + }); + }); + + describe('author and user role badges', () => { + describe('author badge props', () => { + it.each` + isWorkItemAuthor | sameAsCurrentUser | workItemByIidResponseHandler + ${true} | ${'same as'} | ${workItemResponseHandler} + ${false} | ${'not same as'} | ${workItemByAuthoredByDifferentUser} + `( + 'should pass correct isWorkItemAuthor `$isWorkItemAuthor` to note actions when author is $sameAsCurrentUser as current note', + async ({ isWorkItemAuthor, workItemByIidResponseHandler }) => { + createComponent({ workItemByIidResponseHandler }); + await waitForPromises(); + + expect(findNoteActions().props('isWorkItemAuthor')).toBe(isWorkItemAuthor); + }, + ); + }); + + describe('Max access level badge', () => { + it('should pass the max access badge props', async () => { + createComponent({ note: mockWorkItemCommentByMaintainer }); + await waitForPromises(); + + expect(findNoteActions().props('maxAccessLevelOfAuthor')).toBe( + mockWorkItemCommentByMaintainer.maxAccessLevelOfAuthor, + ); + }); + }); + + describe('Contributor badge', () => { + it('should pass the contributor props', async () => { + createComponent({ note: mockWorkItemCommentNoteByContributor }); + await waitForPromises(); + + expect(findNoteActions().props('isAuthorContributor')).toBe( + mockWorkItemCommentNoteByContributor.authorIsContributor, + ); + }); + }); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js index 0045abe50d0..e03c6a7e28d 100644 --- a/spec/frontend/work_items/components/work_item_actions_spec.js +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -1,9 +1,12 @@ import { GlDropdownDivider, GlModal, GlToggle } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; + import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + import { isLoggedIn } from '~/lib/utils/common_utils'; import toast from '~/vue_shared/plugins/global_toast'; import WorkItemActions from '~/work_items/components/work_item_actions.vue'; @@ -13,6 +16,8 @@ import { TEST_ID_NOTIFICATIONS_TOGGLE_FORM, TEST_ID_DELETE_ACTION, TEST_ID_PROMOTE_ACTION, + TEST_ID_COPY_REFERENCE_ACTION, + TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION, } from '~/work_items/constants'; import updateWorkItemNotificationsMutation from '~/work_items/graphql/update_work_item_notifications.mutation.graphql'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; @@ -31,8 +36,10 @@ describe('WorkItemActions component', () => { Vue.use(VueApollo); let wrapper; - let glModalDirective; let mockApollo; + const mockWorkItemReference = 'gitlab-org/gitlab-test#1'; + const mockWorkItemCreateNoteEmail = + 'gitlab-incoming+gitlab-org-gitlab-test-2-ddpzuq0zd2wefzofcpcdr3dg7-issue-1@gmail.com'; const findModal = () => wrapper.findComponent(GlModal); const findConfidentialityToggleButton = () => @@ -41,6 +48,9 @@ describe('WorkItemActions component', () => { wrapper.findByTestId(TEST_ID_NOTIFICATIONS_TOGGLE_ACTION); const findDeleteButton = () => wrapper.findByTestId(TEST_ID_DELETE_ACTION); const findPromoteButton = () => wrapper.findByTestId(TEST_ID_PROMOTE_ACTION); + const findCopyReferenceButton = () => wrapper.findByTestId(TEST_ID_COPY_REFERENCE_ACTION); + const findCopyCreateNoteEmailButton = () => + wrapper.findByTestId(TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION); const findDropdownItems = () => wrapper.findAll('[data-testid="work-item-actions-dropdown"] > *'); const findDropdownItemsActual = () => findDropdownItems().wrappers.map((x) => { @@ -55,6 +65,7 @@ describe('WorkItemActions component', () => { }); const findNotificationsToggle = () => wrapper.findComponent(GlToggle); + const modalShowSpy = jest.fn(); const $toast = { show: jest.fn(), hide: jest.fn(), @@ -77,9 +88,10 @@ describe('WorkItemActions component', () => { notificationsMock = [updateWorkItemNotificationsMutation, jest.fn()], convertWorkItemMutationHandler = convertWorkItemMutationSuccessHandler, workItemType = 'Task', + workItemReference = mockWorkItemReference, + workItemCreateNoteEmail = mockWorkItemCreateNoteEmail, } = {}) => { const handlers = [notificationsMock]; - glModalDirective = jest.fn(); mockApollo = createMockApollo([ ...handlers, [convertWorkItemMutation, convertWorkItemMutationHandler], @@ -96,13 +108,8 @@ describe('WorkItemActions component', () => { subscribed, isParentConfidential, workItemType, - }, - directives: { - glModal: { - bind(_, { value }) { - glModalDirective(value); - }, - }, + workItemReference, + workItemCreateNoteEmail, }, provide: { fullPath: 'gitlab-org/gitlab', @@ -111,6 +118,13 @@ describe('WorkItemActions component', () => { mocks: { $toast, }, + stubs: { + GlModal: stubComponent(GlModal, { + methods: { + show: modalShowSpy, + }, + }), + }, }); }; @@ -141,6 +155,14 @@ describe('WorkItemActions component', () => { text: 'Turn on confidentiality', }, { + testId: TEST_ID_COPY_REFERENCE_ACTION, + text: 'Copy reference', + }, + { + testId: TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION, + text: 'Copy task email address', + }, + { divider: true, }, { @@ -189,7 +211,7 @@ describe('WorkItemActions component', () => { findDeleteButton().vm.$emit('click'); - expect(glModalDirective).toHaveBeenCalled(); + expect(modalShowSpy).toHaveBeenCalled(); }); it('emits event when clicking OK button', () => { @@ -359,4 +381,37 @@ describe('WorkItemActions component', () => { ]); }); }); + + describe('copy reference action', () => { + it('shows toast when user clicks on the action', () => { + createComponent(); + + expect(findCopyReferenceButton().exists()).toBe(true); + findCopyReferenceButton().vm.$emit('click'); + + expect(toast).toHaveBeenCalledWith('Reference copied'); + }); + }); + + describe('copy email address action', () => { + it.each(['key result', 'objective'])( + 'renders correct button name when work item is %s', + (workItemType) => { + createComponent({ workItemType }); + + expect(findCopyCreateNoteEmailButton().text()).toEqual( + `Copy ${workItemType} email address`, + ); + }, + ); + + it('shows toast when user clicks on the action', () => { + createComponent(); + + expect(findCopyCreateNoteEmailButton().exists()).toBe(true); + findCopyCreateNoteEmailButton().vm.$emit('click'); + + expect(toast).toHaveBeenCalledWith('Email address copied'); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index 25b0b74c217..94d47bfb3be 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -26,6 +26,7 @@ import { updateWorkItemMutationResponse, projectMembersResponseWithCurrentUserWithNextPage, projectMembersResponseWithNoMatchingUsers, + projectMembersResponseWithDuplicates, } from '../mock_data'; Vue.use(VueApollo); @@ -529,4 +530,14 @@ describe('WorkItemAssignees component', () => { }); }); }); + + it('filters out the users with the same ID from the list of project members', async () => { + createComponent({ + searchQueryHandler: jest.fn().mockResolvedValue(projectMembersResponseWithDuplicates), + }); + findTokenSelector().vm.$emit('focus'); + await waitForPromises(); + + expect(findTokenSelector().props('dropdownItems')).toHaveLength(2); + }); }); diff --git a/spec/frontend/work_items/components/work_item_award_emoji_spec.js b/spec/frontend/work_items/components/work_item_award_emoji_spec.js index f87c0e3f357..82be6d990e4 100644 --- a/spec/frontend/work_items/components/work_item_award_emoji_spec.js +++ b/spec/frontend/work_items/components/work_item_award_emoji_spec.js @@ -8,19 +8,15 @@ import waitForPromises from 'helpers/wait_for_promises'; import { isLoggedIn } from '~/lib/utils/common_utils'; import AwardList from '~/vue_shared/components/awards_list.vue'; import WorkItemAwardEmoji from '~/work_items/components/work_item_award_emoji.vue'; -import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; -import { - EMOJI_ACTION_REMOVE, - EMOJI_ACTION_ADD, - EMOJI_THUMBSUP, - EMOJI_THUMBSDOWN, -} from '~/work_items/constants'; +import updateAwardEmojiMutation from '~/work_items/graphql/update_award_emoji.mutation.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import { EMOJI_THUMBSUP, EMOJI_THUMBSDOWN } from '~/work_items/constants'; import { workItemByIidResponseFactory, mockAwardsWidget, - updateWorkItemMutationResponseFactory, mockAwardEmojiThumbsUp, + getAwardEmojiResponse, } from '../mock_data'; jest.mock('~/lib/utils/common_utils'); @@ -28,43 +24,61 @@ Vue.use(VueApollo); describe('WorkItemAwardEmoji component', () => { let wrapper; + let mockApolloProvider; const errorMessage = 'Failed to update the award'; - const workItemQueryResponse = workItemByIidResponseFactory(); - const workItemSuccessHandler = jest - .fn() - .mockResolvedValue(updateWorkItemMutationResponseFactory()); - const awardEmojiAddSuccessHandler = jest.fn().mockResolvedValue( - updateWorkItemMutationResponseFactory({ - awardEmoji: { - ...mockAwardsWidget, - nodes: [mockAwardEmojiThumbsUp], - }, - }), - ); - const awardEmojiRemoveSuccessHandler = jest.fn().mockResolvedValue( - updateWorkItemMutationResponseFactory({ - awardEmoji: { - ...mockAwardsWidget, - nodes: [], - }, - }), - ); - const workItemUpdateFailureHandler = jest.fn().mockRejectedValue(new Error(errorMessage)); + const workItemQueryAddAwardEmojiResponse = workItemByIidResponseFactory({ + awardEmoji: { ...mockAwardsWidget, nodes: [mockAwardEmojiThumbsUp] }, + }); + const workItemQueryRemoveAwardEmojiResponse = workItemByIidResponseFactory({ + awardEmoji: { ...mockAwardsWidget, nodes: [] }, + }); + const awardEmojiAddSuccessHandler = jest.fn().mockResolvedValue(getAwardEmojiResponse(true)); + const awardEmojiRemoveSuccessHandler = jest.fn().mockResolvedValue(getAwardEmojiResponse(false)); + const awardEmojiUpdateFailureHandler = jest.fn().mockRejectedValue(new Error(errorMessage)); const mockWorkItem = workItemQueryResponse.data.workspace.workItems.nodes[0]; + const mockAwardEmojiDifferentUserThumbsUp = { + name: 'thumbsup', + __typename: 'AwardEmoji', + user: { + id: 'gid://gitlab/User/1', + name: 'John Doe', + __typename: 'UserCore', + }, + }; const createComponent = ({ - mockWorkItemUpdateMutationHandler = [updateWorkItemMutation, workItemSuccessHandler], + awardMutationHandler = awardEmojiAddSuccessHandler, workItem = mockWorkItem, + workItemIid = '1', awardEmoji = { ...mockAwardsWidget, nodes: [] }, } = {}) => { + mockApolloProvider = createMockApollo([[updateAwardEmojiMutation, awardMutationHandler]]); + + mockApolloProvider.clients.defaultClient.writeQuery({ + query: workItemByIidQuery, + variables: { fullPath: workItem.project.fullPath, iid: workItemIid }, + data: { + ...workItemQueryResponse.data, + workspace: { + __typename: 'Project', + id: 'gid://gitlab/Project/1', + workItems: { + nodes: [workItem], + }, + }, + }, + }); + wrapper = shallowMount(WorkItemAwardEmoji, { isLoggedIn: isLoggedIn(), - apolloProvider: createMockApollo([mockWorkItemUpdateMutationHandler]), + apolloProvider: mockApolloProvider, propsData: { - workItem, + workItemId: workItem.id, + workItemFullpath: workItem.project.fullPath, awardEmoji, + workItemIid, }, }); }; @@ -74,7 +88,8 @@ describe('WorkItemAwardEmoji component', () => { beforeEach(() => { isLoggedIn.mockReturnValue(true); window.gon = { - current_user_id: 1, + current_user_id: 5, + current_user_fullname: 'Dave Smith', }; createComponent(); @@ -85,7 +100,7 @@ describe('WorkItemAwardEmoji component', () => { expect(findAwardsList().props()).toEqual({ boundary: '', canAwardEmoji: true, - currentUserId: 1, + currentUserId: 5, defaultAwards: [EMOJI_THUMBSUP, EMOJI_THUMBSDOWN], selectedClass: 'selected', awards: [], @@ -97,48 +112,70 @@ describe('WorkItemAwardEmoji component', () => { expect(findAwardsList().props('awards')).toEqual([ { - id: 1, name: EMOJI_THUMBSUP, user: { id: 5, + name: 'Dave Smith', }, }, { - id: 2, name: EMOJI_THUMBSDOWN, user: { id: 5, + name: 'Dave Smith', + }, + }, + ]); + }); + + it('renders awards list given by multiple users', () => { + createComponent({ + awardEmoji: { + ...mockAwardsWidget, + nodes: [mockAwardEmojiThumbsUp, mockAwardEmojiDifferentUserThumbsUp], + }, + }); + + expect(findAwardsList().props('awards')).toEqual([ + { + name: EMOJI_THUMBSUP, + user: { + id: 5, + name: 'Dave Smith', + }, + }, + { + name: EMOJI_THUMBSUP, + user: { + id: 1, + name: 'John Doe', }, }, ]); }); it.each` - expectedAssertion | action | successHandler | mockAwardEmojiNodes - ${'added'} | ${EMOJI_ACTION_ADD} | ${awardEmojiAddSuccessHandler} | ${[]} - ${'removed'} | ${EMOJI_ACTION_REMOVE} | ${awardEmojiRemoveSuccessHandler} | ${[mockAwardEmojiThumbsUp]} + expectedAssertion | awardEmojiMutationHandler | mockAwardEmojiNodes | workItem + ${'added'} | ${awardEmojiAddSuccessHandler} | ${[]} | ${workItemQueryRemoveAwardEmojiResponse.data.workspace.workItems.nodes[0]} + ${'removed'} | ${awardEmojiRemoveSuccessHandler} | ${[mockAwardEmojiThumbsUp]} | ${workItemQueryAddAwardEmojiResponse.data.workspace.workItems.nodes[0]} `( 'calls mutation when an award emoji is $expectedAssertion', - async ({ action, successHandler, mockAwardEmojiNodes }) => { + ({ awardEmojiMutationHandler, mockAwardEmojiNodes, workItem }) => { createComponent({ - mockWorkItemUpdateMutationHandler: [updateWorkItemMutation, successHandler], + awardMutationHandler: awardEmojiMutationHandler, awardEmoji: { ...mockAwardsWidget, nodes: mockAwardEmojiNodes, }, + workItem, }); findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); - await waitForPromises(); - - expect(successHandler).toHaveBeenCalledWith({ + expect(awardEmojiMutationHandler).toHaveBeenCalledWith({ input: { - id: mockWorkItem.id, - awardEmojiWidget: { - action, - name: EMOJI_THUMBSUP, - }, + awardableId: mockWorkItem.id, + name: EMOJI_THUMBSUP, }, }); }, @@ -146,7 +183,7 @@ describe('WorkItemAwardEmoji component', () => { it('emits error when the update mutation fails', async () => { createComponent({ - mockWorkItemUpdateMutationHandler: [updateWorkItemMutation, workItemUpdateFailureHandler], + awardMutationHandler: awardEmojiUpdateFailureHandler, }); findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); @@ -167,4 +204,32 @@ describe('WorkItemAwardEmoji component', () => { expect(findAwardsList().props('canAwardEmoji')).toBe(false); }); }); + + describe('when a different users awards same emoji', () => { + beforeEach(() => { + window.gon = { + current_user_id: 1, + current_user_fullname: 'John Doe', + }; + }); + + it('calls mutation succesfully and adds the award emoji with proper user details', () => { + createComponent({ + awardMutationHandler: awardEmojiAddSuccessHandler, + awardEmoji: { + ...mockAwardsWidget, + nodes: [mockAwardEmojiThumbsUp], + }, + }); + + findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); + + expect(awardEmojiAddSuccessHandler).toHaveBeenCalledWith({ + input: { + awardableId: mockWorkItem.id, + name: EMOJI_THUMBSUP, + }, + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index 62cbb1bacb6..b910e9854f8 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -1,3 +1,4 @@ +import { GlForm } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -7,7 +8,6 @@ import waitForPromises from 'helpers/wait_for_promises'; import EditedAt from '~/issues/show/components/edited.vue'; import { updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue'; @@ -36,22 +36,18 @@ describe('WorkItemDescription', () => { const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); const subscriptionHandler = jest.fn().mockResolvedValue(workItemDescriptionSubscriptionResponse); let workItemResponseHandler; - let workItemsMvc; - const findMarkdownField = () => wrapper.findComponent(MarkdownField); + const findForm = () => wrapper.findComponent(GlForm); const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); const findRenderedDescription = () => wrapper.findComponent(WorkItemDescriptionRendered); const findEditedAt = () => wrapper.findComponent(EditedAt); - const editDescription = (newText) => { - if (workItemsMvc) { - return findMarkdownEditor().vm.$emit('input', newText); - } - return wrapper.find('textarea').setValue(newText); - }; + const editDescription = (newText) => findMarkdownEditor().vm.$emit('input', newText); - const clickCancel = () => wrapper.find('[data-testid="cancel"]').vm.$emit('click'); - const clickSave = () => wrapper.find('[data-testid="save-description"]').vm.$emit('click', {}); + const findCancelButton = () => wrapper.find('[data-testid="cancel"]'); + const findSubmitButton = () => wrapper.find('[data-testid="save-description"]'); + const clickCancel = () => findForm().vm.$emit('reset', new Event('reset')); + const clickSave = () => findForm().vm.$emit('submit', new Event('submit')); const createComponent = async ({ mutationHandler = mutationSuccessHandler, @@ -75,12 +71,6 @@ describe('WorkItemDescription', () => { }, provide: { fullPath: 'test-project-path', - glFeatures: { - workItemsMvc, - }, - }, - stubs: { - MarkdownField, }, }); @@ -93,11 +83,15 @@ describe('WorkItemDescription', () => { } }; - describe('editing description with workItemsMvc FF enabled', () => { - beforeEach(() => { - workItemsMvc = true; + it('has a subscription', async () => { + await createComponent(); + + expect(subscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemQueryResponse.data.workItem.id, }); + }); + describe('editing description', () => { it('passes correct autocompletion data and preview markdown sources and enables quick actions', async () => { const { iid, @@ -113,196 +107,162 @@ describe('WorkItemDescription', () => { autocompleteDataSources: autocompleteDataSources(fullPath, iid), }); }); - }); - - describe('editing description with workItemsMvc FF disabled', () => { - beforeEach(() => { - workItemsMvc = false; - }); - - it('passes correct autocompletion data and preview markdown sources', async () => { - const { - iid, - project: { fullPath }, - } = workItemQueryResponse.data.workItem; - - await createComponent({ isEditing: true }); + it('shows edited by text', async () => { + const lastEditedAt = '2022-09-21T06:18:42Z'; + const lastEditedBy = { + name: 'Administrator', + webPath: '/root', + }; + + await createComponent({ + workItemResponse: workItemByIidResponseFactory({ lastEditedAt, lastEditedBy }), + }); - expect(findMarkdownField().props()).toMatchObject({ - autocompleteDataSources: autocompleteDataSources(fullPath, iid), - markdownPreviewPath: markdownPreviewPath(fullPath, iid), - quickActionsDocsPath: wrapper.vm.$options.quickActionsDocsPath, + expect(findEditedAt().props()).toMatchObject({ + updatedAt: lastEditedAt, + updatedByName: lastEditedBy.name, + updatedByPath: lastEditedBy.webPath, }); }); - }); - describe.each([true, false])( - 'editing description with workItemsMvc %workItemsMvcEnabled', - (workItemsMvcEnabled) => { - beforeEach(() => { - beforeEach(() => { - workItemsMvc = workItemsMvcEnabled; - }); - }); + it('does not show edited by text', async () => { + await createComponent(); - it('has a subscription', async () => { - await createComponent(); + expect(findEditedAt().exists()).toBe(false); + }); - expect(subscriptionHandler).toHaveBeenCalledWith({ - issuableId: workItemQueryResponse.data.workItem.id, - }); + it('cancels when clicking cancel', async () => { + await createComponent({ + isEditing: true, }); - describe('editing description', () => { - it('shows edited by text', async () => { - const lastEditedAt = '2022-09-21T06:18:42Z'; - const lastEditedBy = { - name: 'Administrator', - webPath: '/root', - }; + clickCancel(); - await createComponent({ - workItemResponse: workItemByIidResponseFactory({ lastEditedAt, lastEditedBy }), - }); + await nextTick(); - expect(findEditedAt().props()).toMatchObject({ - updatedAt: lastEditedAt, - updatedByName: lastEditedBy.name, - updatedByPath: lastEditedBy.webPath, - }); - }); + expect(confirmAction).not.toHaveBeenCalled(); + expect(findMarkdownEditor().exists()).toBe(false); + }); - it('does not show edited by text', async () => { - await createComponent(); + it('prompts for confirmation when clicking cancel after changes', async () => { + await createComponent({ + isEditing: true, + }); - expect(findEditedAt().exists()).toBe(false); - }); + editDescription('updated desc'); - it('cancels when clicking cancel', async () => { - await createComponent({ - isEditing: true, - }); + clickCancel(); - clickCancel(); + await nextTick(); - await nextTick(); + expect(confirmAction).toHaveBeenCalled(); + }); - expect(confirmAction).not.toHaveBeenCalled(); - expect(findMarkdownField().exists()).toBe(false); - }); + it('calls update widgets mutation', async () => { + const updatedDesc = 'updated desc'; - it('prompts for confirmation when clicking cancel after changes', async () => { - await createComponent({ - isEditing: true, - }); + await createComponent({ + isEditing: true, + }); - editDescription('updated desc'); + editDescription(updatedDesc); - clickCancel(); + clickSave(); - await nextTick(); + await waitForPromises(); - expect(confirmAction).toHaveBeenCalled(); - }); + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemId, + descriptionWidget: { + description: updatedDesc, + }, + }, + }); + }); - it('calls update widgets mutation', async () => { - const updatedDesc = 'updated desc'; + it('tracks editing description', async () => { + await createComponent({ + isEditing: true, + markdownPreviewPath: '/preview', + }); + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - await createComponent({ - isEditing: true, - }); + clickSave(); - editDescription(updatedDesc); + await waitForPromises(); - clickSave(); + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_description', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_description', + property: 'type_Task', + }); + }); - await waitForPromises(); + it('emits error when mutation returns error', async () => { + const error = 'eror'; - expect(mutationSuccessHandler).toHaveBeenCalledWith({ - input: { - id: workItemId, - descriptionWidget: { - description: updatedDesc, - }, + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockResolvedValue({ + data: { + workItemUpdate: { + workItem: {}, + errors: [error], }, - }); - }); - - it('tracks editing description', async () => { - await createComponent({ - isEditing: true, - markdownPreviewPath: '/preview', - }); - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - clickSave(); - - await waitForPromises(); - - expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_description', { - category: TRACKING_CATEGORY_SHOW, - label: 'item_description', - property: 'type_Task', - }); - }); - - it('emits error when mutation returns error', async () => { - const error = 'eror'; + }, + }), + }); - await createComponent({ - isEditing: true, - mutationHandler: jest.fn().mockResolvedValue({ - data: { - workItemUpdate: { - workItem: {}, - errors: [error], - }, - }, - }), - }); + editDescription('updated desc'); - editDescription('updated desc'); + clickSave(); - clickSave(); + await waitForPromises(); - await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[error]]); + }); - expect(wrapper.emitted('error')).toEqual([[error]]); - }); + it('emits error when mutation fails', async () => { + const error = 'eror'; - it('emits error when mutation fails', async () => { - const error = 'eror'; + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockRejectedValue(new Error(error)), + }); - await createComponent({ - isEditing: true, - mutationHandler: jest.fn().mockRejectedValue(new Error(error)), - }); + editDescription('updated desc'); - editDescription('updated desc'); + clickSave(); - clickSave(); + await waitForPromises(); - await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[error]]); + }); - expect(wrapper.emitted('error')).toEqual([[error]]); - }); + it('autosaves description', async () => { + await createComponent({ + isEditing: true, + }); - it('autosaves description', async () => { - await createComponent({ - isEditing: true, - }); + editDescription('updated desc'); - editDescription('updated desc'); + expect(updateDraft).toHaveBeenCalled(); + }); - expect(updateDraft).toHaveBeenCalled(); - }); + it('maps submit and cancel buttons to form actions', async () => { + await createComponent({ + isEditing: true, }); - it('calls the work item query', async () => { - await createComponent(); + expect(findCancelButton().attributes('type')).toBe('reset'); + expect(findSubmitButton().attributes('type')).toBe('submit'); + }); + }); + + it('calls the work item query', async () => { + await createComponent(); - expect(workItemResponseHandler).toHaveBeenCalled(); - }); - }, - ); + expect(workItemResponseHandler).toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js index e305cc310bd..6fa3a70c3eb 100644 --- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -33,7 +33,6 @@ describe('WorkItemDetailModal component', () => { const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); const createComponent = ({ - error = false, deleteWorkItemMutationHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse), } = {}) => { const apolloProvider = createMockApollo([ @@ -46,19 +45,12 @@ describe('WorkItemDetailModal component', () => { workItemId, workItemIid: '1', }, - data() { - return { - error, - }; - }, provide: { fullPath: 'group/project', }, stubs: { GlModal, - WorkItemDetail: stubComponent(WorkItemDetail, { - apollo: {}, - }), + WorkItemDetail: stubComponent(WorkItemDetail), }, }); }; @@ -68,14 +60,18 @@ describe('WorkItemDetailModal component', () => { expect(findWorkItemDetail().props()).toEqual({ isModal: true, - workItemId, workItemIid: '1', workItemParentId: null, }); }); - it('renders alert if there is an error', () => { - createComponent({ error: true }); + it('renders alert if there is an error', async () => { + createComponent({ + deleteWorkItemMutationHandler: jest.fn().mockRejectedValue({ message: 'message' }), + }); + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + await waitForPromises(); expect(findAlert().exists()).toBe(true); }); @@ -87,7 +83,13 @@ describe('WorkItemDetailModal component', () => { }); it('dismisses the alert on `dismiss` emitted event', async () => { - createComponent({ error: true }); + createComponent({ + deleteWorkItemMutationHandler: jest.fn().mockRejectedValue({ message: 'message' }), + }); + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + await waitForPromises(); + findAlert().vm.$emit('dismiss'); await nextTick(); @@ -103,24 +105,19 @@ describe('WorkItemDetailModal component', () => { it('hides the modal when WorkItemDetail emits `close` event', () => { createComponent(); - const closeSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide'); findWorkItemDetail().vm.$emit('close'); - expect(closeSpy).toHaveBeenCalled(); + expect(hideModal).toHaveBeenCalled(); }); it('updates the work item when WorkItemDetail emits `update-modal` event', async () => { createComponent(); - findWorkItemDetail().vm.$emit('update-modal', undefined, { - id: 'updatedId', - iid: 'updatedIid', - }); - await waitForPromises(); + findWorkItemDetail().vm.$emit('update-modal', undefined, { iid: 'updatedIid' }); + await nextTick(); - expect(findWorkItemDetail().props().workItemId).toEqual('updatedId'); - expect(findWorkItemDetail().props().workItemIid).toEqual('updatedIid'); + expect(findWorkItemDetail().props('workItemIid')).toBe('updatedIid'); }); describe('delete work item', () => { diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index 557ae07969e..d8ba8ea74f2 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -100,7 +100,6 @@ describe('WorkItemDetail component', () => { const createComponent = ({ isModal = false, updateInProgress = false, - workItemId = id, workItemIid = '1', handler = successHandler, subscriptionHandler = titleSubscriptionHandler, @@ -120,7 +119,10 @@ describe('WorkItemDetail component', () => { wrapper = shallowMount(WorkItemDetail, { apolloProvider: createMockApollo(handlers), isLoggedIn: isLoggedIn(), - propsData: { isModal, workItemId, workItemIid }, + propsData: { + isModal, + workItemIid, + }, data() { return { updateInProgress, @@ -160,9 +162,9 @@ describe('WorkItemDetail component', () => { setWindowLocation(''); }); - describe('when there is no `workItemId` and no `workItemIid` prop', () => { + describe('when there is no `workItemIid` prop', () => { beforeEach(() => { - createComponent({ workItemId: null, workItemIid: null }); + createComponent({ workItemIid: null }); }); it('skips the work item query', () => { @@ -437,7 +439,7 @@ describe('WorkItemDetail component', () => { }); it('sets the parent breadcrumb URL pointing to issue page when parent type is `Issue`', () => { - expect(findParentButton().attributes().href).toBe('../../issues/5'); + expect(findParentButton().attributes().href).toBe('../../-/issues/5'); }); it('sets the parent breadcrumb URL based on parent webUrl when parent type is not `Issue`', async () => { diff --git a/spec/frontend/work_items/components/work_item_due_date_spec.js b/spec/frontend/work_items/components/work_item_due_date_spec.js index b4811db8bed..5e8c34d90ee 100644 --- a/spec/frontend/work_items/components/work_item_due_date_spec.js +++ b/spec/frontend/work_items/components/work_item_due_date_spec.js @@ -3,6 +3,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking } from 'helpers/tracking_helper'; +import { stubComponent } from 'helpers/stub_component'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue'; @@ -33,6 +34,7 @@ describe('WorkItemDueDate component', () => { dueDate = null, startDate = null, mutationHandler = updateWorkItemMutationHandler, + stubs = {}, } = {}) => { wrapper = mountExtended(WorkItemDueDate, { apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), @@ -43,6 +45,7 @@ describe('WorkItemDueDate component', () => { workItemId, workItemType: 'Task', }, + stubs, }); }; @@ -132,11 +135,21 @@ describe('WorkItemDueDate component', () => { describe('when the start date is later than the due date', () => { const startDate = new Date('2030-01-01T00:00:00.000Z'); - let datePickerOpenSpy; + const datePickerOpenSpy = jest.fn(); beforeEach(() => { - createComponent({ canUpdate: true, dueDate: '2022-12-31', startDate: '2022-12-31' }); - datePickerOpenSpy = jest.spyOn(wrapper.vm.$refs.dueDatePicker, 'show'); + createComponent({ + canUpdate: true, + dueDate: '2022-12-31', + startDate: '2022-12-31', + stubs: { + GlDatepicker: stubComponent(GlDatepicker, { + methods: { + show: datePickerOpenSpy, + }, + }), + }, + }); findStartDatePicker().vm.$emit('input', startDate); findStartDatePicker().vm.$emit('close'); }); diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js index 554c9a4f7b8..6894aa236e3 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -266,7 +266,7 @@ describe('WorkItemLabels component', () => { }); it('skips calling the work item query when missing workItemIid', async () => { - createComponent({ workItemIid: null }); + createComponent({ workItemIid: '' }); await waitForPromises(); expect(workItemQuerySuccess).not.toHaveBeenCalled(); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js index b06be6c8083..cd077fbf705 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js @@ -6,16 +6,28 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue'; import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { childrenWorkItems, workItemByIidResponseFactory } from '../../mock_data'; +import { + changeWorkItemParentMutationResponse, + childrenWorkItems, + updateWorkItemMutationErrorResponse, + workItemByIidResponseFactory, +} from '../../mock_data'; describe('WorkItemChildrenWrapper', () => { let wrapper; + const $toast = { + show: jest.fn(), + }; const getWorkItemQueryHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory()); + const updateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(changeWorkItemParentMutationResponse); const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild); @@ -25,18 +37,33 @@ describe('WorkItemChildrenWrapper', () => { workItemType = 'Objective', confidential = false, children = childrenWorkItems, + mutationHandler = updateWorkItemMutationHandler, } = {}) => { + const mockApollo = createMockApollo([ + [workItemByIidQuery, getWorkItemQueryHandler], + [updateWorkItemMutation, mutationHandler], + ]); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: workItemByIidQuery, + variables: { fullPath: 'test/project', iid: '1' }, + data: workItemByIidResponseFactory().data, + }); + wrapper = shallowMountExtended(WorkItemChildrenWrapper, { - apolloProvider: createMockApollo([[workItemByIidQuery, getWorkItemQueryHandler]]), + apolloProvider: mockApollo, provide: { fullPath: 'test/project', }, propsData: { workItemType, workItemId: 'gid://gitlab/WorkItem/515', + workItemIid: '1', confidential, children, - fetchByIid: true, + }, + mocks: { + $toast, }, }); }; @@ -51,16 +78,6 @@ describe('WorkItemChildrenWrapper', () => { ); }); - it('remove event on child triggers `removeChild` event', () => { - createComponent(); - const workItem = { id: 'gid://gitlab/WorkItem/2' }; - const firstChild = findWorkItemLinkChildItems().at(0); - - firstChild.vm.$emit('removeChild', workItem); - - expect(wrapper.emitted('removeChild')).toEqual([[workItem]]); - }); - it('emits `show-modal` on `click` event', () => { createComponent(); const firstChild = findWorkItemLinkChildItems().at(0); @@ -95,4 +112,47 @@ describe('WorkItemChildrenWrapper', () => { } }, ); + + describe('when removing child work item', () => { + const workItem = { id: 'gid://gitlab/WorkItem/2' }; + + describe('when successful', () => { + beforeEach(async () => { + createComponent(); + findWorkItemLinkChildItems().at(0).vm.$emit('removeChild', workItem); + await waitForPromises(); + }); + + it('calls a mutation to update the work item', () => { + expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: workItem.id, + hierarchyWidget: { + parentId: null, + }, + }, + }); + }); + + it('shows a toast', () => { + expect($toast.show).toHaveBeenCalledWith('Child removed', { + action: { onClick: expect.anything(), text: 'Undo' }, + }); + }); + }); + + describe('when not successful', () => { + beforeEach(async () => { + createComponent({ + mutationHandler: jest.fn().mockResolvedValue(updateWorkItemMutationErrorResponse), + }); + findWorkItemLinkChildItems().at(0).vm.$emit('removeChild', workItem); + await waitForPromises(); + }); + + it('emits an error message', () => { + expect(wrapper.emitted('error')).toEqual([['Something went wrong while removing child.']]); + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index 786f8604039..dd46505bd65 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -4,7 +4,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import setWindowLocation from 'helpers/set_window_location_helper'; -import { stubComponent } from 'helpers/stub_component'; +import { RENDER_ALL_SLOTS_TEMPLATE, stubComponent } from 'helpers/stub_component'; import issueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import { resolvers } from '~/graphql_shared/issuable_client'; import WidgetWrapper from '~/work_items/components/widget_wrapper.vue'; @@ -13,19 +13,14 @@ import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/wor import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import { FORM_TYPES } from '~/work_items/constants'; -import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; -import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { getIssueDetailsResponse, workItemHierarchyResponse, workItemHierarchyEmptyResponse, workItemHierarchyNoUpdatePermissionResponse, - changeWorkItemParentMutationResponse, workItemByIidResponseFactory, - workItemQueryResponse, mockWorkItemCommentNote, - childrenWorkItems, } from '../../mock_data'; Vue.use(VueApollo); @@ -36,66 +31,48 @@ describe('WorkItemLinks', () => { let wrapper; let mockApollo; - const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2'; - - const $toast = { - show: jest.fn(), - }; - - const mutationChangeParentHandler = jest - .fn() - .mockResolvedValue(changeWorkItemParentMutationResponse); - const childWorkItemByIidHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory()); const responseWithAddChildPermission = jest.fn().mockResolvedValue(workItemHierarchyResponse); const responseWithoutAddChildPermission = jest .fn() .mockResolvedValue(workItemByIidResponseFactory({ adminParentLink: false })); const createComponent = async ({ - data = {}, fetchHandler = responseWithAddChildPermission, - mutationHandler = mutationChangeParentHandler, issueDetailsQueryHandler = jest.fn().mockResolvedValue(getIssueDetailsResponse()), hasIterationsFeature = false, } = {}) => { mockApollo = createMockApollo( [ - [workItemQuery, fetchHandler], - [changeWorkItemParentMutation, mutationHandler], + [workItemByIidQuery, fetchHandler], [issueDetailsQuery, issueDetailsQueryHandler], - [workItemByIidQuery, childWorkItemByIidHandler], ], resolvers, { addTypename: true }, ); wrapper = shallowMountExtended(WorkItemLinks, { - data() { - return { - ...data, - }; - }, provide: { fullPath: 'project/path', hasIterationsFeature, reportAbusePath: '/report/abuse/path', }, - propsData: { issuableId: 1 }, - apolloProvider: mockApollo, - mocks: { - $toast, + propsData: { + issuableId: 1, + issuableIid: 1, }, + apolloProvider: mockApollo, stubs: { WorkItemDetailModal: stubComponent(WorkItemDetailModal, { methods: { show: showModal, }, }), + WidgetWrapper: stubComponent(WidgetWrapper, { + template: RENDER_ALL_SLOTS_TEMPLATE, + }), }, }); - wrapper.vm.$refs.wrapper.show = jest.fn(); - await waitForPromises(); }; @@ -122,8 +99,7 @@ describe('WorkItemLinks', () => { `( '$expectedAssertion "Add" button in hierarchy widget header when "userPermissions.adminParentLink" is $value', async ({ workItemFetchHandler, value }) => { - createComponent({ fetchHandler: workItemFetchHandler }); - await waitForPromises(); + await createComponent({ fetchHandler: workItemFetchHandler }); expect(findToggleFormDropdown().exists()).toBe(value); }, @@ -159,24 +135,6 @@ describe('WorkItemLinks', () => { expect(findAddLinksForm().exists()).toBe(false); }); - - it('adds work item child from the form', async () => { - const workItem = { - ...workItemQueryResponse.data.workItem, - id: 'gid://gitlab/WorkItem/11', - }; - await createComponent(); - findToggleFormDropdown().vm.$emit('click'); - findToggleCreateFormButton().vm.$emit('click'); - await nextTick(); - - expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(4); - - findAddLinksForm().vm.$emit('addWorkItemChild', workItem); - await waitForPromises(); - - expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(5); - }); }); describe('when no child links', () => { @@ -230,50 +188,6 @@ describe('WorkItemLinks', () => { }); }); - describe('remove child', () => { - let firstChild; - - beforeEach(async () => { - await createComponent({ mutationHandler: mutationChangeParentHandler }); - - [firstChild] = childrenWorkItems; - }); - - it('calls correct mutation with correct variables', async () => { - findWorkItemLinkChildrenWrapper().vm.$emit('removeChild', firstChild); - - await waitForPromises(); - - expect(mutationChangeParentHandler).toHaveBeenCalledWith({ - input: { - id: WORK_ITEM_ID, - hierarchyWidget: { - parentId: null, - }, - }, - }); - }); - - it('shows toast when mutation succeeds', async () => { - findWorkItemLinkChildrenWrapper().vm.$emit('removeChild', firstChild); - - await waitForPromises(); - - expect($toast.show).toHaveBeenCalledWith('Child removed', { - action: { onClick: expect.anything(), text: 'Undo' }, - }); - }); - - it('renders correct number of children after removal', async () => { - expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(4); - - findWorkItemLinkChildrenWrapper().vm.$emit('removeChild', firstChild); - await waitForPromises(); - - expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(3); - }); - }); - describe('when parent item is confidential', () => { it('passes correct confidentiality status to form', async () => { await createComponent({ @@ -289,16 +203,6 @@ describe('WorkItemLinks', () => { }); }); - it('starts prefetching work item by iid if URL contains work_item_iid query parameter', async () => { - setWindowLocation('?work_item_iid=5'); - await createComponent(); - - expect(childWorkItemByIidHandler).toHaveBeenCalledWith({ - iid: '5', - fullPath: 'project/path', - }); - }); - it('does not open the modal if work item iid URL parameter is not found in child items', async () => { setWindowLocation('?work_item_iid=555'); await createComponent(); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js index 06716584879..f3aa347f389 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js @@ -1,6 +1,7 @@ import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WidgetWrapper from '~/work_items/components/widget_wrapper.vue'; import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue'; import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue'; @@ -19,6 +20,7 @@ describe('WorkItemTree', () => { const findEmptyState = () => wrapper.findByTestId('tree-empty'); const findToggleFormSplitButton = () => wrapper.findComponent(OkrActionsSplitButton); const findForm = () => wrapper.findComponent(WorkItemLinksForm); + const findWidgetWrapper = () => wrapper.findComponent(WidgetWrapper); const findWorkItemLinkChildrenWrapper = () => wrapper.findComponent(WorkItemChildrenWrapper); const createComponent = ({ @@ -70,6 +72,16 @@ describe('WorkItemTree', () => { expect(findForm().exists()).toBe(false); }); + it('shows an error message on error', async () => { + const errorMessage = 'Some error'; + createComponent(); + + findWorkItemLinkChildrenWrapper().vm.$emit('error', errorMessage); + await nextTick(); + + expect(findWidgetWrapper().props('error')).toBe(errorMessage); + }); + it.each` option | event | formType | childType ${'New objective'} | ${'showCreateObjectiveForm'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE} |