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:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-10-19 00:09:37 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-10-19 00:09:37 +0300
commitcace5e8ff1f766b8098e35adc94abc4402aeb2a9 (patch)
tree96bea3616ee60702be89f4845580f3b3db22f936 /spec/frontend
parente4220eeccaf1d53444fdd9102a4061336f91784e (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/groups/components/app_spec.js36
-rw-r--r--spec/frontend/groups/components/groups_spec.js9
-rw-r--r--spec/frontend/ide/init_gitlab_web_ide_spec.js6
-rw-r--r--spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js81
-rw-r--r--spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js2
-rw-r--r--spec/frontend/webhooks/components/form_url_app_spec.js97
-rw-r--r--spec/frontend/webhooks/components/form_url_mask_item_spec.js53
-rw-r--r--spec/frontend/work_items/components/work_item_detail_spec.js29
-rw-r--r--spec/frontend/work_items/components/work_item_milestone_spec.js247
-rw-r--r--spec/frontend/work_items/mock_data.js63
10 files changed, 587 insertions, 36 deletions
diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js
index 56529726350..091ec17d58e 100644
--- a/spec/frontend/groups/components/app_spec.js
+++ b/spec/frontend/groups/components/app_spec.js
@@ -11,6 +11,7 @@ import eventHub from '~/groups/event_hub';
import GroupsService from '~/groups/service/groups_service';
import GroupsStore from '~/groups/store/groups_store';
import EmptyState from '~/groups/components/empty_state.vue';
+import GroupsComponent from '~/groups/components/groups.vue';
import axios from '~/lib/utils/axios_utils';
import * as urlUtilities from '~/lib/utils/url_utility';
import setWindowLocation from 'helpers/set_window_location_helper';
@@ -388,24 +389,27 @@ describe('AppComponent', () => {
});
describe.each`
- action | groups | fromSearch | renderEmptyState | expected
- ${'subgroups_and_projects'} | ${[]} | ${false} | ${true} | ${true}
- ${''} | ${[]} | ${false} | ${true} | ${false}
- ${'subgroups_and_projects'} | ${mockGroups} | ${false} | ${true} | ${false}
- ${'subgroups_and_projects'} | ${[]} | ${true} | ${true} | ${false}
+ action | groups | fromSearch | shouldRenderEmptyState | searchEmpty
+ ${'subgroups_and_projects'} | ${[]} | ${false} | ${true} | ${false}
+ ${''} | ${[]} | ${false} | ${false} | ${false}
+ ${'subgroups_and_projects'} | ${mockGroups} | ${false} | ${false} | ${false}
+ ${'subgroups_and_projects'} | ${[]} | ${true} | ${false} | ${true}
`(
- 'when `action` is $action, `groups` is $groups, `fromSearch` is $fromSearch, and `renderEmptyState` is $renderEmptyState',
- ({ action, groups, fromSearch, renderEmptyState, expected }) => {
- it(`${expected ? 'renders' : 'does not render'} empty state`, async () => {
+ 'when `action` is $action, `groups` is $groups, and `fromSearch` is $fromSearch',
+ ({ action, groups, fromSearch, shouldRenderEmptyState, searchEmpty }) => {
+ it(`${shouldRenderEmptyState ? 'renders' : 'does not render'} empty state`, async () => {
createShallowComponent({
- propsData: { action, renderEmptyState },
+ propsData: { action, renderEmptyState: true },
});
+ await waitForPromises();
+
vm.updateGroups(groups, fromSearch);
await nextTick();
- expect(wrapper.findComponent(EmptyState).exists()).toBe(expected);
+ expect(wrapper.findComponent(EmptyState).exists()).toBe(shouldRenderEmptyState);
+ expect(wrapper.findComponent(GroupsComponent).props('searchEmpty')).toBe(searchEmpty);
});
},
);
@@ -445,18 +449,6 @@ describe('AppComponent', () => {
expect.any(Function),
);
});
-
- it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', async () => {
- createShallowComponent();
- await nextTick();
- expect(vm.searchEmptyMessage).toBe('No groups or projects matched your search');
- });
-
- it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', async () => {
- createShallowComponent({ propsData: { hideProjects: true } });
- await nextTick();
- expect(vm.searchEmptyMessage).toBe('No groups matched your search');
- });
});
describe('beforeDestroy', () => {
diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js
index 866868eff36..0cbb6cc8309 100644
--- a/spec/frontend/groups/components/groups_spec.js
+++ b/spec/frontend/groups/components/groups_spec.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { GlEmptyState } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import GroupFolderComponent from '~/groups/components/group_folder.vue';
@@ -15,7 +16,6 @@ describe('GroupsComponent', () => {
const defaultPropsData = {
groups: mockGroups,
pageInfo: mockPageInfo,
- searchEmptyMessage: 'No matching results',
searchEmpty: false,
};
@@ -67,13 +67,16 @@ describe('GroupsComponent', () => {
expect(wrapper.findComponent(GroupFolderComponent).exists()).toBe(true);
expect(findPaginationLinks().exists()).toBe(true);
- expect(wrapper.findByText(defaultPropsData.searchEmptyMessage).exists()).toBe(false);
+ expect(wrapper.findComponent(GlEmptyState).exists()).toBe(false);
});
it('should render empty search message when `searchEmpty` is `true`', () => {
createComponent({ propsData: { searchEmpty: true } });
- expect(wrapper.findByText(defaultPropsData.searchEmptyMessage).exists()).toBe(true);
+ expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
+ title: GroupsComponent.i18n.emptyStateTitle,
+ description: GroupsComponent.i18n.emptyStateDescription,
+ });
});
});
});
diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js
index ec8559f1b56..067da25cb52 100644
--- a/spec/frontend/ide/init_gitlab_web_ide_spec.js
+++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js
@@ -6,7 +6,7 @@ jest.mock('@gitlab/web-ide');
const ROOT_ELEMENT_ID = 'ide';
const TEST_NONCE = 'test123nonce';
-const TEST_PROJECT = { path_with_namespace: 'group1/project1' };
+const TEST_PROJECT_PATH = 'group1/project1';
const TEST_BRANCH_NAME = '12345-foo-patch';
const TEST_GITLAB_URL = 'https://test-gitlab/';
const TEST_GITLAB_WEB_IDE_PUBLIC_PATH = 'test/webpack/assets/gitlab-web-ide/public/path';
@@ -18,7 +18,7 @@ describe('ide/init_gitlab_web_ide', () => {
el.id = ROOT_ELEMENT_ID;
// why: We'll test that this class is removed later
el.classList.add('ide-loading');
- el.dataset.project = JSON.stringify(TEST_PROJECT);
+ el.dataset.projectPath = TEST_PROJECT_PATH;
el.dataset.cspNonce = TEST_NONCE;
el.dataset.branchName = TEST_BRANCH_NAME;
@@ -43,7 +43,7 @@ describe('ide/init_gitlab_web_ide', () => {
it('calls start with element', () => {
expect(start).toHaveBeenCalledWith(findRootElement(), {
baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`,
- projectPath: TEST_PROJECT.path_with_namespace,
+ projectPath: TEST_PROJECT_PATH,
ref: TEST_BRANCH_NAME,
gitlabUrl: TEST_GITLAB_URL,
nonce: TEST_NONCE,
diff --git a/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js b/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js
new file mode 100644
index 00000000000..c1e1545944b
--- /dev/null
+++ b/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js
@@ -0,0 +1,81 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlListbox } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import searchUsersQuery from '~/graphql_shared/queries/users_search_all.query.graphql';
+
+import createMockApollo from 'helpers/mock_apollo_helper';
+import UserSelect from '~/pages/import/fogbugz/new_user_map/components/user_select.vue';
+
+Vue.use(VueApollo);
+
+const USERS_RESPONSE = {
+ data: {
+ users: {
+ nodes: [
+ {
+ id: 'gid://gitlab/User/44',
+ avatarUrl: '/avatar1',
+ webUrl: '/reported_user_22',
+ name: 'Birgit Steuber',
+ username: 'reported_user_22',
+ __typename: 'UserCore',
+ },
+ {
+ id: 'gid://gitlab/User/43',
+ avatarUrl: '/avatar2',
+ webUrl: '/reported_user_21',
+ name: 'Luke Spinka',
+ username: 'reported_user_21',
+ __typename: 'UserCore',
+ },
+ ],
+ __typename: 'UserCoreConnection',
+ },
+ },
+};
+
+describe('fogbugz user select component', () => {
+ let wrapper;
+ const searchQueryHandlerSuccess = jest.fn().mockResolvedValue(USERS_RESPONSE);
+
+ const createComponent = (propsData = { name: 'demo' }) => {
+ const fakeApollo = createMockApollo([[searchUsersQuery, searchQueryHandlerSuccess]]);
+
+ wrapper = shallowMount(UserSelect, {
+ apolloProvider: fakeApollo,
+ propsData,
+ });
+ };
+
+ it('renders hidden input with name from props', () => {
+ const name = 'test';
+ createComponent({ name });
+ expect(wrapper.find('input').attributes('name')).toBe(name);
+ });
+
+ it('syncs input value with value emitted from listbox', async () => {
+ createComponent();
+
+ const id = 8;
+
+ wrapper.findComponent(GlListbox).vm.$emit('select', `gid://gitlab/User/${id}`);
+ await nextTick();
+
+ expect(wrapper.get('input').attributes('value')).toBe(id.toString());
+ });
+
+ it('filters users when search is performed in listbox', async () => {
+ createComponent();
+ jest.runOnlyPendingTimers();
+
+ wrapper.findComponent(GlListbox).vm.$emit('search', 'test');
+ await nextTick();
+ jest.runOnlyPendingTimers();
+
+ expect(searchQueryHandlerSuccess).toHaveBeenCalledWith({
+ first: expect.anything(),
+ search: 'test',
+ });
+ });
+});
diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js
index 5788968100a..6622749da92 100644
--- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js
@@ -1144,7 +1144,7 @@ describe('MrWidgetOptions', () => {
${'WidgetCodeQuality'} | ${'i_testing_code_quality_widget_total'}
${'WidgetTerraform'} | ${'i_testing_terraform_widget_total'}
${'WidgetIssues'} | ${'i_testing_issues_widget_total'}
- ${'WidgetTestReport'} | ${'i_testing_summary_widget_total'}
+ ${'WidgetTestSummary'} | ${'i_testing_summary_widget_total'}
`(
"sends non-standard events for the '$widgetName' widget",
async ({ widgetName, nonStandardEvent }) => {
diff --git a/spec/frontend/webhooks/components/form_url_app_spec.js b/spec/frontend/webhooks/components/form_url_app_spec.js
index 40de3cc0d33..16e0a3f549e 100644
--- a/spec/frontend/webhooks/components/form_url_app_spec.js
+++ b/spec/frontend/webhooks/components/form_url_app_spec.js
@@ -1,15 +1,18 @@
import { nextTick } from 'vue';
-import { GlFormRadio, GlFormRadioGroup } from '@gitlab/ui';
+import { GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
import FormUrlApp from '~/webhooks/components/form_url_app.vue';
+import FormUrlMaskItem from '~/webhooks/components/form_url_mask_item.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
describe('FormUrlApp', () => {
let wrapper;
- const createComponent = () => {
- wrapper = shallowMountExtended(FormUrlApp);
+ const createComponent = ({ props } = {}) => {
+ wrapper = shallowMountExtended(FormUrlApp, {
+ propsData: { ...props },
+ });
};
afterEach(() => {
@@ -20,13 +23,17 @@ describe('FormUrlApp', () => {
const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
const findUrlMaskDisable = () => findAllRadioButtons().at(0);
const findUrlMaskEnable = () => findAllRadioButtons().at(1);
+ const findAllUrlMaskItems = () => wrapper.findAllComponents(FormUrlMaskItem);
+ const findAddItem = () => wrapper.findComponent(GlLink);
+ const findFormUrl = () => wrapper.findByTestId('form-url');
+ const findFormUrlPreview = () => wrapper.findByTestId('form-url-preview');
const findUrlMaskSection = () => wrapper.findByTestId('url-mask-section');
describe('template', () => {
it('renders radio buttons for URL masking', () => {
createComponent();
- expect(findAllRadioButtons().length).toBe(2);
+ expect(findAllRadioButtons()).toHaveLength(2);
expect(findUrlMaskDisable().text()).toBe(FormUrlApp.i18n.radioFullUrlText);
expect(findUrlMaskEnable().text()).toBe(FormUrlApp.i18n.radioMaskUrlText);
});
@@ -48,6 +55,88 @@ describe('FormUrlApp', () => {
it('renders mask section', () => {
expect(findUrlMaskSection().exists()).toBe(true);
});
+
+ it('renders an empty mask item by default', () => {
+ expect(findAllUrlMaskItems()).toHaveLength(1);
+
+ const firstItem = findAllUrlMaskItems().at(0);
+ expect(firstItem.props('itemKey')).toBeNull();
+ expect(firstItem.props('itemValue')).toBeNull();
+ });
+ });
+
+ describe('with mask items', () => {
+ const mockItem1 = { key: 'key1', value: 'value1' };
+ const mockItem2 = { key: 'key2', value: 'value2' };
+
+ beforeEach(() => {
+ createComponent({
+ props: { initialUrlVariables: [mockItem1, mockItem2] },
+ });
+ });
+
+ it('renders masked URL preview', async () => {
+ const mockUrl = 'https://test.host/value1?secret=value2';
+
+ findFormUrl().vm.$emit('input', mockUrl);
+ await nextTick();
+
+ expect(findFormUrlPreview().attributes('value')).toBe(
+ 'https://test.host/{key1}?secret={key2}',
+ );
+ });
+
+ it('renders mask items correctly', () => {
+ expect(findAllUrlMaskItems()).toHaveLength(2);
+
+ const firstItem = findAllUrlMaskItems().at(0);
+ expect(firstItem.props('itemKey')).toBe(mockItem1.key);
+ expect(firstItem.props('itemValue')).toBe(mockItem1.value);
+
+ const secondItem = findAllUrlMaskItems().at(1);
+ expect(secondItem.props('itemKey')).toBe(mockItem2.key);
+ expect(secondItem.props('itemValue')).toBe(mockItem2.value);
+ });
+
+ describe('on mask item input', () => {
+ const mockInput = { index: 0, key: 'display', value: 'secret' };
+
+ it('updates mask item', async () => {
+ const firstItem = findAllUrlMaskItems().at(0);
+ firstItem.vm.$emit('input', mockInput);
+ await nextTick();
+
+ expect(firstItem.props('itemKey')).toBe(mockInput.key);
+ expect(firstItem.props('itemValue')).toBe(mockInput.value);
+ });
+ });
+
+ describe('when add item is clicked', () => {
+ it('adds mask item', async () => {
+ findAddItem().vm.$emit('click');
+ await nextTick();
+
+ expect(findAllUrlMaskItems()).toHaveLength(3);
+
+ const lastItem = findAllUrlMaskItems().at(-1);
+ expect(lastItem.props('itemKey')).toBeNull();
+ expect(lastItem.props('itemValue')).toBeNull();
+ });
+ });
+
+ describe('when remove item is clicked', () => {
+ it('removes the correct mask item', async () => {
+ const firstItem = findAllUrlMaskItems().at(0);
+ firstItem.vm.$emit('remove');
+ await nextTick();
+
+ expect(findAllUrlMaskItems()).toHaveLength(1);
+
+ const newFirstItem = findAllUrlMaskItems().at(0);
+ expect(newFirstItem.props('itemKey')).toBe(mockItem2.key);
+ expect(newFirstItem.props('itemValue')).toBe(mockItem2.value);
+ });
+ });
});
});
});
diff --git a/spec/frontend/webhooks/components/form_url_mask_item_spec.js b/spec/frontend/webhooks/components/form_url_mask_item_spec.js
index 76681e6ab26..ab028ef2997 100644
--- a/spec/frontend/webhooks/components/form_url_mask_item_spec.js
+++ b/spec/frontend/webhooks/components/form_url_mask_item_spec.js
@@ -1,3 +1,4 @@
+import { nextTick } from 'vue';
import { GlButton, GlFormInput } from '@gitlab/ui';
import FormUrlMaskItem from '~/webhooks/components/form_url_mask_item.vue';
@@ -10,10 +11,13 @@ describe('FormUrlMaskItem', () => {
const defaultProps = {
index: 0,
};
+ const mockKey = 'key';
+ const mockValue = 'value';
+ const mockInput = 'input';
- const createComponent = () => {
+ const createComponent = ({ props } = {}) => {
wrapper = shallowMountExtended(FormUrlMaskItem, {
- propsData: { ...defaultProps },
+ propsData: { ...defaultProps, ...props },
});
};
@@ -42,10 +46,55 @@ describe('FormUrlMaskItem', () => {
);
});
+ describe('on key input', () => {
+ beforeEach(async () => {
+ createComponent({ props: { itemKey: mockKey, itemValue: mockValue } });
+
+ findMaskItemKey().findComponent(GlFormInput).vm.$emit('input', mockInput);
+ await nextTick();
+ });
+
+ it('emits input event', () => {
+ expect(wrapper.emitted('input')).toEqual([
+ [{ index: defaultProps.index, key: mockInput, value: mockValue }],
+ ]);
+ });
+ });
+
+ describe('on value input', () => {
+ beforeEach(async () => {
+ createComponent({ props: { itemKey: mockKey, itemValue: mockValue } });
+
+ findMaskItemValue().findComponent(GlFormInput).vm.$emit('input', mockInput);
+ await nextTick();
+ });
+
+ it('emits input event', () => {
+ expect(wrapper.emitted('input')).toEqual([
+ [{ index: defaultProps.index, key: mockKey, value: mockInput }],
+ ]);
+ });
+ });
+
it('renders remove button', () => {
createComponent();
expect(findRemoveButton().props('icon')).toBe('remove');
});
+
+ describe('when remove button is clicked', () => {
+ const mockIndex = 5;
+
+ beforeEach(async () => {
+ createComponent({ props: { index: mockIndex } });
+
+ findRemoveButton().vm.$emit('click');
+ await nextTick();
+ });
+
+ it('emits remove event', () => {
+ expect(wrapper.emitted('remove')).toEqual([[mockIndex]]);
+ });
+ });
});
});
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 3580842fc1a..aae61b11196 100644
--- a/spec/frontend/work_items/components/work_item_detail_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_spec.js
@@ -20,6 +20,7 @@ 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 WorkItemMilestone from '~/work_items/components/work_item_milestone.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';
@@ -28,6 +29,7 @@ import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subs
import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql';
+import { temporaryConfig } from '~/graphql_shared/issuable_client';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import {
mockParent,
@@ -67,6 +69,7 @@ describe('WorkItemDetail component', () => {
const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate);
const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees);
const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels);
+ const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestone);
const findParent = () => wrapper.find('[data-testid="work-item-parent"]');
const findParentButton = () => findParent().findComponent(GlButton);
const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]');
@@ -82,6 +85,8 @@ describe('WorkItemDetail component', () => {
subscriptionHandler = titleSubscriptionHandler,
confidentialityMock = [updateWorkItemMutation, jest.fn()],
error = undefined,
+ includeWidgets = false,
+ workItemsMvc2Enabled = false,
} = {}) => {
const handlers = [
[workItemQuery, handler],
@@ -92,7 +97,13 @@ describe('WorkItemDetail component', () => {
];
wrapper = shallowMount(WorkItemDetail, {
- apolloProvider: createMockApollo(handlers),
+ apolloProvider: createMockApollo(
+ handlers,
+ {},
+ {
+ typePolicies: includeWidgets ? temporaryConfig.cacheConfig.typePolicies : {},
+ },
+ ),
propsData: { isModal, workItemId },
data() {
return {
@@ -101,6 +112,9 @@ describe('WorkItemDetail component', () => {
};
},
provide: {
+ glFeatures: {
+ workItemsMvc2: workItemsMvc2Enabled,
+ },
hasIssueWeightsFeature: true,
hasIterationsFeature: true,
projectNamespace: 'namespace',
@@ -527,6 +541,19 @@ describe('WorkItemDetail component', () => {
});
});
+ describe('milestone 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(findWorkItemMilestone().exists()).toBe(exists);
+ });
+ });
+
describe('work item information', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/work_items/components/work_item_milestone_spec.js b/spec/frontend/work_items/components/work_item_milestone_spec.js
new file mode 100644
index 00000000000..08cdf62ae52
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_milestone_spec.js
@@ -0,0 +1,247 @@
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlSkeletonLoader,
+ GlFormGroup,
+ GlDropdownText,
+} from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue';
+import { resolvers, temporaryConfig } from '~/graphql_shared/issuable_client';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
+import {
+ projectMilestonesResponse,
+ projectMilestonesResponseWithNoMilestones,
+ mockMilestoneWidgetResponse,
+ workItemResponseFactory,
+ updateWorkItemMutationErrorResponse,
+} from 'jest/work_items/mock_data';
+import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
+
+describe('WorkItemMilestone component', () => {
+ Vue.use(VueApollo);
+
+ let wrapper;
+
+ const workItemId = 'gid://gitlab/WorkItem/1';
+ const workItemType = 'Task';
+ const fullPath = 'full-path';
+
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+ const findNoMilestoneDropdownItem = () => wrapper.findByTestId('no-milestone');
+ const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findFirstDropdownItem = () => findDropdownItems().at(0);
+ const findDropdownTexts = () => wrapper.findAllComponents(GlDropdownText);
+ const findDropdownItemAtIndex = (index) => findDropdownItems().at(index);
+ const findDisabledTextSpan = () => wrapper.findByTestId('disabled-text');
+ const findDropdownTextAtIndex = (index) => findDropdownTexts().at(index);
+ const findInputGroup = () => wrapper.findComponent(GlFormGroup);
+
+ const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true });
+
+ const networkResolvedValue = new Error();
+
+ const successSearchQueryHandler = jest.fn().mockResolvedValue(projectMilestonesResponse);
+ const successSearchWithNoMatchingMilestones = jest
+ .fn()
+ .mockResolvedValue(projectMilestonesResponseWithNoMilestones);
+
+ const showDropdown = () => {
+ findDropdown().vm.$emit('shown');
+ };
+
+ const hideDropdown = () => {
+ findDropdown().vm.$emit('hide');
+ };
+
+ const createComponent = ({
+ canUpdate = true,
+ milestone = mockMilestoneWidgetResponse,
+ searchQueryHandler = successSearchQueryHandler,
+ } = {}) => {
+ const apolloProvider = createMockApollo(
+ [[projectMilestonesQuery, searchQueryHandler]],
+ resolvers,
+ {
+ typePolicies: temporaryConfig.cacheConfig.typePolicies,
+ },
+ );
+
+ apolloProvider.clients.defaultClient.writeQuery({
+ query: workItemQuery,
+ variables: {
+ id: workItemId,
+ },
+ data: workItemQueryResponse.data,
+ });
+
+ wrapper = shallowMountExtended(WorkItemMilestone, {
+ apolloProvider,
+ propsData: {
+ canUpdate,
+ workItemMilestone: milestone,
+ workItemId,
+ workItemType,
+ fullPath,
+ },
+ stubs: {
+ GlDropdown,
+ GlSearchBoxByType,
+ },
+ });
+ };
+
+ it('has "Milestone" label', () => {
+ createComponent();
+
+ expect(findInputGroup().exists()).toBe(true);
+ expect(findInputGroup().attributes('label')).toBe(WorkItemMilestone.i18n.MILESTONE);
+ });
+
+ describe('Default text with canUpdate false and milestone value', () => {
+ describe.each`
+ description | milestone | value
+ ${'when no milestone'} | ${null} | ${WorkItemMilestone.i18n.NONE}
+ ${'when milestone set'} | ${mockMilestoneWidgetResponse} | ${mockMilestoneWidgetResponse.title}
+ `('$description', ({ milestone, value }) => {
+ it(`has a value of "${value}"`, () => {
+ createComponent({ canUpdate: false, milestone });
+
+ expect(findDisabledTextSpan().text()).toBe(value);
+ expect(findDropdown().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('Default text value when canUpdate true and no milestone set', () => {
+ it(`has a value of "Add to milestone"`, () => {
+ createComponent({ canUpdate: true, milestone: null });
+
+ expect(findDropdown().props('text')).toBe(WorkItemMilestone.i18n.MILESTONE_PLACEHOLDER);
+ });
+ });
+
+ describe('Dropdown search', () => {
+ it('has the search box', () => {
+ createComponent();
+
+ expect(findSearchBox().exists()).toBe(true);
+ });
+
+ it('shows no matching results when no items', () => {
+ createComponent({
+ searchQueryHandler: successSearchWithNoMatchingMilestones,
+ });
+
+ expect(findDropdownTextAtIndex(0).text()).toBe(WorkItemMilestone.i18n.NO_MATCHING_RESULTS);
+ expect(findDropdownItems()).toHaveLength(1);
+ expect(findDropdownTexts()).toHaveLength(1);
+ });
+ });
+
+ describe('Dropdown options', () => {
+ beforeEach(() => {
+ createComponent({ canUpdate: true });
+ });
+
+ it('shows the skeleton loader when the items are being fetched on click', async () => {
+ showDropdown();
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('shows the milestones in dropdown when the items have finished fetching', async () => {
+ showDropdown();
+ await waitForPromises();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ expect(findNoMilestoneDropdownItem().exists()).toBe(true);
+ expect(findDropdownItems()).toHaveLength(
+ projectMilestonesResponse.data.workspace.attributes.nodes.length + 1,
+ );
+ });
+
+ it('changes the milestone to null when clicked on no milestone', async () => {
+ showDropdown();
+ findFirstDropdownItem().vm.$emit('click');
+
+ hideDropdown();
+ await nextTick();
+ expect(findDropdown().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findDropdown().props('loading')).toBe(false);
+ expect(findDropdown().props('text')).toBe(WorkItemMilestone.i18n.MILESTONE_PLACEHOLDER);
+ });
+
+ it('changes the milestone to the selected milestone', async () => {
+ const milestoneIndex = 1;
+ /** the index is -1 since no matching results is also a dropdown item */
+ const milestoneAtIndex =
+ projectMilestonesResponse.data.workspace.attributes.nodes[milestoneIndex - 1];
+ showDropdown();
+
+ await waitForPromises();
+ findDropdownItemAtIndex(milestoneIndex).vm.$emit('click');
+
+ hideDropdown();
+ await waitForPromises();
+
+ expect(findDropdown().props('text')).toBe(milestoneAtIndex.title);
+ });
+ });
+
+ describe('Error handlers', () => {
+ it.each`
+ errorType | expectedErrorMessage | mockValue | resolveFunction
+ ${'graphql error'} | ${'Something went wrong while updating the task. Please try again.'} | ${updateWorkItemMutationErrorResponse} | ${'mockResolvedValue'}
+ ${'network error'} | ${'Something went wrong while updating the task. Please try again.'} | ${networkResolvedValue} | ${'mockRejectedValue'}
+ `(
+ 'emits an error when there is a $errorType',
+ async ({ mockValue, expectedErrorMessage, resolveFunction }) => {
+ createComponent({
+ mutationHandler: jest.fn()[resolveFunction](mockValue),
+ canUpdate: true,
+ });
+
+ showDropdown();
+ findFirstDropdownItem().vm.$emit('click');
+ hideDropdown();
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[expectedErrorMessage]]);
+ },
+ );
+ });
+
+ describe('Tracking event', () => {
+ it('tracks updating the milestone', async () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ createComponent({ canUpdate: true });
+
+ showDropdown();
+ findFirstDropdownItem().vm.$emit('click');
+ hideDropdown();
+
+ await waitForPromises();
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_milestone', {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_milestone',
+ property: 'type_Task',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index a0ed4ed1425..ed90b11222a 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -186,6 +186,7 @@ export const workItemResponseFactory = ({
datesWidgetPresent = true,
labelsWidgetPresent = true,
weightWidgetPresent = true,
+ milestoneWidgetPresent = true,
iterationWidgetPresent = true,
confidential = false,
canInviteMembers = false,
@@ -279,6 +280,16 @@ export const workItemResponseFactory = ({
},
}
: { type: 'MOCK TYPE' },
+ milestoneWidgetPresent
+ ? {
+ __typename: 'WorkItemWidgetMilestone',
+ dueDate: null,
+ expired: false,
+ id: 'gid://gitlab/Milestone/30',
+ title: 'v4.0',
+ type: 'MILESTONE',
+ }
+ : { type: 'MOCK TYPE' },
{
__typename: 'WorkItemWidgetHierarchy',
type: 'HIERARCHY',
@@ -1059,3 +1070,55 @@ export const groupIterationsResponseWithNoIterations = {
},
},
};
+
+export const mockMilestoneWidgetResponse = {
+ dueDate: null,
+ expired: false,
+ id: 'gid://gitlab/Milestone/30',
+ title: 'v4.0',
+};
+
+export const projectMilestonesResponse = {
+ data: {
+ workspace: {
+ id: 'gid://gitlab/Project/1',
+ attributes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Milestone/5',
+ title: 'v4.0',
+ webUrl: '/gitlab-org/gitlab-test/-/milestones/5',
+ dueDate: null,
+ expired: false,
+ __typename: 'Milestone',
+ state: 'active',
+ },
+ {
+ id: 'gid://gitlab/Milestone/4',
+ title: 'v3.0',
+ webUrl: '/gitlab-org/gitlab-test/-/milestones/4',
+ dueDate: null,
+ expired: false,
+ __typename: 'Milestone',
+ state: 'active',
+ },
+ ],
+ __typename: 'MilestoneConnection',
+ },
+ __typename: 'Project',
+ },
+ },
+};
+
+export const projectMilestonesResponseWithNoMilestones = {
+ data: {
+ workspace: {
+ id: 'gid://gitlab/Project/1',
+ attributes: {
+ nodes: [],
+ __typename: 'MilestoneConnection',
+ },
+ __typename: 'Project',
+ },
+ },
+};