diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-16 12:10:11 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-16 12:10:11 +0300 |
commit | b1a0a71628cb4531f3b9a2999f5aa4d22f6ac5fb (patch) | |
tree | af69c74ae863c73a051297ce5b6b8462460102e9 /spec | |
parent | 0e0890828e6574c6bbc3fd2518fe8ffb5a3dd13e (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r-- | spec/features/boards/issue_ordering_spec.rb | 35 | ||||
-rw-r--r-- | spec/frontend/boards/board_list_spec.js | 16 | ||||
-rw-r--r-- | spec/frontend/work_items/components/notes/work_item_add_note_spec.js (renamed from spec/frontend/work_items/components/work_item_comment_form_spec.js) | 104 | ||||
-rw-r--r-- | spec/frontend/work_items/components/notes/work_item_comment_form_spec.js | 164 | ||||
-rw-r--r-- | spec/frontend/work_items/components/notes/work_item_comment_locked_spec.js (renamed from spec/frontend/work_items/components/work_item_comment_locked_spec.js) | 2 | ||||
-rw-r--r-- | spec/frontend/work_items/components/notes/work_item_discussion_spec.js | 23 | ||||
-rw-r--r-- | spec/frontend/work_items/components/notes/work_item_note_actions_spec.js | 23 | ||||
-rw-r--r-- | spec/frontend/work_items/components/notes/work_item_note_spec.js | 248 | ||||
-rw-r--r-- | spec/frontend/work_items/components/work_item_notes_spec.js | 12 | ||||
-rw-r--r-- | spec/frontend/work_items/mock_data.js | 46 | ||||
-rw-r--r-- | spec/models/environment_spec.rb | 28 |
11 files changed, 567 insertions, 134 deletions
diff --git a/spec/features/boards/issue_ordering_spec.rb b/spec/features/boards/issue_ordering_spec.rb index f1ee7a8fde7..8aecaab42c2 100644 --- a/spec/features/boards/issue_ordering_spec.rb +++ b/spec/features/boards/issue_ordering_spec.rb @@ -130,6 +130,41 @@ RSpec.describe 'Issue Boards', :js, feature_category: :team_planning do end end + context 'ordering in list using move to position' do + let(:move_to_position) { find('[data-testid="board-move-to-position"]') } + + before do + visit project_board_path(project, board) + wait_for_requests + end + + it 'moves to end of list' do + expect(all('.board-card').first).to have_content(issue3.title) + + page.within(find('.board:nth-child(2)')) do + first('.board-card').hover + move_to_position.click + + click_button 'Move to end of list' + end + + expect(all('.board-card').last).to have_content(issue3.title) + end + + it 'moves to start of list' do + expect(all('.board-card').last).to have_content(issue1.title) + + page.within(find('.board:nth-child(2)')) do + all('.board-card').last.hover + move_to_position.click + + click_button 'Move to start of list' + end + + expect(all('.board-card').first).to have_content(issue1.title) + end + end + context 'ordering when changing list' do let(:label2) { create(:label, project: project) } let!(:list2) { create(:list, board: board, label: label2, position: 1) } diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 543186a8009..fc8dbf8dc3a 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -1,6 +1,6 @@ import Draggable from 'vuedraggable'; import { nextTick } from 'vue'; -import { DraggableItemTypes } from 'ee_else_ce/boards/constants'; +import { DraggableItemTypes, ListType } from 'ee_else_ce/boards/constants'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import waitForPromises from 'helpers/wait_for_promises'; import createComponent from 'jest/boards/board_list_helper'; @@ -107,6 +107,20 @@ describe('Board list component', () => { }); }); + describe('when ListType is Closed', () => { + beforeEach(() => { + wrapper = createComponent({ + listProps: { + listType: ListType.closed, + }, + }); + }); + + it('Board card move to position is not visible', () => { + expect(findMoveToPositionComponent().exists()).toBe(false); + }); + }); + describe('load more issues', () => { const actions = { fetchItemsForList: jest.fn(), diff --git a/spec/frontend/work_items/components/work_item_comment_form_spec.js b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js index bef7efa2536..2a65e91a906 100644 --- a/spec/frontend/work_items/components/work_item_comment_form_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js @@ -5,21 +5,23 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { updateDraft } from '~/lib/utils/autosave'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import WorkItemCommentForm from '~/work_items/components/work_item_comment_form.vue'; -import WorkItemCommentLocked from '~/work_items/components/work_item_comment_locked.vue'; +import { clearDraft } from '~/lib/utils/autosave'; +import { config } from '~/graphql_shared/issuable_client'; +import WorkItemAddNote from '~/work_items/components/notes/work_item_add_note.vue'; +import WorkItemCommentLocked from '~/work_items/components/notes/work_item_comment_locked.vue'; +import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue'; import createNoteMutation from '~/work_items/graphql/notes/create_work_item_note.mutation.graphql'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import workItemNotesQuery from '~/work_items/graphql/notes/work_item_notes.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { workItemResponseFactory, workItemQueryResponse, projectWorkItemResponse, createWorkItemNoteResponse, -} from '../mock_data'; + mockWorkItemNotesResponse, +} from '../../mock_data'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); jest.mock('~/lib/utils/autosave'); @@ -35,18 +37,7 @@ describe('WorkItemCommentForm', () => { const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse); let workItemResponseHandler; - const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); - - const setText = (newText) => { - return findMarkdownEditor().vm.$emit('input', newText); - }; - - const clickSave = () => - wrapper - .findAllComponents(GlButton) - .filter((button) => button.text().startsWith('Comment')) - .at(0) - .vm.$emit('click', {}); + const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm); const createComponent = async ({ mutationHandler = mutationSuccessHandler, @@ -65,13 +56,28 @@ describe('WorkItemCommentForm', () => { window.gon.current_user_avatar_url = 'avatar.png'; } - const { id } = workItemQueryResponse.data.workItem; - wrapper = shallowMount(WorkItemCommentForm, { - apolloProvider: createMockApollo([ + const apolloProvider = createMockApollo( + [ [workItemQuery, workItemResponseHandler], [createNoteMutation, mutationHandler], [workItemByIidQuery, workItemByIidResponseHandler], - ]), + ], + {}, + { ...config.cacheConfig }, + ); + + apolloProvider.clients.defaultClient.writeQuery({ + query: workItemNotesQuery, + variables: { + id: workItemId, + pageSize: 100, + }, + data: mockWorkItemNotesResponse.data, + }); + + const { id } = workItemQueryResponse.data.workItem; + wrapper = shallowMount(WorkItemAddNote, { + apolloProvider, propsData: { workItemId: id, fullPath: 'test-project-path', @@ -80,7 +86,6 @@ describe('WorkItemCommentForm', () => { workItemType, }, stubs: { - MarkdownField, WorkItemCommentLocked, }, }); @@ -101,9 +106,7 @@ describe('WorkItemCommentForm', () => { signedIn: true, }); - setText(noteText); - - clickSave(); + findCommentForm().vm.$emit('submitForm', noteText); await waitForPromises(); @@ -120,9 +123,7 @@ describe('WorkItemCommentForm', () => { await createComponent(); const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - setText('test'); - - clickSave(); + findCommentForm().vm.$emit('submitForm', 'test'); await waitForPromises(); @@ -133,6 +134,33 @@ describe('WorkItemCommentForm', () => { }); }); + it('emits `replied` event and hides form after successful mutation', async () => { + await createComponent({ + isEditing: true, + signedIn: true, + queryVariables: { + id: mockWorkItemNotesResponse.data.workItem.id, + }, + }); + + findCommentForm().vm.$emit('submitForm', 'some text'); + await waitForPromises(); + + expect(wrapper.emitted('replied')).toEqual([[]]); + }); + + it('clears a draft after successful mutation', async () => { + await createComponent({ + isEditing: true, + signedIn: true, + }); + + 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'; @@ -160,9 +188,7 @@ describe('WorkItemCommentForm', () => { }), }); - setText('updated desc'); - - clickSave(); + findCommentForm().vm.$emit('submitForm', 'updated desc'); await waitForPromises(); @@ -177,24 +203,12 @@ describe('WorkItemCommentForm', () => { mutationHandler: jest.fn().mockRejectedValue(new Error(error)), }); - setText('updated desc'); - - clickSave(); + findCommentForm().vm.$emit('submitForm', 'updated desc'); await waitForPromises(); expect(wrapper.emitted('error')).toEqual([[error]]); }); - - it('autosaves', async () => { - await createComponent({ - isEditing: true, - }); - - setText('updated'); - - expect(updateDraft).toHaveBeenCalled(); - }); }); it('calls the global ID work item query when `fetchByIid` prop is false', async () => { 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 new file mode 100644 index 00000000000..23a9f285804 --- /dev/null +++ b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js @@ -0,0 +1,164 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import * as autosave from '~/lib/utils/autosave'; +import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys'; +import * as confirmViaGlModal from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; + +const draftComment = 'draft comment'; + +jest.mock('~/lib/utils/autosave', () => ({ + updateDraft: jest.fn(), + clearDraft: jest.fn(), + getDraft: jest.fn().mockReturnValue(draftComment), +})); +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => ({ + confirmAction: jest.fn().mockResolvedValue(true), +})); + +describe('Work item comment form component', () => { + let wrapper; + + const mockAutosaveKey = 'test-auto-save-key'; + + const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); + const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); + const findConfirmButton = () => wrapper.find('[data-testid="confirm-button"]'); + + const createComponent = ({ isSubmitting = false, initialValue = '' } = {}) => { + wrapper = shallowMount(WorkItemCommentForm, { + propsData: { + workItemType: 'Issue', + ariaLabel: 'test-aria-label', + autosaveKey: mockAutosaveKey, + isSubmitting, + initialValue, + }, + provide: { + fullPath: 'test-project-path', + }, + }); + }; + + it('passes correct markdown preview path to markdown editor', () => { + createComponent(); + + expect(findMarkdownEditor().props('renderMarkdownPath')).toBe( + '/test-project-path/preview_markdown?target_type=Issue', + ); + }); + + it('passes correct form field props to markdown editor', () => { + createComponent(); + + expect(findMarkdownEditor().props('formFieldProps')).toEqual({ + 'aria-label': 'test-aria-label', + id: 'work-item-add-or-edit-comment', + name: 'work-item-add-or-edit-comment', + placeholder: 'Write a comment or drag your files hereโฆ', + }); + }); + + it('passes correct `loading` prop to confirm button', () => { + createComponent({ isSubmitting: true }); + + expect(findConfirmButton().props('loading')).toBe(true); + }); + + it('passes a draft from local storage as a value to markdown editor if the draft exists', () => { + createComponent({ initialValue: 'parent comment' }); + expect(findMarkdownEditor().props('value')).toBe(draftComment); + }); + + it('passes an initialValue prop as a value to markdown editor if storage draft does not exist', () => { + jest.spyOn(autosave, 'getDraft').mockImplementation(() => ''); + createComponent({ initialValue: 'parent comment' }); + + expect(findMarkdownEditor().props('value')).toBe('parent comment'); + }); + + it('passes an empty string as a value to markdown editor if storage draft and initialValue are empty', () => { + createComponent(); + + expect(findMarkdownEditor().props('value')).toBe(''); + }); + + describe('on markdown editor input', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets correct comment text value', async () => { + expect(findMarkdownEditor().props('value')).toBe(''); + + findMarkdownEditor().vm.$emit('input', 'new comment'); + await nextTick(); + + expect(findMarkdownEditor().props('value')).toBe('new comment'); + }); + + it('calls `updateDraft` with correct parameters', async () => { + findMarkdownEditor().vm.$emit('input', 'new comment'); + + expect(autosave.updateDraft).toHaveBeenCalledWith(mockAutosaveKey, 'new comment'); + }); + }); + + describe('on cancel editing', () => { + beforeEach(() => { + jest.spyOn(autosave, 'getDraft').mockImplementation(() => draftComment); + createComponent(); + findMarkdownEditor().vm.$emit('keydown', new KeyboardEvent('keydown', { key: ESC_KEY })); + + return waitForPromises(); + }); + + it('confirms a user action if comment text is not empty', () => { + expect(confirmViaGlModal.confirmAction).toHaveBeenCalled(); + }); + + it('emits `cancelEditing` and clears draft from the local storage', () => { + expect(wrapper.emitted('cancelEditing')).toHaveLength(1); + expect(autosave.clearDraft).toHaveBeenCalledWith(mockAutosaveKey); + }); + }); + + it('cancels editing on clicking cancel button', async () => { + createComponent(); + findCancelButton().vm.$emit('click'); + + await waitForPromises(); + + expect(wrapper.emitted('cancelEditing')).toHaveLength(1); + expect(autosave.clearDraft).toHaveBeenCalledWith(mockAutosaveKey); + }); + + it('emits `submitForm` event on confirm button click', () => { + createComponent(); + findConfirmButton().vm.$emit('click'); + + expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]); + }); + + it('emits `submitForm` event on pressing enter with meta key on markdown editor', () => { + createComponent(); + findMarkdownEditor().vm.$emit( + 'keydown', + new KeyboardEvent('keydown', { key: ENTER_KEY, metaKey: true }), + ); + + expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]); + }); + + it('emits `submitForm` event on pressing ctrl+enter on markdown editor', () => { + createComponent(); + findMarkdownEditor().vm.$emit( + 'keydown', + new KeyboardEvent('keydown', { key: ENTER_KEY, ctrlKey: true }), + ); + + expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_comment_locked_spec.js b/spec/frontend/work_items/components/notes/work_item_comment_locked_spec.js index 58491c4b09c..734b474c8fc 100644 --- a/spec/frontend/work_items/components/work_item_comment_locked_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_comment_locked_spec.js @@ -1,6 +1,6 @@ import { GlLink, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import WorkItemCommentLocked from '~/work_items/components/work_item_comment_locked.vue'; +import WorkItemCommentLocked from '~/work_items/components/notes/work_item_comment_locked.vue'; const createComponent = ({ workItemType = 'Task', isProjectArchived = false } = {}) => shallowMount(WorkItemCommentLocked, { 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 1e2ec7e8dc2..bb65b75c4d8 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 @@ -6,7 +6,7 @@ import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue'; import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue'; import WorkItemNote from '~/work_items/components/notes/work_item_note.vue'; import WorkItemNoteReplying from '~/work_items/components/notes/work_item_note_replying.vue'; -import WorkItemCommentForm from '~/work_items/components/work_item_comment_form.vue'; +import WorkItemAddNote from '~/work_items/components/notes/work_item_add_note.vue'; import { mockWorkItemCommentNote, mockWorkItemNotesResponseWithComments, @@ -27,7 +27,7 @@ describe('Work Item Discussion', () => { const findToggleRepliesWidget = () => wrapper.findComponent(ToggleRepliesWidget); const findAllThreads = () => wrapper.findAllComponents(WorkItemNote); const findThreadAtIndex = (index) => findAllThreads().at(index); - const findWorkItemCommentForm = () => wrapper.findComponent(WorkItemCommentForm); + const findWorkItemAddNote = () => wrapper.findComponent(WorkItemAddNote); const findWorkItemNoteReplying = () => wrapper.findComponent(WorkItemNoteReplying); const createComponent = ({ @@ -73,7 +73,7 @@ describe('Work Item Discussion', () => { }); it('should not show the comment form by default', () => { - expect(findWorkItemCommentForm().exists()).toBe(false); + expect(findWorkItemAddNote().exists()).toBe(false); }); }); @@ -101,8 +101,8 @@ describe('Work Item Discussion', () => { mainComment.vm.$emit('startReplying'); await nextTick(); - expect(findWorkItemCommentForm().exists()).toBe(true); - expect(findWorkItemCommentForm().props('autofocus')).toBe(true); + expect(findWorkItemAddNote().exists()).toBe(true); + expect(findWorkItemAddNote().props('autofocus')).toBe(true); }); }); @@ -115,7 +115,7 @@ describe('Work Item Discussion', () => { mainComment.vm.$emit('startReplying'); await nextTick(); - await findWorkItemCommentForm().vm.$emit('replying', 'reply text'); + await findWorkItemAddNote().vm.$emit('replying', 'reply text'); }); it('should show optimistic behavior when replying', async () => { @@ -124,7 +124,7 @@ describe('Work Item Discussion', () => { }); it('should be expanded when the reply is successful', async () => { - findWorkItemCommentForm().vm.$emit('replied'); + findWorkItemAddNote().vm.$emit('replied'); await nextTick(); expect(findToggleRepliesWidget().exists()).toBe(true); expect(findToggleRepliesWidget().props('collapsed')).toBe(false); @@ -137,4 +137,13 @@ describe('Work Item Discussion', () => { expect(wrapper.emitted('deleteNote')).toEqual([[mockWorkItemCommentNote]]); }); + + it('emits `error` event when child note emits an `error`', () => { + const mockErrorText = 'Houston, we have a problem'; + + createComponent(); + findThreadAtIndex(0).vm.$emit('error', mockErrorText); + + expect(wrapper.emitted('error')).toEqual([[mockErrorText]]); + }); }); 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 f3d0e86ee53..d85cd46c1c3 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 @@ -6,11 +6,13 @@ describe('Work Item Note Actions', () => { let wrapper; const findReplyButton = () => wrapper.findComponent(ReplyButton); + const findEditButton = () => wrapper.find('[data-testid="edit-work-item-note"]'); - const createComponent = ({ showReply = true } = {}) => { + const createComponent = ({ showReply = true, showEdit = true } = {}) => { wrapper = shallowMount(WorkItemNoteActions, { propsData: { showReply, + showEdit, }, }); }; @@ -28,4 +30,23 @@ describe('Work Item Note Actions', () => { expect(findReplyButton().exists()).toBe(false); }); }); + + it('shows edit button when `showEdit` prop is true', () => { + createComponent(); + + expect(findEditButton().exists()).toBe(true); + }); + + it('does not show edit button when `showEdit` prop is false', () => { + createComponent({ showEdit: false }); + + expect(findEditButton().exists()).toBe(false); + }); + + it('emits `startEditing` event when edit button is clicked', () => { + createComponent(); + findEditButton().vm.$emit('click'); + + expect(wrapper.emitted('startEditing')).toEqual([[]]); + }); }); 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 8f7d27def15..9b87419cee7 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 @@ -1,14 +1,41 @@ import { GlAvatarLink, GlDropdown } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import mockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { updateDraft } from '~/lib/utils/autosave'; +import EditedAt from '~/issues/show/components/edited.vue'; import WorkItemNote from '~/work_items/components/notes/work_item_note.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import NoteBody from '~/work_items/components/notes/work_item_note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue'; +import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue'; +import updateWorkItemNoteMutation from '~/work_items/graphql/notes/update_work_item_note.mutation.graphql'; import { mockWorkItemCommentNote } from 'jest/work_items/mock_data'; +Vue.use(VueApollo); +jest.mock('~/lib/utils/autosave'); + describe('Work Item Note', () => { let wrapper; + const updatedNoteText = '# Some title'; + const updatedNoteBody = '<h1 data-sourcepos="1:1-1:12" dir="auto">Some title</h1>'; + + const successHandler = jest.fn().mockResolvedValue({ + data: { + updateNote: { + errors: [], + note: { + ...mockWorkItemCommentNote, + body: updatedNoteText, + bodyHtml: updatedNoteBody, + }, + }, + }, + }); + const errorHandler = jest.fn().mockRejectedValue('Oops'); const findAuthorAvatarLink = () => wrapper.findComponent(GlAvatarLink); const findTimelineEntryItem = () => wrapper.findComponent(TimelineEntryItem); @@ -16,86 +43,219 @@ describe('Work Item Note', () => { const findNoteBody = () => wrapper.findComponent(NoteBody); const findNoteActions = () => wrapper.findComponent(NoteActions); const findDropdown = () => wrapper.findComponent(GlDropdown); + const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm); + const findEditedAt = () => wrapper.findComponent(EditedAt); + const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]'); + const findNoteWrapper = () => wrapper.find('[data-testid="note-wrapper"]'); - const createComponent = ({ note = mockWorkItemCommentNote, isFirstNote = false } = {}) => { + const createComponent = ({ + note = mockWorkItemCommentNote, + isFirstNote = false, + updateNoteMutationHandler = successHandler, + } = {}) => { wrapper = shallowMount(WorkItemNote, { propsData: { note, isFirstNote, + workItemType: 'Task', }, + apolloProvider: mockApollo([[updateWorkItemNoteMutation, updateNoteMutationHandler]]), }); }; - describe('Main comment', () => { + describe('when editing', () => { beforeEach(() => { - createComponent({ isFirstNote: true }); + createComponent(); + findNoteActions().vm.$emit('startEditing'); + return nextTick(); }); - it('Should have the note header, actions and body', () => { - expect(findTimelineEntryItem().exists()).toBe(true); - expect(findNoteHeader().exists()).toBe(true); - expect(findNoteBody().exists()).toBe(true); - expect(findNoteActions().exists()).toBe(true); + it('should render a comment form', () => { + expect(findCommentForm().exists()).toBe(true); }); - it('Should not have the Avatar link for main thread inside the timeline-entry', () => { - expect(findAuthorAvatarLink().exists()).toBe(false); + it('should not render note wrapper', () => { + expect(findNoteWrapper().exists()).toBe(false); }); - it('Should have the reply button props', () => { - expect(findNoteActions().props('showReply')).toBe(true); + it('updates saved draft with current note text', () => { + expect(updateDraft).toHaveBeenCalledWith( + `${mockWorkItemCommentNote.id}-comment`, + mockWorkItemCommentNote.body, + ); + }); + + it('passes correct autosave key prop to comment form component', () => { + expect(findCommentForm().props('autosaveKey')).toBe(`${mockWorkItemCommentNote.id}-comment`); + }); + + it('should hide a form and show wrapper when user cancels editing', async () => { + findCommentForm().vm.$emit('cancelEditing'); + await nextTick(); + + expect(findCommentForm().exists()).toBe(false); + expect(findNoteWrapper().exists()).toBe(true); }); }); - describe('Comment threads', () => { - beforeEach(() => { + describe('when submitting a form to edit a note', () => { + it('calls update mutation with correct variables', async () => { createComponent(); - }); + findNoteActions().vm.$emit('startEditing'); + await nextTick(); - it('Should have the note header, actions and body', () => { - expect(findTimelineEntryItem().exists()).toBe(true); - expect(findNoteHeader().exists()).toBe(true); - expect(findNoteBody().exists()).toBe(true); - expect(findNoteActions().exists()).toBe(true); + findCommentForm().vm.$emit('submitForm', updatedNoteText); + + expect(successHandler).toHaveBeenCalledWith({ + input: { + id: mockWorkItemCommentNote.id, + body: updatedNoteText, + }, + }); }); - it('Should have the Avatar link for comment threads', () => { - expect(findAuthorAvatarLink().exists()).toBe(true); + it('hides the form after succesful mutation', async () => { + createComponent(); + findNoteActions().vm.$emit('startEditing'); + await nextTick(); + + findCommentForm().vm.$emit('submitForm', updatedNoteText); + await waitForPromises(); + + expect(findCommentForm().exists()).toBe(false); }); - it('Should not have the reply button props', () => { - expect(findNoteActions().props('showReply')).toBe(false); + describe('when mutation fails', () => { + beforeEach(async () => { + createComponent({ updateNoteMutationHandler: errorHandler }); + findNoteActions().vm.$emit('startEditing'); + await nextTick(); + + findCommentForm().vm.$emit('submitForm', updatedNoteText); + await waitForPromises(); + }); + + it('opens the form again', () => { + expect(findCommentForm().exists()).toBe(true); + }); + + it('updates the saved draft with the latest comment text', () => { + expect(updateDraft).toHaveBeenCalledWith( + `${mockWorkItemCommentNote.id}-comment`, + updatedNoteText, + ); + }); + + it('emits an error', () => { + expect(wrapper.emitted('error')).toHaveLength(1); + }); }); }); - it('should display a dropdown if user has a permission to delete note', () => { - createComponent({ - note: { - ...mockWorkItemCommentNote, - userPermissions: { ...mockWorkItemCommentNote.userPermissions, adminNote: true }, - }, + describe('when not editing', () => { + it('should not render a comment form', () => { + createComponent(); + expect(findCommentForm().exists()).toBe(false); }); - expect(findDropdown().exists()).toBe(true); - }); + it('should render note wrapper', () => { + createComponent(); + expect(findNoteWrapper().exists()).toBe(true); + }); - it('should not display a dropdown if user has no permission to delete note', () => { - createComponent(); + it('renders no "edited at" information by default', () => { + createComponent(); + expect(findEditedAt().exists()).toBe(false); + }); - expect(findDropdown().exists()).toBe(false); - }); + it('renders "edited at" information if the note was edited', () => { + createComponent({ + note: { + ...mockWorkItemCommentNote, + lastEditedAt: '2023-02-12T07:47:40Z', + lastEditedBy: { ...mockWorkItemCommentNote.author, webPath: 'test-path' }, + }, + }); - it('should emit `deleteNote` event when delete note action is clicked', () => { - createComponent({ - note: { - ...mockWorkItemCommentNote, - userPermissions: { ...mockWorkItemCommentNote.userPermissions, adminNote: true }, - }, + expect(findEditedAt().exists()).toBe(true); + expect(findEditedAt().props()).toEqual({ + updatedAt: '2023-02-12T07:47:40Z', + updatedByName: 'Administrator', + updatedByPath: 'test-path', + }); + }); + + describe('main comment', () => { + beforeEach(() => { + createComponent({ isFirstNote: true }); + }); + + it('should have the note header, actions and body', () => { + expect(findTimelineEntryItem().exists()).toBe(true); + expect(findNoteHeader().exists()).toBe(true); + expect(findNoteBody().exists()).toBe(true); + expect(findNoteActions().exists()).toBe(true); + }); + + it('should not have the Avatar link for main thread inside the timeline-entry', () => { + expect(findAuthorAvatarLink().exists()).toBe(false); + }); + + it('should have the reply button props', () => { + expect(findNoteActions().props('showReply')).toBe(true); + }); + }); + + describe('comment threads', () => { + beforeEach(() => { + createComponent(); + }); + + it('should have the note header, actions and body', () => { + expect(findTimelineEntryItem().exists()).toBe(true); + expect(findNoteHeader().exists()).toBe(true); + expect(findNoteBody().exists()).toBe(true); + expect(findNoteActions().exists()).toBe(true); + }); + + it('should have the Avatar link for comment threads', () => { + expect(findAuthorAvatarLink().exists()).toBe(true); + }); + + it('should not have the reply button props', () => { + expect(findNoteActions().props('showReply')).toBe(false); + }); + }); + + it('should display a dropdown if user has a permission to delete a note', () => { + createComponent({ + note: { + ...mockWorkItemCommentNote, + userPermissions: { ...mockWorkItemCommentNote.userPermissions, adminNote: true }, + }, + }); + + expect(findDropdown().exists()).toBe(true); }); - findDeleteNoteButton().vm.$emit('click'); + it('should not display a dropdown if user has no permission to delete a note', () => { + createComponent(); + + expect(findDropdown().exists()).toBe(false); + }); - expect(wrapper.emitted('deleteNote')).toEqual([[]]); + it('should emit `deleteNote` event when delete note action is clicked', () => { + createComponent({ + note: { + ...mockWorkItemCommentNote, + userPermissions: { ...mockWorkItemCommentNote.userPermissions, adminNote: true }, + }, + }); + + findDeleteNoteButton().vm.$emit('click'); + + expect(wrapper.emitted('deleteNote')).toEqual([[]]); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_notes_spec.js b/spec/frontend/work_items/components/work_item_notes_spec.js index e5b4bee68a8..3db848a0ad2 100644 --- a/spec/frontend/work_items/components/work_item_notes_spec.js +++ b/spec/frontend/work_items/components/work_item_notes_spec.js @@ -8,7 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import SystemNote from '~/work_items/components/notes/system_note.vue'; import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue'; -import WorkItemCommentForm from '~/work_items/components/work_item_comment_form.vue'; +import WorkItemAddNote from '~/work_items/components/notes/work_item_add_note.vue'; import ActivityFilter from '~/work_items/components/notes/activity_filter.vue'; import workItemNotesQuery from '~/work_items/graphql/notes/work_item_notes.query.graphql'; import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql'; @@ -54,7 +54,7 @@ describe('WorkItemNotes component', () => { const findAllSystemNotes = () => wrapper.findAllComponents(SystemNote); const findAllListItems = () => wrapper.findAll('ul.timeline > *'); const findActivityLabel = () => wrapper.find('label'); - const findWorkItemCommentForm = () => wrapper.findComponent(WorkItemCommentForm); + const findWorkItemAddNote = () => wrapper.findComponent(WorkItemAddNote); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findSortingFilter = () => wrapper.findComponent(ActivityFilter); const findSystemNoteAtIndex = (index) => findAllSystemNotes().at(index); @@ -123,7 +123,7 @@ describe('WorkItemNotes component', () => { }); await waitForPromises(); - expect(findWorkItemCommentForm().props('fetchByIid')).toEqual(false); + expect(findWorkItemAddNote().props('fetchByIid')).toEqual(false); }); describe('when notes are loading', () => { @@ -161,7 +161,7 @@ describe('WorkItemNotes component', () => { }); it('passes correct props to comment form component', () => { - expect(findWorkItemCommentForm().props('fetchByIid')).toEqual(true); + expect(findWorkItemAddNote().props('fetchByIid')).toEqual(true); }); }); @@ -218,13 +218,13 @@ describe('WorkItemNotes component', () => { it('puts form at start of list in when sorting by newest first', async () => { await findSortingFilter().vm.$emit('changeSortOrder', DESC); - expect(findAllListItems().at(0).is(WorkItemCommentForm)).toEqual(true); + expect(findAllListItems().at(0).is(WorkItemAddNote)).toEqual(true); }); it('puts form at end of list in when sorting by oldest first', async () => { await findSortingFilter().vm.$emit('changeSortOrder', ASC); - expect(findAllListItems().at(-1).is(WorkItemCommentForm)).toEqual(true); + expect(findAllListItems().at(-1).is(WorkItemAddNote)).toEqual(true); }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index a74e01db0da..d4832fe376d 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -1631,7 +1631,7 @@ export const projectWorkItemResponse = { export const mockWorkItemNotesResponse = { data: { workItem: { - id: 'gid://gitlab/WorkItem/600', + id: 'gid://gitlab/WorkItem/1', iid: '60', widgets: [ { @@ -1675,10 +1675,13 @@ export const mockWorkItemNotesResponse = { nodes: [ { id: 'gid://gitlab/Note/2428', + body: 'added #31 as parent issue', bodyHtml: '<p data-sourcepos="1:1-1:25" dir="auto">added <a href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container=body data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue">#31</a> as parent issue</p>', systemNoteIconName: 'link', createdAt: '2022-11-14T04:18:59Z', + lastEditedAt: null, + lastEditedBy: null, system: true, internal: false, discussion: { @@ -1715,10 +1718,13 @@ export const mockWorkItemNotesResponse = { nodes: [ { id: 'gid://gitlab/MilestoneNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864', + body: 'changed milestone to %v4.0', bodyHtml: '<p data-sourcepos="1:1-1:23" dir="auto">changed milestone to <a href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container=body data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a></p>', systemNoteIconName: 'clock', createdAt: '2022-11-14T04:18:59Z', + lastEditedAt: null, + lastEditedBy: null, system: true, internal: false, discussion: { @@ -1755,9 +1761,12 @@ export const mockWorkItemNotesResponse = { nodes: [ { id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a9883864', + body: 'changed weight to **89**', bodyHtml: '<p dir="auto">changed weight to <strong>89</strong></p>', systemNoteIconName: 'weight', createdAt: '2022-11-25T07:16:20Z', + lastEditedAt: null, + lastEditedBy: null, system: true, internal: false, discussion: { @@ -1853,10 +1862,13 @@ export const mockWorkItemNotesByIidResponse = { nodes: [ { id: 'gid://gitlab/Note/2428', + body: 'added as parent issue', bodyHtml: '\u003cp data-sourcepos="1:1-1:25" dir="auto"\u003eadded \u003ca href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container="body" data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue"\u003e#31\u003c/a\u003e as parent issue\u003c/p\u003e', systemNoteIconName: 'link', createdAt: '2022-11-14T04:18:59Z', + lastEditedAt: null, + lastEditedBy: null, system: true, internal: false, discussion: { @@ -1895,10 +1907,13 @@ export const mockWorkItemNotesByIidResponse = { { id: 'gid://gitlab/MilestoneNote/7b08b89a728a5ceb7de8334246837ba1d07270dc', + body: 'changed milestone to %v4.0', bodyHtml: '\u003cp data-sourcepos="1:1-1:23" dir="auto"\u003echanged milestone to \u003ca href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container="body" data-placement="top" title="" class="gfm gfm-milestone has-tooltip"\u003e%v4.0\u003c/a\u003e\u003c/p\u003e', systemNoteIconName: 'clock', createdAt: '2022-11-14T04:18:59Z', + lastEditedAt: null, + lastEditedBy: null, system: true, internal: false, discussion: { @@ -1937,10 +1952,14 @@ export const mockWorkItemNotesByIidResponse = { { id: 'gid://gitlab/IterationNote/addbc177f7664699a135130ab05ffb78c57e4db3', + body: + 'changed iteration to Et autem debitis nam suscipit eos ut. Jul 13, 2022 - Jul 19, 2022', bodyHtml: '\u003cp data-sourcepos="1:1-1:36" dir="auto"\u003echanged iteration to \u003ca href="/groups/flightjs/-/iterations/5352" data-reference-type="iteration" data-original="*iteration:5352" data-link="false" data-link-reference="false" data-project="6" data-iteration="5352" data-container="body" data-placement="top" title="Iteration" class="gfm gfm-iteration has-tooltip"\u003eEt autem debitis nam suscipit eos ut. Jul 13, 2022 - Jul 19, 2022\u003c/a\u003e\u003c/p\u003e', systemNoteIconName: 'iteration', createdAt: '2022-11-14T04:19:00Z', + lastEditedAt: null, + lastEditedBy: null, system: true, internal: false, discussion: { @@ -2034,10 +2053,13 @@ export const mockMoreWorkItemNotesResponse = { nodes: [ { id: 'gid://gitlab/Note/2428', + body: 'added #31 as parent issue', bodyHtml: '<p data-sourcepos="1:1-1:25" dir="auto">added <a href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container=body data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue">#31</a> as parent issue</p>', systemNoteIconName: 'link', createdAt: '2022-11-14T04:18:59Z', + lastEditedAt: null, + lastEditedBy: null, system: true, internal: false, discussion: { @@ -2074,10 +2096,13 @@ export const mockMoreWorkItemNotesResponse = { nodes: [ { id: 'gid://gitlab/MilestoneNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83823', + body: 'changed milestone to %v4.0', bodyHtml: '<p data-sourcepos="1:1-1:23" dir="auto">changed milestone to <a href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container=body data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a></p>', systemNoteIconName: 'clock', createdAt: '2022-11-14T04:18:59Z', + lastEditedAt: null, + lastEditedBy: null, system: true, internal: false, discussion: { @@ -2114,9 +2139,12 @@ export const mockMoreWorkItemNotesResponse = { nodes: [ { id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864', + body: 'changed weight to **89**', bodyHtml: '<p dir="auto">changed weight to <strong>89</strong></p>', systemNoteIconName: 'weight', createdAt: '2022-11-25T07:16:20Z', + lastEditedAt: null, + lastEditedBy: null, system: true, internal: false, discussion: { @@ -2163,17 +2191,21 @@ export const createWorkItemNoteResponse = { createNote: { errors: [], note: { + id: 'gid://gitlab/Note/569', discussion: { id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', notes: { nodes: [ { id: 'gid://gitlab/Note/569', + body: 'Main comment', bodyHtml: '<p data-sourcepos="1:1-1:9" dir="auto">Main comment</p>', system: false, internal: false, systemNoteIconName: null, createdAt: '2023-01-25T04:49:46Z', + lastEditedAt: null, + lastEditedBy: null, discussion: { id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', __typename: 'Discussion', @@ -2214,10 +2246,13 @@ export const createWorkItemNoteResponse = { export const mockWorkItemCommentNote = { id: 'gid://gitlab/Note/158', + body: 'How are you ? what do you think about this ?', bodyHtml: '<p data-sourcepos="1:1-1:76" dir="auto"><gl-emoji title="waving hand sign" data-name="wave" data-unicode-version="6.0">๐</gl-emoji> Hi <a href="/fredda.brekke" data-reference-type="user" data-user="3" data-container="body" data-placement="top" class="gfm gfm-project_member js-user-link" title="Sherie Nitzsche">@fredda.brekke</a> How are you ? what do you think about this ? <gl-emoji title="person with folded hands" data-name="pray" data-unicode-version="6.0">๐</gl-emoji></p>', systemNoteIconName: false, createdAt: '2022-11-25T07:16:20Z', + lastEditedAt: null, + lastEditedBy: null, system: false, internal: false, discussion: { @@ -2289,11 +2324,14 @@ export const mockWorkItemNotesResponseWithComments = { nodes: [ { id: 'gid://gitlab/DiscussionNote/174', + body: 'Separate thread', bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Separate thread</p>', system: false, internal: false, systemNoteIconName: null, createdAt: '2023-01-12T07:47:40Z', + lastEditedAt: null, + lastEditedBy: null, discussion: { id: 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3', __typename: 'Discussion', @@ -2320,11 +2358,14 @@ export const mockWorkItemNotesResponseWithComments = { }, { id: 'gid://gitlab/DiscussionNote/235', + body: 'Thread comment', bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Thread comment</p>', system: false, internal: false, systemNoteIconName: null, createdAt: '2023-01-18T09:09:54Z', + lastEditedAt: null, + lastEditedBy: null, discussion: { id: 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3', __typename: 'Discussion', @@ -2360,9 +2401,12 @@ export const mockWorkItemNotesResponseWithComments = { nodes: [ { id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a9883864', + body: 'Main thread 2', bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Main thread 2</p>', systemNoteIconName: 'weight', createdAt: '2022-11-25T07:16:20Z', + lastEditedAt: null, + lastEditedBy: null, system: false, internal: false, discussion: { diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 23de9a50e50..dfb7de34993 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -91,34 +91,6 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ end end - describe 'preloading deployment associations' do - let!(:environment) { create(:environment, project: project) } - - associations = [:last_deployment, :last_visible_deployment, :upcoming_deployment] - associations.concat Deployment::FINISHED_STATUSES.map { |status| "last_#{status}_deployment".to_sym } - associations.concat Deployment::UPCOMING_STATUSES.map { |status| "last_#{status}_deployment".to_sym } - - context 'raises error for legacy approach' do - let!(:error_pattern) { /Preloading instance dependent scopes is not supported/ } - - subject { described_class.preload(association_name).find_by(id: environment) } - - shared_examples 'raises error' do - it do - expect { subject }.to raise_error(error_pattern) - end - end - - associations.each do |association| - context association.to_s do - let!(:association_name) { association } - - include_examples "raises error" - end - end - end - end - describe 'validate and sanitize external url' do let_it_be_with_refind(:environment) { create(:environment) } |