summaryrefslogtreecommitdiff
path: root/spec/frontend/work_items/components
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/work_items/components')
-rw-r--r--spec/frontend/work_items/components/notes/__snapshots__/work_item_note_body_spec.js.snap9
-rw-r--r--spec/frontend/work_items/components/notes/activity_filter_spec.js74
-rw-r--r--spec/frontend/work_items/components/notes/work_item_note_body_spec.js32
-rw-r--r--spec/frontend/work_items/components/notes/work_item_note_spec.js53
-rw-r--r--spec/frontend/work_items/components/work_item_comment_form_spec.js205
-rw-r--r--spec/frontend/work_items/components/work_item_comment_locked_spec.js41
-rw-r--r--spec/frontend/work_items/components/work_item_detail_modal_spec.js18
-rw-r--r--spec/frontend/work_items/components/work_item_detail_spec.js92
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js27
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js21
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js93
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js29
-rw-r--r--spec/frontend/work_items/components/work_item_notes_spec.js95
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);
+ });
});
});