diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-19 00:09:37 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-19 00:09:37 +0300 |
commit | cace5e8ff1f766b8098e35adc94abc4402aeb2a9 (patch) | |
tree | 96bea3616ee60702be89f4845580f3b3db22f936 /spec/frontend | |
parent | e4220eeccaf1d53444fdd9102a4061336f91784e (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend')
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', + }, + }, +}; |