Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/work_items')
-rw-r--r--spec/frontend/work_items/components/item_title_spec.js2
-rw-r--r--spec/frontend/work_items/components/work_item_assignees_spec.js407
-rw-r--r--spec/frontend/work_items/components/work_item_detail_modal_spec.js10
-rw-r--r--spec/frontend/work_items/components/work_item_information_spec.js48
-rw-r--r--spec/frontend/work_items/components/work_item_labels_spec.js171
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js65
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js141
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_spec.js30
-rw-r--r--spec/frontend/work_items/components/work_item_weight_spec.js154
-rw-r--r--spec/frontend/work_items/mock_data.js292
-rw-r--r--spec/frontend/work_items/pages/create_work_item_spec.js52
-rw-r--r--spec/frontend/work_items/pages/work_item_detail_spec.js135
-rw-r--r--spec/frontend/work_items/pages/work_item_root_spec.js1
13 files changed, 1365 insertions, 143 deletions
diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js
index 2c3f6ef8634..a55f448c9a2 100644
--- a/spec/frontend/work_items/components/item_title_spec.js
+++ b/spec/frontend/work_items/components/item_title_spec.js
@@ -1,5 +1,4 @@
import { shallowMount } from '@vue/test-utils';
-import { escape } from 'lodash';
import ItemTitle from '~/work_items/components/item_title.vue';
jest.mock('lodash/escape', () => jest.fn((fn) => fn));
@@ -51,6 +50,5 @@ describe('ItemTitle', () => {
await findInputEl().trigger(sourceEvent);
expect(wrapper.emitted(eventName)).toBeTruthy();
- expect(escape).toHaveBeenCalledWith(mockUpdatedTitle);
});
});
diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js
index 0552fe5050e..299949a4baa 100644
--- a/spec/frontend/work_items/components/work_item_assignees_spec.js
+++ b/spec/frontend/work_items/components/work_item_assignees_spec.js
@@ -1,52 +1,90 @@
-import { GlLink, GlTokenSelector } from '@gitlab/ui';
-import { nextTick } from 'vue';
+import { GlLink, GlTokenSelector, GlSkeletonLoader } from '@gitlab/ui';
+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 { mountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import { stripTypenames } from 'helpers/graphql_helpers';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
+import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
+import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
-import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql';
-
-const mockAssignees = [
- {
- __typename: 'UserCore',
- id: 'gid://gitlab/User/1',
- avatarUrl: '',
- webUrl: '',
- name: 'John Doe',
- username: 'doe_I',
- },
- {
- __typename: 'UserCore',
- id: 'gid://gitlab/User/2',
- avatarUrl: '',
- webUrl: '',
- name: 'Marcus Rutherford',
- username: 'ruthfull',
- },
-];
+import { i18n, TASK_TYPE_NAME, TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+import { temporaryConfig, resolvers } from '~/work_items/graphql/provider';
+import {
+ projectMembersResponseWithCurrentUser,
+ mockAssignees,
+ workItemQueryResponse,
+ currentUserResponse,
+ currentUserNullResponse,
+ projectMembersResponseWithoutCurrentUser,
+} from '../mock_data';
-const workItemId = 'gid://gitlab/WorkItem/1';
+Vue.use(VueApollo);
-const mutate = jest.fn();
+const workItemId = 'gid://gitlab/WorkItem/1';
+const dropdownItems = projectMembersResponseWithCurrentUser.data.workspace.users.nodes;
describe('WorkItemAssignees component', () => {
let wrapper;
const findAssigneeLinks = () => wrapper.findAllComponents(GlLink);
const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findEmptyState = () => wrapper.findByTestId('empty-state');
+ const findAssignSelfButton = () => wrapper.findByTestId('assign-self');
+ const findAssigneesTitle = () => wrapper.findByTestId('assignees-title');
+
+ const successSearchQueryHandler = jest
+ .fn()
+ .mockResolvedValue(projectMembersResponseWithCurrentUser);
+ const successCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserResponse);
+ const noCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserNullResponse);
+
+ const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
+
+ const createComponent = ({
+ assignees = mockAssignees,
+ searchQueryHandler = successSearchQueryHandler,
+ currentUserQueryHandler = successCurrentUserQueryHandler,
+ allowsMultipleAssignees = true,
+ canUpdate = true,
+ } = {}) => {
+ const apolloProvider = createMockApollo(
+ [
+ [userSearchQuery, searchQueryHandler],
+ [currentUserQuery, currentUserQueryHandler],
+ ],
+ resolvers,
+ {
+ typePolicies: temporaryConfig.cacheConfig.typePolicies,
+ },
+ );
+
+ apolloProvider.clients.defaultClient.writeQuery({
+ query: workItemQuery,
+ variables: {
+ id: workItemId,
+ },
+ data: workItemQueryResponse.data,
+ });
- const createComponent = ({ assignees = mockAssignees } = {}) => {
wrapper = mountExtended(WorkItemAssignees, {
+ provide: {
+ fullPath: 'test-project-path',
+ },
propsData: {
assignees,
workItemId,
- },
- mocks: {
- $apollo: {
- mutate,
- },
+ allowsMultipleAssignees,
+ workItemType: TASK_TYPE_NAME,
+ canUpdate,
},
attachTo: document.body,
+ apolloProvider,
});
};
@@ -54,39 +92,316 @@ describe('WorkItemAssignees component', () => {
wrapper.destroy();
});
- it('should pass the correct data-user-id attribute', () => {
+ it('passes the correct data-user-id attribute', () => {
createComponent();
expect(findAssigneeLinks().at(0).attributes('data-user-id')).toBe('1');
});
- describe('when there are assignees', () => {
+ it('container does not have shadow by default', () => {
+ createComponent();
+ expect(findTokenSelector().props('containerClass')).toBe('gl-shadow-none!');
+ });
+
+ it('container has shadow after focusing token selector', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('focus');
+ await nextTick();
+
+ expect(findTokenSelector().props('containerClass')).toBe('');
+ });
+
+ it('focuses token selector on token selector input event', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
+ await nextTick();
+
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findTokenSelector().element.contains(document.activeElement)).toBe(true);
+ });
+
+ it('calls a mutation on clicking outside the token selector', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
+ findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null }));
+ await waitForPromises();
+
+ expect(findTokenSelector().props('selectedTokens')).toEqual([mockAssignees[0]]);
+ });
+
+ it('passes `false` to `viewOnly` token selector prop if user can update assignees', () => {
+ createComponent();
+
+ expect(findTokenSelector().props('viewOnly')).toBe(false);
+ });
+
+ it('passes `true` to `viewOnly` token selector prop if user can not update assignees', () => {
+ createComponent({ canUpdate: false });
+
+ expect(findTokenSelector().props('viewOnly')).toBe(true);
+ });
+
+ describe('when searching for users', () => {
beforeEach(() => {
createComponent();
});
- it('should focus token selector on token removal', async () => {
- findTokenSelector().vm.$emit('token-remove', mockAssignees[0].id);
+ it('does not start user search by default', () => {
+ expect(findTokenSelector().props('loading')).toBe(false);
+ expect(findTokenSelector().props('dropdownItems')).toEqual([]);
+ });
+
+ it('starts user search on hovering for more than 250ms', async () => {
+ findTokenSelector().trigger('mouseover');
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await nextTick();
- expect(findEmptyState().exists()).toBe(false);
- expect(findTokenSelector().element.contains(document.activeElement)).toBe(true);
+ expect(findTokenSelector().props('loading')).toBe(true);
});
- it('should call a mutation on clicking outside the token selector', async () => {
- findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
- findTokenSelector().vm.$emit('token-remove');
+ it('starts user search on focusing token selector', async () => {
+ findTokenSelector().vm.$emit('focus');
await nextTick();
- expect(mutate).not.toHaveBeenCalled();
- findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null }));
+ expect(findTokenSelector().props('loading')).toBe(true);
+ });
+
+ it('does not start searching if token-selector was hovered for less than 250ms', async () => {
+ findTokenSelector().trigger('mouseover');
+ jest.advanceTimersByTime(100);
+ await nextTick();
+
+ expect(findTokenSelector().props('loading')).toBe(false);
+ });
+
+ it('does not start searching if cursor was moved out from token selector before 250ms passed', async () => {
+ findTokenSelector().trigger('mouseover');
+ jest.advanceTimersByTime(100);
+
+ findTokenSelector().trigger('mouseout');
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ await nextTick();
+
+ expect(findTokenSelector().props('loading')).toBe(false);
+ });
+
+ it('shows skeleton loader on dropdown when loading users', async () => {
+ findTokenSelector().vm.$emit('focus');
await nextTick();
- expect(mutate).toHaveBeenCalledWith({
- mutation: localUpdateWorkItemMutation,
- variables: {
- input: { id: workItemId, assigneeIds: [mockAssignees[0].id] },
- },
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('shows correct users list in dropdown when loaded', async () => {
+ findTokenSelector().vm.$emit('focus');
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+
+ await waitForPromises();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ expect(findTokenSelector().props('dropdownItems')).toHaveLength(2);
+ });
+
+ it('should search for users with correct key after text input', async () => {
+ const searchKey = 'Hello';
+
+ findTokenSelector().vm.$emit('focus');
+ findTokenSelector().vm.$emit('text-input', searchKey);
+ await waitForPromises();
+
+ expect(successSearchQueryHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ search: searchKey }),
+ );
+ });
+ });
+
+ it('emits error event if search users query fails', async () => {
+ createComponent({ searchQueryHandler: errorHandler });
+ findTokenSelector().vm.$emit('focus');
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]);
+ });
+
+ describe('when assigning to current user', () => {
+ it('does not show `Assign myself` button if current user is loading', () => {
+ createComponent();
+ findTokenSelector().trigger('mouseover');
+
+ expect(findAssignSelfButton().exists()).toBe(false);
+ });
+
+ it('does not show `Assign myself` button if work item has assignees', async () => {
+ createComponent();
+ await waitForPromises();
+ findTokenSelector().trigger('mouseover');
+
+ expect(findAssignSelfButton().exists()).toBe(false);
+ });
+
+ it('does now show `Assign myself` button if user is not logged in', async () => {
+ createComponent({ currentUserQueryHandler: noCurrentUserQueryHandler, assignees: [] });
+ await waitForPromises();
+ findTokenSelector().trigger('mouseover');
+
+ expect(findAssignSelfButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when user is logged in and there are no assignees', () => {
+ beforeEach(() => {
+ createComponent({ assignees: [] });
+ return waitForPromises();
+ });
+
+ it('renders `Assign myself` button', async () => {
+ findTokenSelector().trigger('mouseover');
+ expect(findAssignSelfButton().exists()).toBe(true);
+ });
+
+ it('calls update work item assignees mutation with current user as a variable on button click', () => {
+ // TODO: replace this test as soon as we have a real mutation implemented
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementation(jest.fn());
+
+ findTokenSelector().trigger('mouseover');
+ findAssignSelfButton().vm.$emit('click', new MouseEvent('click'));
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variables: {
+ input: {
+ assignees: [stripTypenames(currentUserResponse.data.currentUser)],
+ id: workItemId,
+ },
+ },
+ }),
+ );
+ });
+ });
+
+ it('moves current user to the top of dropdown items if user is a project member', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(findTokenSelector().props('dropdownItems')[0]).toEqual(
+ expect.objectContaining({
+ ...stripTypenames(currentUserResponse.data.currentUser),
+ }),
+ );
+ });
+
+ describe('when current user is not in the list of project members', () => {
+ const searchQueryHandler = jest
+ .fn()
+ .mockResolvedValue(projectMembersResponseWithoutCurrentUser);
+
+ beforeEach(() => {
+ createComponent({ searchQueryHandler });
+ return waitForPromises();
+ });
+
+ it('adds current user to the top of dropdown items', () => {
+ expect(findTokenSelector().props('dropdownItems')[0]).toEqual(
+ stripTypenames(currentUserResponse.data.currentUser),
+ );
+ });
+
+ it('does not add current user if search is not empty', async () => {
+ findTokenSelector().vm.$emit('text-input', 'test');
+ await waitForPromises();
+
+ expect(findTokenSelector().props('dropdownItems')[0]).not.toEqual(
+ stripTypenames(currentUserResponse.data.currentUser),
+ );
+ });
+ });
+
+ it('has `Assignee` label when only one assignee is present', () => {
+ createComponent({ assignees: [mockAssignees[0]] });
+
+ expect(findAssigneesTitle().text()).toBe('Assignee');
+ });
+
+ it('has `Assignees` label if more than one assignee is present', () => {
+ createComponent();
+
+ expect(findAssigneesTitle().text()).toBe('Assignees');
+ });
+
+ describe('when multiple assignees are allowed', () => {
+ beforeEach(() => {
+ createComponent({ allowsMultipleAssignees: true, assignees: [] });
+ return waitForPromises();
+ });
+
+ it('has `Add assignees` text on placeholder', () => {
+ expect(findEmptyState().text()).toContain('Add assignees');
+ });
+
+ it('adds multiple assignees when token-selector provides multiple values', async () => {
+ findTokenSelector().vm.$emit('input', dropdownItems);
+ await nextTick();
+
+ expect(findTokenSelector().props('selectedTokens')).toHaveLength(2);
+ });
+ });
+
+ describe('when multiple assignees are not allowed', () => {
+ beforeEach(() => {
+ createComponent({ allowsMultipleAssignees: false, assignees: [] });
+ return waitForPromises();
+ });
+
+ it('has `Add assignee` text on placeholder', () => {
+ expect(findEmptyState().text()).toContain('Add assignee');
+ expect(findEmptyState().text()).not.toContain('Add assignees');
+ });
+
+ it('adds a single assignee token-selector provides multiple values', async () => {
+ findTokenSelector().vm.$emit('input', dropdownItems);
+ await nextTick();
+
+ expect(findTokenSelector().props('selectedTokens')).toHaveLength(1);
+ });
+
+ it('removes shadow after token-selector input', async () => {
+ findTokenSelector().vm.$emit('input', dropdownItems);
+ await nextTick();
+
+ expect(findTokenSelector().props('containerClass')).toBe('gl-shadow-none!');
+ });
+ });
+
+ describe('tracking', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ trackingSpy = null;
+ });
+
+ it('does not track updating assignees until token selector blur event', async () => {
+ findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
+ await waitForPromises();
+
+ expect(trackingSpy).not.toHaveBeenCalled();
+ });
+
+ it('tracks editing the assignees on token selector blur', async () => {
+ findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
+ findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null }));
+ await waitForPromises();
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_assignees', {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_assignees',
+ property: 'type_Task',
});
});
});
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 d55ba318e46..70b1261bdb7 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
@@ -66,6 +66,7 @@ describe('WorkItemDetailModal component', () => {
createComponent();
expect(findWorkItemDetail().props()).toEqual({
+ isModal: true,
workItemId: '1',
workItemParentId: '2',
});
@@ -98,6 +99,15 @@ describe('WorkItemDetailModal component', () => {
expect(wrapper.emitted('close')).toBeTruthy();
});
+ it('hides the modal when WorkItemDetail emits `close` event', () => {
+ createComponent();
+ const closeSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide');
+
+ findWorkItemDetail().vm.$emit('close');
+
+ expect(closeSpy).toHaveBeenCalled();
+ });
+
describe('delete work item', () => {
it('emits workItemDeleted and closes modal', async () => {
createComponent();
diff --git a/spec/frontend/work_items/components/work_item_information_spec.js b/spec/frontend/work_items/components/work_item_information_spec.js
new file mode 100644
index 00000000000..d5f6921c2bc
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_information_spec.js
@@ -0,0 +1,48 @@
+import { mount } from '@vue/test-utils';
+import { GlAlert, GlLink } from '@gitlab/ui';
+import WorkItemInformation from '~/work_items/components/work_item_information.vue';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+const createComponent = () => mount(WorkItemInformation);
+
+describe('Work item information alert', () => {
+ let wrapper;
+ const tasksHelpPath = helpPagePath('user/tasks');
+ const workItemsHelpPath = helpPagePath('development/work_items');
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findHelpLink = () => wrapper.findComponent(GlLink);
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should be visible', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('should emit `work-item-banner-dismissed` event when cross icon is clicked', () => {
+ findAlert().vm.$emit('dismiss');
+ expect(wrapper.emitted('work-item-banner-dismissed').length).toBe(1);
+ });
+
+ it('the alert variant should be tip', () => {
+ expect(findAlert().props('variant')).toBe('tip');
+ });
+
+ it('should have the correct text for primary button and link', () => {
+ expect(findAlert().props('title')).toBe(WorkItemInformation.i18n.tasksInformationTitle);
+ expect(findAlert().props('primaryButtonText')).toBe(
+ WorkItemInformation.i18n.learnTasksButtonText,
+ );
+ expect(findAlert().props('primaryButtonLink')).toBe(tasksHelpPath);
+ });
+
+ it('should have the correct link to work item link', () => {
+ expect(findHelpLink().exists()).toBe(true);
+ expect(findHelpLink().attributes('href')).toBe(workItemsHelpPath);
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js
new file mode 100644
index 00000000000..1734b901d1a
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_labels_spec.js
@@ -0,0 +1,171 @@
+import { GlTokenSelector, GlSkeletonLoader } from '@gitlab/ui';
+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 { mountExtended } from 'helpers/vue_test_utils_helper';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
+import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
+import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
+import { i18n } from '~/work_items/constants';
+import { temporaryConfig, resolvers } from '~/work_items/graphql/provider';
+import { projectLabelsResponse, mockLabels, workItemQueryResponse } from '../mock_data';
+
+Vue.use(VueApollo);
+
+const workItemId = 'gid://gitlab/WorkItem/1';
+
+describe('WorkItemLabels component', () => {
+ let wrapper;
+
+ const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+
+ const findEmptyState = () => wrapper.findByTestId('empty-state');
+
+ const successSearchQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse);
+ const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
+
+ const createComponent = ({
+ labels = mockLabels,
+ canUpdate = true,
+ searchQueryHandler = successSearchQueryHandler,
+ } = {}) => {
+ const apolloProvider = createMockApollo([[labelSearchQuery, searchQueryHandler]], resolvers, {
+ typePolicies: temporaryConfig.cacheConfig.typePolicies,
+ });
+
+ apolloProvider.clients.defaultClient.writeQuery({
+ query: workItemQuery,
+ variables: {
+ id: workItemId,
+ },
+ data: workItemQueryResponse.data,
+ });
+
+ wrapper = mountExtended(WorkItemLabels, {
+ provide: {
+ fullPath: 'test-project-path',
+ },
+ propsData: {
+ labels,
+ workItemId,
+ canUpdate,
+ },
+ attachTo: document.body,
+ apolloProvider,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('focuses token selector on token selector input event', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('input', [mockLabels[0]]);
+ await nextTick();
+
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findTokenSelector().element.contains(document.activeElement)).toBe(true);
+ });
+
+ it('does not start search by default', () => {
+ createComponent();
+
+ expect(findTokenSelector().props('loading')).toBe(false);
+ expect(findTokenSelector().props('dropdownItems')).toEqual([]);
+ });
+
+ it('starts search on hovering for more than 250ms', async () => {
+ createComponent();
+ findTokenSelector().trigger('mouseover');
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ await nextTick();
+
+ expect(findTokenSelector().props('loading')).toBe(true);
+ });
+
+ it('starts search on focusing token selector', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('focus');
+ await nextTick();
+
+ expect(findTokenSelector().props('loading')).toBe(true);
+ });
+
+ it('does not start searching if token-selector was hovered for less than 250ms', async () => {
+ createComponent();
+ findTokenSelector().trigger('mouseover');
+ jest.advanceTimersByTime(100);
+ await nextTick();
+
+ expect(findTokenSelector().props('loading')).toBe(false);
+ });
+
+ it('does not start searching if cursor was moved out from token selector before 250ms passed', async () => {
+ createComponent();
+ findTokenSelector().trigger('mouseover');
+ jest.advanceTimersByTime(100);
+
+ findTokenSelector().trigger('mouseout');
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ await nextTick();
+
+ expect(findTokenSelector().props('loading')).toBe(false);
+ });
+
+ it('shows skeleton loader on dropdown when loading', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('focus');
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('shows list in dropdown when loaded', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('focus');
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+
+ await waitForPromises();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ expect(findTokenSelector().props('dropdownItems')).toHaveLength(2);
+ });
+
+ it.each([true, false])(
+ 'passes canUpdate=%s prop to view-only of token-selector',
+ async (canUpdate) => {
+ createComponent({ canUpdate });
+
+ await waitForPromises();
+
+ expect(findTokenSelector().props('viewOnly')).toBe(!canUpdate);
+ },
+ );
+
+ it('emits error event if search query fails', async () => {
+ createComponent({ searchQueryHandler: errorHandler });
+ findTokenSelector().vm.$emit('focus');
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]);
+ });
+
+ it('should search for with correct key after text input', async () => {
+ const searchKey = 'Hello';
+
+ createComponent();
+ findTokenSelector().vm.$emit('focus');
+ findTokenSelector().vm.$emit('text-input', searchKey);
+ await waitForPromises();
+
+ expect(successSearchQueryHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ search: searchKey }),
+ );
+ });
+});
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
new file mode 100644
index 00000000000..93bf7286aa7
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+import { GlForm, GlFormCombobox } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+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 projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import { availableWorkItemsResponse, updateWorkItemMutationResponse } from '../../mock_data';
+
+Vue.use(VueApollo);
+
+describe('WorkItemLinksForm', () => {
+ let wrapper;
+
+ const updateMutationResolver = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
+
+ const createComponent = async ({ listResponse = availableWorkItemsResponse } = {}) => {
+ wrapper = shallowMountExtended(WorkItemLinksForm, {
+ apolloProvider: createMockApollo([
+ [projectWorkItemsQuery, jest.fn().mockResolvedValue(listResponse)],
+ [updateWorkItemMutation, updateMutationResolver],
+ ]),
+ propsData: { issuableGid: 'gid://gitlab/WorkItem/1' },
+ provide: {
+ projectPath: 'project/path',
+ },
+ });
+
+ await waitForPromises();
+ };
+
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findCombobox = () => wrapper.findComponent(GlFormCombobox);
+ const findAddChildButton = () => wrapper.findByTestId('add-child-button');
+
+ beforeEach(async () => {
+ await createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders form', () => {
+ expect(findForm().exists()).toBe(true);
+ });
+
+ it('passes available work items as prop when typing in combobox', async () => {
+ findCombobox().vm.$emit('input', 'Task');
+ await waitForPromises();
+
+ expect(findCombobox().exists()).toBe(true);
+ expect(findCombobox().props('tokenList').length).toBe(2);
+ });
+
+ it('selects and add child', async () => {
+ findCombobox().vm.$emit('input', availableWorkItemsResponse.data.workspace.workItems.edges[0]);
+
+ findAddChildButton().vm.$emit('click');
+ await waitForPromises();
+ expect(updateMutationResolver).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js
new file mode 100644
index 00000000000..f8471b7f167
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js
@@ -0,0 +1,141 @@
+import Vue from 'vue';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { cloneDeep } from 'lodash';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue';
+import changeWorkItemParentMutation from '~/work_items/graphql/change_work_item_parent_link.mutation.graphql';
+import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql';
+import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants';
+import { workItemHierarchyResponse, changeWorkItemParentMutationResponse } from '../../mock_data';
+
+Vue.use(VueApollo);
+
+const PARENT_ID = 'gid://gitlab/WorkItem/1';
+const WORK_ITEM_ID = 'gid://gitlab/WorkItem/3';
+
+describe('WorkItemLinksMenu', () => {
+ let wrapper;
+ let mockApollo;
+
+ const $toast = {
+ show: jest.fn(),
+ };
+
+ const createComponent = async ({
+ data = {},
+ mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse),
+ } = {}) => {
+ mockApollo = createMockApollo([
+ [getWorkItemLinksQuery, jest.fn().mockResolvedValue(workItemHierarchyResponse)],
+ [changeWorkItemParentMutation, mutationHandler],
+ ]);
+
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getWorkItemLinksQuery,
+ variables: {
+ id: PARENT_ID,
+ },
+ data: workItemHierarchyResponse.data,
+ });
+
+ wrapper = shallowMountExtended(WorkItemLinksMenu, {
+ data() {
+ return {
+ ...data,
+ };
+ },
+ propsData: {
+ workItemId: WORK_ITEM_ID,
+ parentWorkItemId: PARENT_ID,
+ },
+ apolloProvider: mockApollo,
+ mocks: {
+ $toast,
+ },
+ });
+
+ await waitForPromises();
+ };
+
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findRemoveDropdownItem = () => wrapper.find(GlDropdownItem);
+
+ beforeEach(async () => {
+ await createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mockApollo = null;
+ });
+
+ it('renders dropdown and dropdown items', () => {
+ expect(findDropdown().exists()).toBe(true);
+ expect(findRemoveDropdownItem().exists()).toBe(true);
+ });
+
+ it('calls correct mutation with correct variables', async () => {
+ const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse);
+
+ createComponent({ mutationHandler });
+
+ findRemoveDropdownItem().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(mutationHandler).toHaveBeenCalledWith({
+ id: WORK_ITEM_ID,
+ parentId: null,
+ });
+ });
+
+ it('shows toast when mutation succeeds', async () => {
+ const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse);
+
+ createComponent({ mutationHandler });
+
+ findRemoveDropdownItem().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect($toast.show).toHaveBeenCalledWith('Child removed', {
+ action: { onClick: expect.anything(), text: 'Undo' },
+ });
+ });
+
+ it('updates the cache when mutation succeeds', async () => {
+ const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse);
+
+ createComponent({ mutationHandler });
+
+ mockApollo.clients.defaultClient.cache.readQuery = jest.fn(
+ () => workItemHierarchyResponse.data,
+ );
+
+ mockApollo.clients.defaultClient.cache.writeQuery = jest.fn();
+
+ findRemoveDropdownItem().vm.$emit('click');
+
+ await waitForPromises();
+
+ // Remove the work item from parent's children
+ const resp = cloneDeep(workItemHierarchyResponse);
+ const index = resp.data.workItem.widgets
+ .find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)
+ .children.nodes.findIndex((child) => child.id === WORK_ITEM_ID);
+ resp.data.workItem.widgets
+ .find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)
+ .children.nodes.splice(index, 1);
+
+ expect(mockApollo.clients.defaultClient.cache.writeQuery).toHaveBeenCalledWith(
+ expect.objectContaining({
+ query: expect.anything(),
+ variables: { id: PARENT_ID },
+ data: resp.data,
+ }),
+ );
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
index 774e9198992..2ec9b1ec0ac 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
@@ -51,6 +51,20 @@ describe('WorkItemLinks', () => {
expect(findLinksBody().exists()).toBe(false);
});
+ describe('add link form', () => {
+ it('displays form on click add button and hides form on cancel', async () => {
+ findToggleAddFormButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findAddLinksForm().exists()).toBe(true);
+
+ findAddLinksForm().vm.$emit('cancel');
+ await nextTick();
+
+ expect(findAddLinksForm().exists()).toBe(false);
+ });
+ });
+
describe('when no child links', () => {
beforeEach(async () => {
await createComponent({ response: workItemHierarchyEmptyResponse });
@@ -59,22 +73,6 @@ describe('WorkItemLinks', () => {
it('displays empty state if there are no children', () => {
expect(findEmptyState().exists()).toBe(true);
});
-
- describe('add link form', () => {
- it('displays form on click add button and hides form on cancel', async () => {
- expect(findEmptyState().exists()).toBe(true);
-
- findToggleAddFormButton().vm.$emit('click');
- await nextTick();
-
- expect(findAddLinksForm().exists()).toBe(true);
-
- findAddLinksForm().vm.$emit('cancel');
- await nextTick();
-
- expect(findAddLinksForm().exists()).toBe(false);
- });
- });
});
it('renders all hierarchy widget children', () => {
diff --git a/spec/frontend/work_items/components/work_item_weight_spec.js b/spec/frontend/work_items/components/work_item_weight_spec.js
index 80a1d032ad7..c3bbea26cda 100644
--- a/spec/frontend/work_items/components/work_item_weight_spec.js
+++ b/spec/frontend/work_items/components/work_item_weight_spec.js
@@ -1,21 +1,51 @@
-import { shallowMount } from '@vue/test-utils';
+import { GlForm, GlFormInput } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { mockTracking } from 'helpers/tracking_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { __ } from '~/locale';
import WorkItemWeight from '~/work_items/components/work_item_weight.vue';
+import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql';
-describe('WorkItemAssignees component', () => {
+describe('WorkItemWeight component', () => {
let wrapper;
- const createComponent = ({ weight, hasIssueWeightsFeature = true } = {}) => {
- wrapper = shallowMount(WorkItemWeight, {
+ const mutateSpy = jest.fn();
+ const workItemId = 'gid://gitlab/WorkItem/1';
+ const workItemType = 'Task';
+
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findInput = () => wrapper.findComponent(GlFormInput);
+
+ const createComponent = ({
+ canUpdate = false,
+ hasIssueWeightsFeature = true,
+ isEditing = false,
+ weight,
+ } = {}) => {
+ wrapper = mountExtended(WorkItemWeight, {
propsData: {
+ canUpdate,
weight,
+ workItemId,
+ workItemType,
},
provide: {
hasIssueWeightsFeature,
},
+ mocks: {
+ $apollo: {
+ mutate: mutateSpy,
+ },
+ },
});
+
+ if (isEditing) {
+ findInput().vm.$emit('focus');
+ }
};
- describe('weight licensed feature', () => {
+ describe('`issue_weights` licensed feature', () => {
describe.each`
description | hasIssueWeightsFeature | exists
${'when available'} | ${true} | ${true}
@@ -24,23 +54,111 @@ describe('WorkItemAssignees component', () => {
it(hasIssueWeightsFeature ? 'renders component' : 'does not render component', () => {
createComponent({ hasIssueWeightsFeature });
- expect(wrapper.find('div').exists()).toBe(exists);
+ expect(findForm().exists()).toBe(exists);
});
});
});
- describe('weight text', () => {
- describe.each`
- description | weight | text
- ${'renders 1'} | ${1} | ${'1'}
- ${'renders 0'} | ${0} | ${'0'}
- ${'renders None'} | ${null} | ${'None'}
- ${'renders None'} | ${undefined} | ${'None'}
- `('when weight is $weight', ({ description, weight, text }) => {
- it(description, () => {
- createComponent({ weight });
-
- expect(wrapper.text()).toContain(text);
+ describe('weight input', () => {
+ it('has "Weight" label', () => {
+ createComponent();
+
+ expect(wrapper.findByLabelText(__('Weight')).exists()).toBe(true);
+ });
+
+ describe('placeholder attribute', () => {
+ describe.each`
+ description | isEditing | canUpdate | value
+ ${'when not editing and cannot update'} | ${false} | ${false} | ${__('None')}
+ ${'when editing and cannot update'} | ${true} | ${false} | ${__('None')}
+ ${'when not editing and can update'} | ${false} | ${true} | ${__('None')}
+ ${'when editing and can update'} | ${true} | ${true} | ${__('Enter a number')}
+ `('$description', ({ isEditing, canUpdate, value }) => {
+ it(`has a value of "${value}"`, async () => {
+ createComponent({ canUpdate, isEditing });
+ await nextTick();
+
+ expect(findInput().attributes('placeholder')).toBe(value);
+ });
+ });
+ });
+
+ describe('readonly attribute', () => {
+ describe.each`
+ description | canUpdate | value
+ ${'when cannot update'} | ${false} | ${'readonly'}
+ ${'when can update'} | ${true} | ${undefined}
+ `('$description', ({ canUpdate, value }) => {
+ it(`renders readonly=${value}`, () => {
+ createComponent({ canUpdate });
+
+ expect(findInput().attributes('readonly')).toBe(value);
+ });
+ });
+ });
+
+ describe('type attribute', () => {
+ describe.each`
+ description | isEditing | canUpdate | type
+ ${'when not editing and cannot update'} | ${false} | ${false} | ${'text'}
+ ${'when editing and cannot update'} | ${true} | ${false} | ${'text'}
+ ${'when not editing and can update'} | ${false} | ${true} | ${'text'}
+ ${'when editing and can update'} | ${true} | ${true} | ${'number'}
+ `('$description', ({ isEditing, canUpdate, type }) => {
+ it(`has a value of "${type}"`, async () => {
+ createComponent({ canUpdate, isEditing });
+ await nextTick();
+
+ expect(findInput().attributes('type')).toBe(type);
+ });
+ });
+ });
+
+ describe('value attribute', () => {
+ describe.each`
+ weight | value
+ ${1} | ${'1'}
+ ${0} | ${'0'}
+ ${null} | ${''}
+ ${undefined} | ${''}
+ `('when `weight` prop is "$weight"', ({ weight, value }) => {
+ it(`value is "${value}"`, () => {
+ createComponent({ weight });
+
+ expect(findInput().element.value).toBe(value);
+ });
+ });
+ });
+
+ describe('when blurred', () => {
+ it('calls a mutation to update the weight', () => {
+ const weight = 0;
+ createComponent({ isEditing: true, weight });
+
+ findInput().trigger('blur');
+
+ expect(mutateSpy).toHaveBeenCalledWith({
+ mutation: localUpdateWorkItemMutation,
+ variables: {
+ input: {
+ id: workItemId,
+ weight,
+ },
+ },
+ });
+ });
+
+ it('tracks updating the weight', () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ createComponent();
+
+ findInput().trigger('blur');
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_weight', {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_weight',
+ property: 'type_Task',
+ });
});
});
});
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index bf3f4e1364d..0359caf7116 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -1,3 +1,22 @@
+export const mockAssignees = [
+ {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/1',
+ avatarUrl: '',
+ webUrl: '',
+ name: 'John Doe',
+ username: 'doe_I',
+ },
+ {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/2',
+ avatarUrl: '',
+ webUrl: '',
+ name: 'Marcus Rutherford',
+ username: 'ruthfull',
+ },
+];
+
export const workItemQueryResponse = {
data: {
workItem: {
@@ -23,6 +42,32 @@ export const workItemQueryResponse = {
descriptionHtml:
'<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>',
},
+ {
+ __typename: 'WorkItemWidgetAssignees',
+ type: 'ASSIGNEES',
+ allowsMultipleAssignees: true,
+ assignees: {
+ nodes: mockAssignees,
+ },
+ },
+ {
+ __typename: 'WorkItemWidgetHierarchy',
+ type: 'HIERARCHY',
+ parent: {
+ id: 'gid://gitlab/Issue/1',
+ iid: '5',
+ title: 'Parent title',
+ },
+ children: {
+ edges: [
+ {
+ node: {
+ id: 'gid://gitlab/WorkItem/444',
+ },
+ },
+ ],
+ },
+ },
],
},
},
@@ -47,13 +92,28 @@ export const updateWorkItemMutationResponse = {
deleteWorkItem: false,
updateWorkItem: false,
},
- widgets: [],
+ widgets: [
+ {
+ children: {
+ edges: [
+ {
+ node: 'gid://gitlab/WorkItem/444',
+ },
+ ],
+ },
+ },
+ ],
},
},
},
};
-export const workItemResponseFactory = ({ canUpdate } = {}) => ({
+export const workItemResponseFactory = ({
+ canUpdate = false,
+ allowsMultipleAssignees = true,
+ assigneesWidgetPresent = true,
+ parent = null,
+} = {}) => ({
data: {
workItem: {
__typename: 'WorkItem',
@@ -78,6 +138,30 @@ export const workItemResponseFactory = ({ canUpdate } = {}) => ({
descriptionHtml:
'<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>',
},
+ assigneesWidgetPresent
+ ? {
+ __typename: 'WorkItemWidgetAssignees',
+ type: 'ASSIGNEES',
+ allowsMultipleAssignees,
+ assignees: {
+ nodes: mockAssignees,
+ },
+ }
+ : { type: 'MOCK TYPE' },
+ {
+ __typename: 'WorkItemWidgetHierarchy',
+ type: 'HIERARCHY',
+ children: {
+ edges: [
+ {
+ node: {
+ id: 'gid://gitlab/WorkItem/444',
+ },
+ },
+ ],
+ },
+ parent,
+ },
],
},
},
@@ -140,13 +224,45 @@ export const createWorkItemFromTaskMutationResponse = {
__typename: 'WorkItemCreateFromTaskPayload',
errors: [],
workItem: {
- descriptionHtml: '<p>New description</p>',
- id: 'gid://gitlab/WorkItem/13',
__typename: 'WorkItem',
+ description: 'New description',
+ id: 'gid://gitlab/WorkItem/1',
+ title: 'Updated title',
+ state: 'OPEN',
+ workItemType: {
+ __typename: 'WorkItemType',
+ id: 'gid://gitlab/WorkItems::Type/5',
+ name: 'Task',
+ },
userPermissions: {
deleteWorkItem: false,
updateWorkItem: false,
},
+ widgets: [
+ {
+ __typename: 'WorkItemWidgetDescription',
+ type: 'DESCRIPTION',
+ description: 'New description',
+ descriptionHtml: '<p>New description</p>',
+ },
+ ],
+ },
+ newWorkItem: {
+ __typename: 'WorkItem',
+ id: 'gid://gitlab/WorkItem/1000000',
+ title: 'Updated title',
+ state: 'OPEN',
+ description: '',
+ workItemType: {
+ __typename: 'WorkItemType',
+ id: 'gid://gitlab/WorkItems::Type/5',
+ name: 'Task',
+ },
+ userPermissions: {
+ deleteWorkItem: false,
+ updateWorkItem: false,
+ },
+ widgets: [],
},
},
},
@@ -275,3 +391,171 @@ export const workItemHierarchyResponse = {
},
},
};
+
+export const changeWorkItemParentMutationResponse = {
+ data: {
+ workItemUpdate: {
+ workItem: {
+ id: 'gid://gitlab/WorkItem/2',
+ workItemType: {
+ id: 'gid://gitlab/WorkItems::Type/5',
+ __typename: 'WorkItemType',
+ },
+ title: 'Foo',
+ state: 'OPEN',
+ __typename: 'WorkItem',
+ },
+ errors: [],
+ __typename: 'WorkItemUpdatePayload',
+ },
+ },
+};
+
+export const availableWorkItemsResponse = {
+ data: {
+ workspace: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/2',
+ workItems: {
+ edges: [
+ {
+ node: {
+ id: 'gid://gitlab/WorkItem/458',
+ title: 'Task 1',
+ state: 'OPEN',
+ },
+ },
+ {
+ node: {
+ id: 'gid://gitlab/WorkItem/459',
+ title: 'Task 2',
+ state: 'OPEN',
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
+export const projectMembersResponseWithCurrentUser = {
+ data: {
+ workspace: {
+ id: '1',
+ __typename: 'Project',
+ users: {
+ nodes: [
+ {
+ id: 'user-2',
+ user: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/5',
+ avatarUrl: '/avatar2',
+ name: 'rookie',
+ username: 'rookie',
+ webUrl: 'rookie',
+ status: null,
+ },
+ },
+ {
+ id: 'user-1',
+ user: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: '/root',
+ status: null,
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
+export const projectMembersResponseWithoutCurrentUser = {
+ data: {
+ workspace: {
+ id: '1',
+ __typename: 'Project',
+ users: {
+ nodes: [
+ {
+ id: 'user-2',
+ user: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/5',
+ avatarUrl: '/avatar2',
+ name: 'rookie',
+ username: 'rookie',
+ webUrl: 'rookie',
+ status: null,
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
+export const currentUserResponse = {
+ data: {
+ currentUser: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: '/root',
+ },
+ },
+};
+
+export const currentUserNullResponse = {
+ data: {
+ currentUser: null,
+ },
+};
+
+export const mockLabels = [
+ {
+ __typename: 'Label',
+ id: 'gid://gitlab/Label/1',
+ title: 'Label 1',
+ description: '',
+ color: '#f00',
+ textColor: '#00f',
+ },
+ {
+ __typename: 'Label',
+ id: 'gid://gitlab/Label/2',
+ title: 'Label 2',
+ description: '',
+ color: '#b00',
+ textColor: '#00b',
+ },
+];
+
+export const projectLabelsResponse = {
+ data: {
+ workspace: {
+ id: '1',
+ __typename: 'Project',
+ labels: {
+ nodes: mockLabels,
+ },
+ },
+ },
+};
+
+export const mockParent = {
+ parent: {
+ id: 'gid://gitlab/Issue/1',
+ iid: '5',
+ title: 'Parent title',
+ },
+};
diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js
index e89477ed599..fed8be3783a 100644
--- a/spec/frontend/work_items/pages/create_work_item_spec.js
+++ b/spec/frontend/work_items/pages/create_work_item_spec.js
@@ -9,11 +9,7 @@ import ItemTitle from '~/work_items/components/item_title.vue';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql';
-import {
- projectWorkItemTypesQueryResponse,
- createWorkItemMutationResponse,
- createWorkItemFromTaskMutationResponse,
-} from '../mock_data';
+import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data';
jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] }));
@@ -25,9 +21,6 @@ describe('Create work item component', () => {
const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse);
const createWorkItemSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse);
- const createWorkItemFromTaskSuccessHandler = jest
- .fn()
- .mockResolvedValue(createWorkItemFromTaskMutationResponse);
const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
const findAlert = () => wrapper.findComponent(GlAlert);
@@ -122,49 +115,6 @@ describe('Create work item component', () => {
});
});
- describe('when displayed in a modal', () => {
- beforeEach(() => {
- createComponent({
- props: {
- isModal: true,
- },
- mutationHandler: createWorkItemFromTaskSuccessHandler,
- });
- });
-
- it('emits `closeModal` event on Cancel button click', () => {
- findCancelButton().vm.$emit('click');
-
- expect(wrapper.emitted('closeModal')).toEqual([[]]);
- });
-
- it('emits `onCreate` on successful mutation', async () => {
- findTitleInput().vm.$emit('title-input', 'Test title');
-
- wrapper.find('form').trigger('submit');
- await waitForPromises();
-
- expect(wrapper.emitted('onCreate')).toEqual([['<p>New description</p>']]);
- });
-
- it('does not right margin for create button', () => {
- expect(findCreateButton().classes()).not.toContain('gl-mr-3');
- });
-
- it('adds right margin for cancel button', () => {
- expect(findCancelButton().classes()).toContain('gl-mr-3');
- });
-
- it('adds padding for content', () => {
- expect(findContent().classes('gl-px-5')).toBe(true);
- });
-
- it('defaults type to `Task`', async () => {
- await waitForPromises();
- expect(findSelect().attributes('value')).toBe('gid://gitlab/WorkItems::Type/3');
- });
- });
-
it('displays a loading icon inside dropdown when work items query is loading', () => {
createComponent();
diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js
index b9724034cb4..43869468ad0 100644
--- a/spec/frontend/work_items/pages/work_item_detail_spec.js
+++ b/spec/frontend/work_items/pages/work_item_detail_spec.js
@@ -1,26 +1,36 @@
-import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
+import { GlAlert, GlSkeletonLoader, 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 waitForPromises from 'helpers/wait_for_promises';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import WorkItemDescription from '~/work_items/components/work_item_description.vue';
import WorkItemState from '~/work_items/components/work_item_state.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
+import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
import WorkItemWeight from '~/work_items/components/work_item_weight.vue';
+import WorkItemInformation from '~/work_items/components/work_item_information.vue';
import { i18n } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
import { temporaryConfig } from '~/work_items/graphql/provider';
-import { workItemTitleSubscriptionResponse, workItemQueryResponse } from '../mock_data';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import {
+ workItemTitleSubscriptionResponse,
+ workItemResponseFactory,
+ mockParent,
+} from '../mock_data';
describe('WorkItemDetail component', () => {
let wrapper;
+ useLocalStorageSpy();
Vue.use(VueApollo);
+ const workItemQueryResponse = workItemResponseFactory();
const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
@@ -30,9 +40,17 @@ describe('WorkItemDetail component', () => {
const findWorkItemState = () => wrapper.findComponent(WorkItemState);
const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription);
const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees);
+ const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels);
const findWorkItemWeight = () => wrapper.findComponent(WorkItemWeight);
+ const findParent = () => wrapper.find('[data-testid="work-item-parent"]');
+ const findParentButton = () => findParent().findComponent(GlButton);
+ const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]');
+ const findWorkItemType = () => wrapper.find('[data-testid="work-item-type"]');
+ const findWorkItemInformationAlert = () => wrapper.findComponent(WorkItemInformation);
+ const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
const createComponent = ({
+ isModal = false,
workItemId = workItemQueryResponse.data.workItem.id,
handler = successHandler,
subscriptionHandler = initialSubscriptionHandler,
@@ -50,7 +68,7 @@ describe('WorkItemDetail component', () => {
typePolicies: includeWidgets ? temporaryConfig.cacheConfig.typePolicies : {},
},
),
- propsData: { workItemId },
+ propsData: { isModal, workItemId },
provide: {
glFeatures: {
workItemsMvc2: workItemsMvc2Enabled,
@@ -98,6 +116,36 @@ describe('WorkItemDetail component', () => {
});
});
+ describe('close button', () => {
+ describe('when isModal prop is false', () => {
+ it('does not render', async () => {
+ createComponent({ isModal: false });
+ await waitForPromises();
+
+ expect(findCloseButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when isModal prop is true', () => {
+ it('renders', async () => {
+ createComponent({ isModal: true });
+ await waitForPromises();
+
+ expect(findCloseButton().props('icon')).toBe('close');
+ expect(findCloseButton().attributes('aria-label')).toBe('Close');
+ });
+
+ it('emits `close` event when clicked', async () => {
+ createComponent({ isModal: true });
+ await waitForPromises();
+
+ findCloseButton().vm.$emit('click');
+
+ expect(wrapper.emitted('close')).toEqual([[]]);
+ });
+ });
+ });
+
describe('description', () => {
it('does not show description widget if loading description fails', () => {
createComponent();
@@ -107,13 +155,56 @@ describe('WorkItemDetail component', () => {
it('shows description widget if description loads', async () => {
createComponent();
-
await waitForPromises();
expect(findWorkItemDescription().exists()).toBe(true);
});
});
+ describe('secondary breadcrumbs', () => {
+ it('does not show secondary breadcrumbs by default', () => {
+ createComponent();
+
+ expect(findParent().exists()).toBe(false);
+ });
+
+ it('does not show secondary breadcrumbs if there is not a parent', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findParent().exists()).toBe(false);
+ });
+
+ it('shows work item type if there is not a parent', async () => {
+ createComponent();
+
+ await waitForPromises();
+ expect(findWorkItemType().exists()).toBe(true);
+ });
+
+ describe('with parent', () => {
+ beforeEach(() => {
+ const parentResponse = workItemResponseFactory(mockParent);
+ createComponent({ handler: jest.fn().mockResolvedValue(parentResponse) });
+
+ return waitForPromises();
+ });
+
+ it('shows secondary breadcrumbs if there is a parent', () => {
+ expect(findParent().exists()).toBe(true);
+ });
+
+ it('does not show work item type', async () => {
+ expect(findWorkItemType().exists()).toBe(false);
+ });
+
+ it('sets the parent breadcrumb URL', () => {
+ expect(findParentButton().attributes().href).toBe('../../issues/5');
+ });
+ });
+ });
+
it('shows an error message when the work item query was unsuccessful', async () => {
const errorHandler = jest.fn().mockRejectedValue('Oops');
createComponent({ handler: errorHandler });
@@ -145,7 +236,6 @@ describe('WorkItemDetail component', () => {
it('renders assignees component when assignees widget is returned from the API', async () => {
createComponent({
workItemsMvc2Enabled: true,
- includeWidgets: true,
});
await waitForPromises();
@@ -155,7 +245,9 @@ describe('WorkItemDetail component', () => {
it('does not render assignees component when assignees widget is not returned from the API', async () => {
createComponent({
workItemsMvc2Enabled: true,
- includeWidgets: false,
+ handler: jest
+ .fn()
+ .mockResolvedValue(workItemResponseFactory({ assigneesWidgetPresent: false })),
});
await waitForPromises();
@@ -170,6 +262,19 @@ describe('WorkItemDetail component', () => {
expect(findWorkItemAssignees().exists()).toBe(false);
});
+ describe('labels widget', () => {
+ it.each`
+ description | includeWidgets | exists
+ ${'renders when widget is returned from API'} | ${true} | ${true}
+ ${'does not render when widget is not returned from API'} | ${false} | ${false}
+ `('$description', async ({ includeWidgets, exists }) => {
+ createComponent({ includeWidgets, workItemsMvc2Enabled: true });
+ await waitForPromises();
+
+ expect(findWorkItemLabels().exists()).toBe(exists);
+ });
+ });
+
describe('weight widget', () => {
describe('when work_items_mvc_2 feature flag is enabled', () => {
describe.each`
@@ -201,4 +306,22 @@ describe('WorkItemDetail component', () => {
});
});
});
+
+ describe('work item information', () => {
+ beforeEach(() => {
+ createComponent();
+ return waitForPromises();
+ });
+
+ it('is visible when viewed for the first time and sets localStorage value', async () => {
+ localStorage.clear();
+ expect(findWorkItemInformationAlert().exists()).toBe(true);
+ expect(findLocalStorageSync().props('value')).toBe(true);
+ });
+
+ it('is not visible after reading local storage input', async () => {
+ await findLocalStorageSync().vm.$emit('input', false);
+ expect(findWorkItemInformationAlert().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js
index 3c5da94114e..d9372f2bcf0 100644
--- a/spec/frontend/work_items/pages/work_item_root_spec.js
+++ b/spec/frontend/work_items/pages/work_item_root_spec.js
@@ -52,6 +52,7 @@ describe('Work items root component', () => {
createComponent();
expect(findWorkItemDetail().props()).toEqual({
+ isModal: false,
workItemId: 'gid://gitlab/WorkItem/1',
workItemParentId: null,
});