diff options
Diffstat (limited to 'spec/frontend/work_items/components')
13 files changed, 744 insertions, 45 deletions
diff --git a/spec/frontend/work_items/components/notes/__snapshots__/work_item_note_body_spec.js.snap b/spec/frontend/work_items/components/notes/__snapshots__/work_item_note_body_spec.js.snap new file mode 100644 index 00000000000..52838dcd0bc --- /dev/null +++ b/spec/frontend/work_items/components/notes/__snapshots__/work_item_note_body_spec.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Work Item Note Body should have the wrapper to show the note body 1`] = ` +"<div data-testid=\\"work-item-note-body\\" class=\\"note-text md\\"> + <p dir=\\"auto\\" data-sourcepos=\\"1:1-1:76\\"> + <gl-emoji data-unicode-version=\\"6.0\\" data-name=\\"wave\\" title=\\"waving hand sign\\">👋</gl-emoji> Hi <a title=\\"Sherie Nitzsche\\" class=\\"gfm gfm-project_member js-user-link\\" data-placement=\\"top\\" data-container=\\"body\\" data-user=\\"3\\" data-reference-type=\\"user\\" href=\\"/fredda.brekke\\">@fredda.brekke</a> How are you ? what do you think about this ? <gl-emoji data-unicode-version=\\"6.0\\" data-name=\\"pray\\" title=\\"person with folded hands\\">🙏</gl-emoji> + </p> +</div>" +`; diff --git a/spec/frontend/work_items/components/notes/activity_filter_spec.js b/spec/frontend/work_items/components/notes/activity_filter_spec.js new file mode 100644 index 00000000000..eb4bcbf942b --- /dev/null +++ b/spec/frontend/work_items/components/notes/activity_filter_spec.js @@ -0,0 +1,74 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ActivityFilter from '~/work_items/components/notes/activity_filter.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { ASC, DESC } from '~/notes/constants'; + +import { mockTracking } from 'helpers/tracking_helper'; +import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; + +describe('Activity Filter', () => { + let wrapper; + + const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findNewestFirstItem = () => wrapper.findByTestId('js-newest-first'); + + const createComponent = ({ sortOrder = ASC, loading = false, workItemType = 'Task' } = {}) => { + wrapper = shallowMountExtended(ActivityFilter, { + propsData: { + sortOrder, + loading, + workItemType, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + describe('default', () => { + it('has a dropdown with 2 options', () => { + expect(findDropdown().exists()).toBe(true); + expect(findAllDropdownItems()).toHaveLength(ActivityFilter.SORT_OPTIONS.length); + }); + + it('has local storage sync with the correct props', () => { + expect(findLocalStorageSync().props('asString')).toBe(true); + }); + + it('emits `updateSavedSortOrder` event when update is emitted', async () => { + findLocalStorageSync().vm.$emit('input', ASC); + + await nextTick(); + expect(wrapper.emitted('updateSavedSortOrder')).toHaveLength(1); + expect(wrapper.emitted('updateSavedSortOrder')).toEqual([[ASC]]); + }); + }); + + describe('when asc', () => { + describe('when the dropdown is clicked', () => { + it('calls the right actions', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + findNewestFirstItem().vm.$emit('click'); + await nextTick(); + + expect(wrapper.emitted('changeSortOrder')).toHaveLength(1); + expect(wrapper.emitted('changeSortOrder')).toEqual([[DESC]]); + + expect(trackingSpy).toHaveBeenCalledWith( + TRACKING_CATEGORY_SHOW, + 'notes_sort_order_changed', + { + category: TRACKING_CATEGORY_SHOW, + label: 'item_track_notes_sorting', + property: 'type_Task', + }, + ); + }); + }); + }); +}); diff --git a/spec/frontend/work_items/components/notes/work_item_note_body_spec.js b/spec/frontend/work_items/components/notes/work_item_note_body_spec.js new file mode 100644 index 00000000000..4fcbcfcaf30 --- /dev/null +++ b/spec/frontend/work_items/components/notes/work_item_note_body_spec.js @@ -0,0 +1,32 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WorkItemNoteBody from '~/work_items/components/notes/work_item_note_body.vue'; +import NoteEditedText from '~/notes/components/note_edited_text.vue'; +import { mockWorkItemCommentNote } from 'jest/work_items/mock_data'; + +describe('Work Item Note Body', () => { + let wrapper; + + const findNoteBody = () => wrapper.findByTestId('work-item-note-body'); + const findNoteEditedText = () => wrapper.findComponent(NoteEditedText); + + const createComponent = ({ note = mockWorkItemCommentNote } = {}) => { + wrapper = shallowMountExtended(WorkItemNoteBody, { + propsData: { + note, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should have the wrapper to show the note body', () => { + expect(findNoteBody().exists()).toBe(true); + expect(findNoteBody().html()).toMatchSnapshot(); + }); + + it('should not show the edited text when the value is not present', () => { + expect(findNoteEditedText().exists()).toBe(false); + }); +}); 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 new file mode 100644 index 00000000000..7257d5c8023 --- /dev/null +++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js @@ -0,0 +1,53 @@ +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import WorkItemNote from '~/work_items/components/notes/work_item_note.vue'; +import NoteBody from '~/work_items/components/notes/work_item_note_body.vue'; +import NoteHeader from '~/notes/components/note_header.vue'; +import { mockWorkItemCommentNote } from 'jest/work_items/mock_data'; + +describe('Work Item Note', () => { + let wrapper; + + const findTimelineEntryItem = () => wrapper.findComponent(TimelineEntryItem); + const findNoteHeader = () => wrapper.findComponent(NoteHeader); + const findNoteBody = () => wrapper.findComponent(NoteBody); + const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findAvatar = () => wrapper.findComponent(GlAvatar); + + const createComponent = ({ note = mockWorkItemCommentNote } = {}) => { + wrapper = shallowMount(WorkItemNote, { + propsData: { + note, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('Should be wrapped inside the timeline entry item', () => { + expect(findTimelineEntryItem().exists()).toBe(true); + }); + + it('should have the author avatar of the work item note', () => { + expect(findAvatarLink().exists()).toBe(true); + expect(findAvatarLink().attributes('href')).toBe(mockWorkItemCommentNote.author.webUrl); + + expect(findAvatar().exists()).toBe(true); + expect(findAvatar().props('src')).toBe(mockWorkItemCommentNote.author.avatarUrl); + expect(findAvatar().props('entityName')).toBe(mockWorkItemCommentNote.author.username); + }); + + it('has note header', () => { + expect(findNoteHeader().exists()).toBe(true); + expect(findNoteHeader().props('author')).toEqual(mockWorkItemCommentNote.author); + expect(findNoteHeader().props('createdAt')).toBe(mockWorkItemCommentNote.createdAt); + }); + + it('has note body', () => { + expect(findNoteBody().exists()).toBe(true); + expect(findNoteBody().props('note')).toEqual(mockWorkItemCommentNote); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_comment_form_spec.js b/spec/frontend/work_items/components/work_item_comment_form_spec.js new file mode 100644 index 00000000000..07c00119398 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_comment_form_spec.js @@ -0,0 +1,205 @@ +import { GlButton } 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 { 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 createNoteMutation from '~/work_items/graphql/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 workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import { + workItemResponseFactory, + workItemQueryResponse, + projectWorkItemResponse, + createWorkItemNoteResponse, +} from '../mock_data'; + +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); +jest.mock('~/lib/utils/autosave'); + +const workItemId = workItemQueryResponse.data.workItem.id; + +describe('WorkItemCommentForm', () => { + let wrapper; + + Vue.use(VueApollo); + + const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemNoteResponse); + 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 createComponent = async ({ + mutationHandler = mutationSuccessHandler, + canUpdate = true, + workItemResponse = workItemResponseFactory({ canUpdate }), + queryVariables = { id: workItemId }, + fetchByIid = false, + signedIn = true, + isEditing = true, + } = {}) => { + workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); + + if (signedIn) { + window.gon.current_user_id = '1'; + window.gon.current_user_avatar_url = 'avatar.png'; + } + + const { id } = workItemQueryResponse.data.workItem; + wrapper = shallowMount(WorkItemCommentForm, { + apolloProvider: createMockApollo([ + [workItemQuery, workItemResponseHandler], + [createNoteMutation, mutationHandler], + [workItemByIidQuery, workItemByIidResponseHandler], + ]), + propsData: { + workItemId: id, + fullPath: 'test-project-path', + queryVariables, + fetchByIid, + }, + stubs: { + MarkdownField, + WorkItemCommentLocked, + }, + }); + + await waitForPromises(); + + if (isEditing) { + wrapper.findComponent(GlButton).vm.$emit('click'); + } + }; + + describe('adding a comment', () => { + it('calls update widgets mutation', async () => { + const noteText = 'updated desc'; + + await createComponent({ + isEditing: true, + signedIn: true, + }); + + setText(noteText); + + clickSave(); + + await waitForPromises(); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + noteableId: workItemId, + body: noteText, + }, + }); + }); + + it('tracks adding comment', async () => { + await createComponent(); + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + setText('test'); + + clickSave(); + + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'add_work_item_comment', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_comment', + property: 'type_Task', + }); + }); + + it('emits error when mutation returns error', async () => { + const error = 'eror'; + + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockResolvedValue({ + data: { + createNote: { + note: null, + errors: [error], + }, + }, + }), + }); + + setText('updated desc'); + + clickSave(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[error]]); + }); + + it('emits error when mutation fails', async () => { + const error = 'eror'; + + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockRejectedValue(new Error(error)), + }); + + setText('updated desc'); + + clickSave(); + + 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 () => { + createComponent({ fetchByIid: false }); + await waitForPromises(); + + expect(workItemResponseHandler).toHaveBeenCalled(); + expect(workItemByIidResponseHandler).not.toHaveBeenCalled(); + }); + + it('calls the IID work item query when when `fetchByIid` prop is true', async () => { + await createComponent({ fetchByIid: true, isEditing: false }); + + expect(workItemResponseHandler).not.toHaveBeenCalled(); + expect(workItemByIidResponseHandler).toHaveBeenCalled(); + }); + + it('skips calling the handlers when missing the needed queryVariables', async () => { + await createComponent({ queryVariables: {}, fetchByIid: false, isEditing: false }); + + expect(workItemResponseHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_comment_locked_spec.js b/spec/frontend/work_items/components/work_item_comment_locked_spec.js new file mode 100644 index 00000000000..58491c4b09c --- /dev/null +++ b/spec/frontend/work_items/components/work_item_comment_locked_spec.js @@ -0,0 +1,41 @@ +import { GlLink, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import WorkItemCommentLocked from '~/work_items/components/work_item_comment_locked.vue'; + +const createComponent = ({ workItemType = 'Task', isProjectArchived = false } = {}) => + shallowMount(WorkItemCommentLocked, { + propsData: { + workItemType, + isProjectArchived, + }, + }); + +describe('WorkItemCommentLocked', () => { + let wrapper; + const findLockedIcon = () => wrapper.findComponent(GlIcon); + const findLearnMoreLink = () => wrapper.findComponent(GlLink); + + it('renders the locked icon', () => { + wrapper = createComponent(); + expect(findLockedIcon().props('name')).toBe('lock'); + }); + + it('has the learn more link', () => { + wrapper = createComponent(); + expect(findLearnMoreLink().attributes('href')).toBe( + WorkItemCommentLocked.constantOptions.lockedIssueDocsPath, + ); + }); + + describe('when the project is archived', () => { + beforeEach(() => { + wrapper = createComponent({ isProjectArchived: true }); + }); + + it('learn more link is directed to archived project docs path', () => { + expect(findLearnMoreLink().attributes('href')).toBe( + WorkItemCommentLocked.constantOptions.archivedProjectDocsPath, + ); + }); + }); +}); 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 686641800b3..8976cd6e22b 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 @@ -4,10 +4,11 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; -import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import { stubComponent } from 'helpers/stub_component'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import deleteWorkItemFromTaskMutation from '~/work_items/graphql/delete_task_from_work_item.mutation.graphql'; import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import { deleteWorkItemFromTaskMutationErrorResponse, deleteWorkItemFromTaskMutationResponse, @@ -69,8 +70,14 @@ describe('WorkItemDetailModal component', () => { error, }; }, + provide: { + fullPath: 'group/project', + }, stubs: { GlModal, + WorkItemDetail: stubComponent(WorkItemDetail, { + apollo: {}, + }), }, }); }; @@ -126,6 +133,15 @@ describe('WorkItemDetailModal component', () => { expect(closeSpy).toHaveBeenCalled(); }); + it('updates the work item when WorkItemDetail emits `update-modal` event', async () => { + createComponent(); + + findWorkItemDetail().vm.$emit('update-modal', null, 'updatedId'); + await waitForPromises(); + + expect(findWorkItemDetail().props().workItemId).toEqual('updatedId'); + }); + describe('delete work item', () => { describe('when there is task data', () => { it('emits workItemDeleted and closes modal', async () => { 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 bbab45c7055..a50a48de921 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -12,6 +12,7 @@ import VueApollo from 'vue-apollo'; 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 WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemActions from '~/work_items/components/work_item_actions.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; @@ -22,6 +23,8 @@ import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; +import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; +import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import { i18n } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; @@ -63,6 +66,7 @@ describe('WorkItemDetail component', () => { const assigneesSubscriptionHandler = jest .fn() .mockResolvedValue(workItemAssigneesSubscriptionResponse); + const showModalHandler = jest.fn(); const findAlert = () => wrapper.findComponent(GlAlert); const findEmptyState = () => wrapper.findComponent(GlEmptyState); @@ -81,6 +85,8 @@ describe('WorkItemDetail component', () => { const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]'); const findWorkItemType = () => wrapper.find('[data-testid="work-item-type"]'); const findHierarchyTree = () => wrapper.findComponent(WorkItemTree); + const findNotesWidget = () => wrapper.findComponent(WorkItemNotes); + const findModal = () => wrapper.findComponent(WorkItemDetailModal); const createComponent = ({ isModal = false, @@ -129,6 +135,12 @@ describe('WorkItemDetail component', () => { stubs: { WorkItemWeight: true, WorkItemIteration: true, + WorkItemHealthStatus: true, + WorkItemDetailModal: stubComponent(WorkItemDetailModal, { + methods: { + show: showModalHandler, + }, + }), }, }); }; @@ -652,15 +664,89 @@ describe('WorkItemDetail component', () => { expect(findHierarchyTree().exists()).toBe(false); }); - it('renders children tree when work item is an Objective', async () => { + describe('work item has children', () => { const objectiveWorkItem = workItemResponseFactory({ workItemType: objectiveType, + confidential: true, }); const handler = jest.fn().mockResolvedValue(objectiveWorkItem); - createComponent({ handler }); + + it('renders children tree when work item is an Objective', async () => { + createComponent({ handler }); + await waitForPromises(); + + expect(findHierarchyTree().exists()).toBe(true); + }); + + it('renders a modal', async () => { + createComponent({ handler }); + await waitForPromises(); + + expect(findModal().exists()).toBe(true); + }); + + it('opens the modal with the child when `show-modal` is emitted', async () => { + createComponent({ handler }); + await waitForPromises(); + + const event = { + preventDefault: jest.fn(), + }; + + findHierarchyTree().vm.$emit('show-modal', event, { id: 'childWorkItemId' }); + await waitForPromises(); + + expect(wrapper.findComponent(WorkItemDetailModal).props().workItemId).toBe( + 'childWorkItemId', + ); + expect(showModalHandler).toHaveBeenCalled(); + }); + + describe('work item is rendered in a modal and has children', () => { + beforeEach(async () => { + createComponent({ + isModal: true, + handler, + }); + + await waitForPromises(); + }); + + it('does not render a new modal', () => { + expect(findModal().exists()).toBe(false); + }); + + it('emits `update-modal` when `show-modal` is emitted', async () => { + const event = { + preventDefault: jest.fn(), + }; + + findHierarchyTree().vm.$emit('show-modal', event, { id: 'childWorkItemId' }); + await waitForPromises(); + + expect(wrapper.emitted('update-modal')).toBeDefined(); + }); + }); + }); + }); + + describe('notes widget', () => { + it('does not render notes by default', async () => { + createComponent(); + await waitForPromises(); + + expect(findNotesWidget().exists()).toBe(false); + }); + + it('renders notes when the work_items_mvc flag is on', async () => { + const notesWorkItem = workItemResponseFactory({ + notesWidgetPresent: true, + }); + const handler = jest.fn().mockResolvedValue(notesWorkItem); + createComponent({ workItemsMvcEnabled: true, handler }); await waitForPromises(); - expect(findHierarchyTree().exists()).toBe(true); + expect(findNotesWidget().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js index 47489d4796b..e693ccfb156 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js @@ -5,23 +5,22 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ItemMilestone from '~/issuable/components/issue_milestone.vue'; import WorkItemLinkChildMetadata from '~/work_items/components/work_item_links/work_item_link_child_metadata.vue'; -import { mockMilestone, mockAssignees, mockLabels } from '../../mock_data'; +import { workItemObjectiveMetadataWidgets } from '../../mock_data'; describe('WorkItemLinkChildMetadata', () => { + const { MILESTONE, ASSIGNEES, LABELS } = workItemObjectiveMetadataWidgets; + const mockMilestone = MILESTONE.milestone; + const mockAssignees = ASSIGNEES.assignees.nodes; + const mockLabels = LABELS.labels.nodes; let wrapper; - const createComponent = ({ - allowsScopedLabels = true, - milestone = mockMilestone, - assignees = mockAssignees, - labels = mockLabels, - } = {}) => { + const createComponent = ({ metadataWidgets = workItemObjectiveMetadataWidgets } = {}) => { wrapper = shallowMountExtended(WorkItemLinkChildMetadata, { propsData: { - allowsScopedLabels, - milestone, - assignees, - labels, + metadataWidgets, + }, + slots: { + default: `<div data-testid="default-slot">Foo</div>`, }, }); }; @@ -30,7 +29,11 @@ describe('WorkItemLinkChildMetadata', () => { createComponent(); }); - it('renders milestone link button', () => { + it('renders default slot contents', () => { + expect(wrapper.findByTestId('default-slot').text()).toBe('Foo'); + }); + + it('renders item milestone', () => { const milestoneLink = wrapper.findComponent(ItemMilestone); expect(milestoneLink.exists()).toBe(true); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js index 73d498ad055..0470249d7ce 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js @@ -5,11 +5,12 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue'; + import { createAlert } from '~/flash'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; import getWorkItemTreeQuery from '~/work_items/graphql/work_item_tree.query.graphql'; -import WorkItemLinkChildMetadata from '~/work_items/components/work_item_links/work_item_link_child_metadata.vue'; import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue'; import WorkItemTreeChildren from '~/work_items/components/work_item_links/work_item_tree_children.vue'; @@ -25,11 +26,9 @@ import { workItemObjectiveNoMetadata, confidentialWorkItemTask, closedWorkItemTask, - mockMilestone, - mockAssignees, - mockLabels, workItemHierarchyTreeResponse, workItemHierarchyTreeFailureResponse, + workItemObjectiveMetadataWidgets, } from '../../mock_data'; jest.mock('~/flash'); @@ -148,10 +147,7 @@ describe('WorkItemLinkChild', () => { const metadataEl = findMetadataComponent(); expect(metadataEl.exists()).toBe(true); expect(metadataEl.props()).toMatchObject({ - allowsScopedLabels: true, - milestone: mockMilestone, - assignees: mockAssignees, - labels: mockLabels, + metadataWidgets: workItemObjectiveMetadataWidgets, }); }); @@ -265,5 +261,14 @@ describe('WorkItemLinkChild', () => { message: 'Something went wrong while fetching children.', }); }); + + it('click event on child emits `click` event', async () => { + findExpandButton().vm.$emit('click'); + await waitForPromises(); + + findTreeChildren().vm.$emit('click', 'event'); + + expect(wrapper.emitted('click')).toEqual([['event']]); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js index bbe460a55ba..5e1c46826cc 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -1,11 +1,18 @@ import Vue from 'vue'; -import { GlForm, GlFormInput, GlTokenSelector } from '@gitlab/ui'; +import { GlForm, GlFormInput, GlFormCheckbox, GlTooltip, GlTokenSelector } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; +import { sprintf, s__ } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue'; -import { FORM_TYPES } from '~/work_items/constants'; +import { + FORM_TYPES, + WORK_ITEM_TYPE_ENUM_TASK, + WORK_ITEM_TYPE_VALUE_ISSUE, + I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, + I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP, +} from '~/work_items/constants'; import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; @@ -36,6 +43,8 @@ describe('WorkItemLinksForm', () => { workItemsMvcEnabled = false, parentIteration = null, formType = FORM_TYPES.create, + parentWorkItemType = WORK_ITEM_TYPE_VALUE_ISSUE, + childrenType = WORK_ITEM_TYPE_ENUM_TASK, } = {}) => { wrapper = shallowMountExtended(WorkItemLinksForm, { apolloProvider: createMockApollo([ @@ -48,6 +57,8 @@ describe('WorkItemLinksForm', () => { issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential, parentIteration, + parentWorkItemType, + childrenType, formType, }, provide: { @@ -65,6 +76,7 @@ describe('WorkItemLinksForm', () => { const findForm = () => wrapper.findComponent(GlForm); const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); const findInput = () => wrapper.findComponent(GlFormInput); + const findConfidentialCheckbox = () => wrapper.findComponent(GlFormCheckbox); const findAddChildButton = () => wrapper.findByTestId('add-child-button'); afterEach(() => { @@ -90,6 +102,7 @@ describe('WorkItemLinksForm', () => { preventDefault: jest.fn(), }); await waitForPromises(); + expect(wrapper.vm.childWorkItemType).toEqual('gid://gitlab/WorkItems::Type/3'); expect(createMutationResolver).toHaveBeenCalledWith({ input: { title: 'Create task test', @@ -112,6 +125,7 @@ describe('WorkItemLinksForm', () => { preventDefault: jest.fn(), }); await waitForPromises(); + expect(wrapper.vm.childWorkItemType).toEqual('gid://gitlab/WorkItems::Type/3'); expect(createMutationResolver).toHaveBeenCalledWith({ input: { title: 'Create confidential task', @@ -124,9 +138,50 @@ describe('WorkItemLinksForm', () => { }, }); }); + + describe('confidentiality checkbox', () => { + it('renders confidentiality checkbox', () => { + const confidentialCheckbox = findConfidentialCheckbox(); + + expect(confidentialCheckbox.exists()).toBe(true); + expect(wrapper.findComponent(GlTooltip).exists()).toBe(false); + expect(confidentialCheckbox.text()).toBe( + sprintf(I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, { + workItemType: WORK_ITEM_TYPE_ENUM_TASK.toLocaleLowerCase(), + }), + ); + }); + + it('renders confidentiality tooltip with checkbox checked and disabled when parent is confidential', () => { + createComponent({ parentConfidential: true }); + + const confidentialCheckbox = findConfidentialCheckbox(); + const confidentialTooltip = wrapper.findComponent(GlTooltip); + + expect(confidentialCheckbox.attributes('disabled')).toBe('true'); + expect(confidentialCheckbox.attributes('checked')).toBe('true'); + expect(confidentialTooltip.exists()).toBe(true); + expect(confidentialTooltip.text()).toBe( + sprintf(I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP, { + workItemType: WORK_ITEM_TYPE_ENUM_TASK.toLocaleLowerCase(), + parentWorkItemType: WORK_ITEM_TYPE_VALUE_ISSUE.toLocaleLowerCase(), + }), + ); + }); + }); }); describe('adding an existing work item', () => { + const selectAvailableWorkItemTokens = async () => { + findTokenSelector().vm.$emit( + 'input', + availableWorkItemsResponse.data.workspace.workItems.nodes, + ); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + + await waitForPromises(); + }; + beforeEach(async () => { await createComponent({ formType: FORM_TYPES.add }); }); @@ -136,6 +191,7 @@ describe('WorkItemLinksForm', () => { expect(findTokenSelector().exists()).toBe(true); expect(findAddChildButton().text()).toBe('Add task'); expect(findInput().exists()).toBe(false); + expect(findConfidentialCheckbox().exists()).toBe(false); }); it('searches for available work items as prop when typing in input', async () => { @@ -147,13 +203,7 @@ describe('WorkItemLinksForm', () => { }); it('selects and adds children', async () => { - findTokenSelector().vm.$emit( - 'input', - availableWorkItemsResponse.data.workspace.workItems.nodes, - ); - findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); - - await waitForPromises(); + await selectAvailableWorkItemTokens(); expect(findAddChildButton().text()).toBe('Add tasks'); findForm().vm.$emit('submit', { @@ -162,6 +212,31 @@ describe('WorkItemLinksForm', () => { await waitForPromises(); expect(updateMutationResolver).toHaveBeenCalled(); }); + + it('shows validation error when non-confidential child items are being added to confidential parent', async () => { + await createComponent({ formType: FORM_TYPES.add, parentConfidential: true }); + + await selectAvailableWorkItemTokens(); + + const validationEl = wrapper.findByTestId('work-items-invalid'); + expect(validationEl.exists()).toBe(true); + expect(validationEl.text().trim()).toBe( + sprintf( + s__( + 'WorkItem|%{invalidWorkItemsList} cannot be added: Cannot assign a non-confidential %{childWorkItemType} to a confidential parent %{parentWorkItemType}. Make the selected %{childWorkItemType} confidential and try again.', + ), + { + // Only non-confidential work items are shown in the error message + invalidWorkItemsList: availableWorkItemsResponse.data.workspace.workItems.nodes + .filter((wi) => !wi.confidential) + .map((wi) => wi.title) + .join(', '), + childWorkItemType: 'Task', + parentWorkItemType: 'Issue', + }, + ), + ); + }); }); describe('associate iteration with task', () => { 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 96211e12755..156f06a0d5e 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 @@ -34,6 +34,8 @@ describe('WorkItemTree', () => { const createComponent = ({ workItemType = 'Objective', + parentWorkItemType = 'Objective', + confidential = false, children = childrenWorkItems, apolloProvider = null, } = {}) => { @@ -55,7 +57,9 @@ describe('WorkItemTree', () => { apolloProvider || createMockApollo([[workItemQuery, getWorkItemQueryHandler]]), propsData: { workItemType, + parentWorkItemType, workItemId: 'gid://gitlab/WorkItem/515', + confidential, children, projectPath: 'test/project', }, @@ -90,7 +94,11 @@ describe('WorkItemTree', () => { }); it('renders all hierarchy widget children', () => { - expect(findWorkItemLinkChildItems()).toHaveLength(4); + const workItemLinkChildren = findWorkItemLinkChildItems(); + expect(workItemLinkChildren).toHaveLength(4); + expect(workItemLinkChildren.at(0).props().childItem.confidential).toBe( + childrenWorkItems[0].confidential, + ); }); it('does not display form by default', () => { @@ -110,8 +118,12 @@ describe('WorkItemTree', () => { await nextTick(); expect(findForm().exists()).toBe(true); - expect(findForm().props('formType')).toBe(formType); - expect(findForm().props('childrenType')).toBe(childType); + expect(findForm().props()).toMatchObject({ + formType, + childrenType: childType, + parentWorkItemType: 'Objective', + parentConfidential: false, + }); }, ); @@ -122,6 +134,17 @@ describe('WorkItemTree', () => { expect(wrapper.emitted('removeChild')).toEqual([['gid://gitlab/WorkItem/2']]); }); + it('emits `show-modal` on `click` event', () => { + const firstChild = findWorkItemLinkChildItems().at(0); + const event = { + childItem: 'gid://gitlab/WorkItem/2', + }; + + firstChild.vm.$emit('click', event); + + expect(wrapper.emitted('show-modal')).toEqual([[event, event.childItem]]); + }); + it.each` description | workItemType | prefetch ${'prefetches'} | ${'Issue'} | ${true} 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 ed68d214fc9..23dd2b6bacb 100644 --- a/spec/frontend/work_items/components/work_item_notes_spec.js +++ b/spec/frontend/work_items/components/work_item_notes_spec.js @@ -1,18 +1,22 @@ import { GlSkeletonLoader } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; 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 WorkItemCommentForm from '~/work_items/components/work_item_comment_form.vue'; +import ActivityFilter from '~/work_items/components/notes/activity_filter.vue'; import workItemNotesQuery from '~/work_items/graphql/work_item_notes.query.graphql'; import workItemNotesByIidQuery from '~/work_items/graphql/work_item_notes_by_iid.query.graphql'; -import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import { DEFAULT_PAGE_SIZE_NOTES, WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import { DESC } from '~/notes/constants'; import { mockWorkItemNotesResponse, workItemQueryResponse, mockWorkItemNotesByIidResponse, + mockMoreWorkItemNotesResponse, } from '../mock_data'; const mockWorkItemId = workItemQueryResponse.data.workItem.id; @@ -24,6 +28,12 @@ const mockNotesByIidWidgetResponse = mockWorkItemNotesByIidResponse.data.workspa (widget) => widget.type === WIDGET_TYPE_NOTES, ); +const mockMoreNotesWidgetResponse = mockMoreWorkItemNotesResponse.data.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_NOTES, +); + +const firstSystemNodeId = mockNotesWidgetResponse.discussions.nodes[0].notes.nodes[0].id; + describe('WorkItemNotes component', () => { let wrapper; @@ -31,16 +41,24 @@ describe('WorkItemNotes component', () => { const findAllSystemNotes = () => wrapper.findAllComponents(SystemNote); const findActivityLabel = () => wrapper.find('label'); + const findWorkItemCommentForm = () => wrapper.findComponent(WorkItemCommentForm); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findSortingFilter = () => wrapper.findComponent(ActivityFilter); + const findSystemNoteAtIndex = (index) => findAllSystemNotes().at(index); const workItemNotesQueryHandler = jest.fn().mockResolvedValue(mockWorkItemNotesResponse); const workItemNotesByIidQueryHandler = jest .fn() .mockResolvedValue(mockWorkItemNotesByIidResponse); + const workItemMoreNotesQueryHandler = jest.fn().mockResolvedValue(mockMoreWorkItemNotesResponse); - const createComponent = ({ workItemId = mockWorkItemId, fetchByIid = false } = {}) => { + const createComponent = ({ + workItemId = mockWorkItemId, + fetchByIid = false, + defaultWorkItemNotesQueryHandler = workItemNotesQueryHandler, + } = {}) => { wrapper = shallowMount(WorkItemNotes, { apolloProvider: createMockApollo([ - [workItemNotesQuery, workItemNotesQueryHandler], + [workItemNotesQuery, defaultWorkItemNotesQueryHandler], [workItemNotesByIidQuery, workItemNotesByIidQueryHandler], ]), propsData: { @@ -50,6 +68,7 @@ describe('WorkItemNotes component', () => { }, fullPath: 'test-path', fetchByIid, + workItemType: 'task', }, provide: { glFeatures: { @@ -63,14 +82,17 @@ describe('WorkItemNotes component', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('renders activity label', () => { expect(findActivityLabel().exists()).toBe(true); }); + it('passes correct props to comment form component', async () => { + createComponent({ workItemId: mockWorkItemId, fetchByIid: false }); + await waitForPromises(); + + expect(findWorkItemCommentForm().props('fetchByIid')).toEqual(false); + }); + describe('when notes are loading', () => { it('renders skeleton loader', () => { expect(findSkeletonLoader().exists()).toBe(true); @@ -98,10 +120,65 @@ describe('WorkItemNotes component', () => { await waitForPromises(); }); - it('shows the notes list', () => { + it('renders the notes list to the length of the response', () => { expect(findAllSystemNotes()).toHaveLength( mockNotesByIidWidgetResponse.discussions.nodes.length, ); }); + + it('passes correct props to comment form component', () => { + expect(findWorkItemCommentForm().props('fetchByIid')).toEqual(true); + }); + }); + + describe('Pagination', () => { + describe('When there is no next page', () => { + it('fetch more notes is not called', async () => { + createComponent(); + await nextTick(); + expect(workItemMoreNotesQueryHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when there is next page', () => { + beforeEach(async () => { + createComponent({ defaultWorkItemNotesQueryHandler: workItemMoreNotesQueryHandler }); + await waitForPromises(); + }); + + it('fetch more notes should be called', async () => { + expect(workItemMoreNotesQueryHandler).toHaveBeenCalledWith({ + pageSize: DEFAULT_PAGE_SIZE_NOTES, + id: 'gid://gitlab/WorkItem/1', + }); + + await nextTick(); + + expect(workItemMoreNotesQueryHandler).toHaveBeenCalledWith({ + pageSize: 45, + id: 'gid://gitlab/WorkItem/1', + after: mockMoreNotesWidgetResponse.discussions.pageInfo.endCursor, + }); + }); + }); + }); + + describe('Sorting', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('filter exists', () => { + expect(findSortingFilter().exists()).toBe(true); + }); + + it('sorts the list when the `changeSortOrder` event is emitted', async () => { + expect(findSystemNoteAtIndex(0).props('note').id).toEqual(firstSystemNodeId); + + await findSortingFilter().vm.$emit('changeSortOrder', DESC); + + expect(findSystemNoteAtIndex(0).props('note').id).not.toEqual(firstSystemNodeId); + }); }); }); |