diff options
Diffstat (limited to 'spec/frontend/sidebar')
8 files changed, 753 insertions, 38 deletions
diff --git a/spec/frontend/sidebar/assignees_spec.js b/spec/frontend/sidebar/assignees_spec.js index 74dce499999..be27a800418 100644 --- a/spec/frontend/sidebar/assignees_spec.js +++ b/spec/frontend/sidebar/assignees_spec.js @@ -19,7 +19,7 @@ describe('Assignee component', () => { }); }; - const findComponentTextNoUsers = () => wrapper.find('.assign-yourself'); + const findComponentTextNoUsers = () => wrapper.find('[data-testid="no-value"]'); const findCollapsedChildren = () => wrapper.findAll('.sidebar-collapsed-icon > *'); afterEach(() => { @@ -64,7 +64,7 @@ describe('Assignee component', () => { }); jest.spyOn(wrapper.vm, '$emit'); - wrapper.find('.assign-yourself .btn-link').trigger('click'); + wrapper.find('[data-testid="assign-yourself"]').trigger('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted('assign-self')).toBeTruthy(); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js index cfbe7227915..b738d931040 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js @@ -4,11 +4,16 @@ import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_ describe('Sidebar invite members component', () => { let wrapper; + const issuableType = 'issue'; const findDirectInviteLink = () => wrapper.findComponent(InviteMembersTrigger); const createComponent = () => { - wrapper = shallowMount(SidebarInviteMembers); + wrapper = shallowMount(SidebarInviteMembers, { + propsData: { + issuableType, + }, + }); }; afterEach(() => { @@ -23,5 +28,9 @@ describe('Sidebar invite members component', () => { it('renders a direct link to project members path', () => { expect(findDirectInviteLink().exists()).toBe(true); }); + + it('has expected attributes on the trigger', () => { + expect(findDirectInviteLink().props('triggerSource')).toBe('issue-assignee-dropdown'); + }); }); }); diff --git a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js index 91cbcc6cc27..619e89beb23 100644 --- a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js +++ b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js @@ -22,6 +22,10 @@ describe('Sidebar date Widget', () => { let fakeApollo; const date = '2021-04-15'; + window.gon = { + first_day_of_week: 1, + }; + const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); const findPopoverIcon = () => wrapper.find('[data-testid="inherit-date-popover"]'); const findDatePicker = () => wrapper.find(GlDatepicker); @@ -119,11 +123,12 @@ describe('Sidebar date Widget', () => { expect(wrapper.emitted('dueDateUpdated')).toEqual([[date]]); }); - it('uses a correct prop to set the initial date for GlDatePicker', () => { + it('uses a correct prop to set the initial date and first day of the week for GlDatePicker', () => { expect(findDatePicker().props()).toMatchObject({ value: null, autocomplete: 'off', defaultDate: expect.any(Object), + firstDay: window.gon.first_day_of_week, }); }); diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js new file mode 100644 index 00000000000..8d58854b013 --- /dev/null +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -0,0 +1,503 @@ +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLink, + GlSearchBoxByType, + GlFormInput, + GlLoadingIcon, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; + +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { IssuableType } from '~/issue_show/constants'; +import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { IssuableAttributeType } from '~/sidebar/constants'; +import projectIssueMilestoneMutation from '~/sidebar/queries/project_issue_milestone.mutation.graphql'; +import projectIssueMilestoneQuery from '~/sidebar/queries/project_issue_milestone.query.graphql'; +import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; + +import { + mockIssue, + mockProjectMilestonesResponse, + noCurrentMilestoneResponse, + mockMilestoneMutationResponse, + mockMilestone2, + emptyProjectMilestonesResponse, +} from '../mock_data'; + +jest.mock('~/flash'); + +const localVue = createLocalVue(); + +describe('SidebarDropdownWidget', () => { + let wrapper; + let mockApollo; + + const promiseData = { issuableSetAttribute: { issue: { attribute: { id: '123' } } } }; + const firstErrorMsg = 'first error'; + const promiseWithErrors = { + ...promiseData, + issuableSetAttribute: { ...promiseData.issuableSetAttribute, errors: [firstErrorMsg] }, + }; + + const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData }); + const mutationError = () => + jest.fn().mockRejectedValue('Failed to set milestone on this issue. Please try again.'); + const mutationSuccessWithErrors = () => jest.fn().mockResolvedValue({ data: promiseWithErrors }); + + const findGlLink = () => wrapper.findComponent(GlLink); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownText = () => wrapper.findComponent(GlDropdownText); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemWithText = (text) => + findAllDropdownItems().wrappers.find((x) => x.text() === text); + + const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findEditButton = () => findSidebarEditableItem().find('[data-testid="edit-button"]'); + const findEditableLoadingIcon = () => findSidebarEditableItem().findComponent(GlLoadingIcon); + const findAttributeItems = () => wrapper.findByTestId('milestone-items'); + const findSelectedAttribute = () => wrapper.findByTestId('select-milestone'); + const findNoAttributeItem = () => wrapper.findByTestId('no-milestone-item'); + const findLoadingIconDropdown = () => wrapper.findByTestId('loading-icon-dropdown'); + + const waitForDropdown = async () => { + // BDropdown first changes its `visible` property + // in a requestAnimationFrame callback. + // It then emits `shown` event in a watcher for `visible` + // Hence we need both of these: + await waitForPromises(); + await wrapper.vm.$nextTick(); + }; + + const waitForApollo = async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + }; + + // Used with createComponentWithApollo which uses 'mount' + const clickEdit = async () => { + await findEditButton().trigger('click'); + + await waitForDropdown(); + + // We should wait for attributes list to be fetched. + await waitForApollo(); + }; + + // Used with createComponent which shallow mounts components + const toggleDropdown = async () => { + wrapper.vm.$refs.editable.expand(); + + await waitForDropdown(); + }; + + const createComponentWithApollo = async ({ + requestHandlers = [], + projectMilestonesSpy = jest.fn().mockResolvedValue(mockProjectMilestonesResponse), + currentMilestoneSpy = jest.fn().mockResolvedValue(noCurrentMilestoneResponse), + } = {}) => { + localVue.use(VueApollo); + mockApollo = createMockApollo([ + [projectMilestonesQuery, projectMilestonesSpy], + [projectIssueMilestoneQuery, currentMilestoneSpy], + ...requestHandlers, + ]); + + wrapper = extendedWrapper( + mount(SidebarDropdownWidget, { + localVue, + provide: { canUpdate: true }, + apolloProvider: mockApollo, + propsData: { + workspacePath: mockIssue.projectPath, + attrWorkspacePath: mockIssue.projectPath, + iid: mockIssue.iid, + issuableType: IssuableType.Issue, + issuableAttribute: IssuableAttributeType.Milestone, + }, + attachTo: document.body, + }), + ); + + await waitForApollo(); + }; + + const createComponent = ({ data = {}, mutationPromise = mutationSuccess, queries = {} } = {}) => { + wrapper = extendedWrapper( + shallowMount(SidebarDropdownWidget, { + provide: { canUpdate: true }, + data() { + return data; + }, + propsData: { + workspacePath: '', + attrWorkspacePath: '', + iid: '', + issuableType: IssuableType.Issue, + issuableAttribute: IssuableAttributeType.Milestone, + }, + mocks: { + $apollo: { + mutate: mutationPromise(), + queries: { + currentAttribute: { loading: false }, + attributesList: { loading: false }, + ...queries, + }, + }, + }, + stubs: { + SidebarEditableItem, + GlSearchBoxByType, + GlDropdown, + }, + }), + ); + + // We need to mock out `showDropdown` which + // invokes `show` method of BDropdown used inside GlDropdown. + jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation(); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when not editing', () => { + beforeEach(() => { + createComponent({ + data: { + currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl' }, + }, + stubs: { + GlDropdown, + SidebarEditableItem, + }, + }); + }); + + it('shows the current attribute', () => { + expect(findSelectedAttribute().text()).toBe('title'); + }); + + it('links to the current attribute', () => { + expect(findGlLink().attributes().href).toBe('webUrl'); + }); + + it('does not show a loading spinner next to the heading', () => { + expect(findEditableLoadingIcon().exists()).toBe(false); + }); + + it('shows a loading spinner while fetching the current attribute', () => { + createComponent({ + queries: { + currentAttribute: { loading: true }, + }, + }); + + expect(findEditableLoadingIcon().exists()).toBe(true); + }); + + it('shows the loading spinner and the title of the selected attribute while updating', () => { + createComponent({ + data: { + updating: true, + selectedTitle: 'Some milestone title', + }, + queries: { + currentAttribute: { loading: false }, + }, + }); + + expect(findEditableLoadingIcon().exists()).toBe(true); + expect(findSelectedAttribute().text()).toBe('Some milestone title'); + }); + + describe('when current attribute does not exist', () => { + it('renders "None" as the selected attribute title', () => { + createComponent(); + + expect(findSelectedAttribute().text()).toBe('None'); + }); + }); + }); + + describe('when a user can edit', () => { + describe('when user is editing', () => { + describe('when rendering the dropdown', () => { + it('shows a loading spinner while fetching a list of attributes', async () => { + createComponent({ + queries: { + attributesList: { loading: true }, + }, + }); + + await toggleDropdown(); + + expect(findLoadingIconDropdown().exists()).toBe(true); + }); + + describe('GlDropdownItem with the right title and id', () => { + const id = 'id'; + const title = 'title'; + + beforeEach(async () => { + createComponent({ + data: { attributesList: [{ id, title }], currentAttribute: { id, title } }, + }); + + await toggleDropdown(); + }); + + it('does not show a loading spinner', () => { + expect(findLoadingIconDropdown().exists()).toBe(false); + }); + + it('renders title $title', () => { + expect(findDropdownItemWithText(title).exists()).toBe(true); + }); + + it('checks the correct dropdown item', () => { + expect( + findAllDropdownItems() + .filter((w) => w.props('isChecked') === true) + .at(0) + .text(), + ).toBe(title); + }); + }); + + describe('when no data is assigned', () => { + beforeEach(async () => { + createComponent(); + + await toggleDropdown(); + }); + + it('finds GlDropdownItem with "No milestone"', () => { + expect(findNoAttributeItem().text()).toBe('No milestone'); + }); + + it('"No milestone" is checked', () => { + expect(findNoAttributeItem().props('isChecked')).toBe(true); + }); + + it('does not render any dropdown item', () => { + expect(findAttributeItems().exists()).toBe(false); + }); + }); + + describe('when clicking on dropdown item', () => { + describe('when currentAttribute is equal to attribute id', () => { + it('does not call setIssueAttribute mutation', async () => { + createComponent({ + data: { + attributesList: [{ id: 'id', title: 'title' }], + currentAttribute: { id: 'id', title: 'title' }, + }, + }); + + await toggleDropdown(); + + findDropdownItemWithText('title').vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(0); + }); + }); + + describe('when currentAttribute is not equal to attribute id', () => { + describe('when error', () => { + const bootstrapComponent = (mutationResp) => { + createComponent({ + data: { + attributesList: [ + { id: '123', title: '123' }, + { id: 'id', title: 'title' }, + ], + currentAttribute: '123', + }, + mutationPromise: mutationResp, + }); + }; + + describe.each` + description | mutationResp | expectedMsg + ${'top-level error'} | ${mutationError} | ${'Failed to set milestone on this issue. Please try again.'} + ${'user-recoverable error'} | ${mutationSuccessWithErrors} | ${firstErrorMsg} + `(`$description`, ({ mutationResp, expectedMsg }) => { + beforeEach(async () => { + bootstrapComponent(mutationResp); + + await toggleDropdown(); + + findDropdownItemWithText('title').vm.$emit('click'); + }); + + it(`calls createFlash with "${expectedMsg}"`, async () => { + await wrapper.vm.$nextTick(); + expect(createFlash).toHaveBeenCalledWith({ + message: expectedMsg, + captureError: true, + error: expectedMsg, + }); + }); + }); + }); + }); + }); + }); + + describe('when a user is searching', () => { + describe('when search result is not found', () => { + it('renders "No milestone found"', async () => { + createComponent(); + + await toggleDropdown(); + + findSearchBox().vm.$emit('input', 'non existing milestones'); + + await wrapper.vm.$nextTick(); + + expect(findDropdownText().text()).toBe('No milestone found'); + }); + }); + }); + }); + }); + + describe('with mock apollo', () => { + let error; + + beforeEach(() => { + jest.spyOn(Sentry, 'captureException'); + error = new Error('mayday'); + }); + + describe("when issuable type is 'issue'", () => { + describe('when dropdown is expanded and user can edit', () => { + let milestoneMutationSpy; + beforeEach(async () => { + milestoneMutationSpy = jest.fn().mockResolvedValue(mockMilestoneMutationResponse); + + await createComponentWithApollo({ + requestHandlers: [[projectIssueMilestoneMutation, milestoneMutationSpy]], + }); + + await clickEdit(); + }); + + it('renders the dropdown on clicking edit', async () => { + expect(findDropdown().isVisible()).toBe(true); + }); + + it('focuses on the input when dropdown is shown', async () => { + expect(document.activeElement).toEqual(wrapper.findComponent(GlFormInput).element); + }); + + describe('when currentAttribute is not equal to attribute id', () => { + describe('when update is successful', () => { + beforeEach(() => { + findDropdownItemWithText(mockMilestone2.title).vm.$emit('click'); + }); + + it('calls setIssueAttribute mutation', () => { + expect(milestoneMutationSpy).toHaveBeenCalledWith({ + iid: mockIssue.iid, + attributeId: getIdFromGraphQLId(mockMilestone2.id), + fullPath: mockIssue.projectPath, + }); + }); + + it('sets the value returned from the mutation to currentAttribute', async () => { + expect(findSelectedAttribute().text()).toBe(mockMilestone2.title); + }); + }); + }); + + describe('milestones', () => { + let projectMilestonesSpy; + + it('should call createFlash if milestones query fails', async () => { + await createComponentWithApollo({ + projectMilestonesSpy: jest.fn().mockRejectedValue(error), + }); + + await clickEdit(); + + expect(createFlash).toHaveBeenCalledWith({ + message: wrapper.vm.i18n.listFetchError, + captureError: true, + error: expect.any(Error), + }); + }); + + it('only fetches attributes when dropdown is opened', async () => { + projectMilestonesSpy = jest.fn().mockResolvedValueOnce(emptyProjectMilestonesResponse); + await createComponentWithApollo({ projectMilestonesSpy }); + + expect(projectMilestonesSpy).not.toHaveBeenCalled(); + + await clickEdit(); + + expect(projectMilestonesSpy).toHaveBeenNthCalledWith(1, { + fullPath: mockIssue.projectPath, + title: '', + state: 'active', + }); + }); + + describe('when a user is searching', () => { + const mockSearchTerm = 'foobar'; + + beforeEach(async () => { + projectMilestonesSpy = jest + .fn() + .mockResolvedValueOnce(emptyProjectMilestonesResponse); + await createComponentWithApollo({ projectMilestonesSpy }); + + await clickEdit(); + }); + + it('sends a projectMilestones query with the entered search term "foo"', async () => { + findSearchBox().vm.$emit('input', mockSearchTerm); + await wrapper.vm.$nextTick(); + + // Account for debouncing + jest.runAllTimers(); + + expect(projectMilestonesSpy).toHaveBeenNthCalledWith(2, { + fullPath: mockIssue.projectPath, + title: mockSearchTerm, + state: 'active', + }); + }); + }); + }); + }); + + describe('currentAttributes', () => { + it('should call createFlash if currentAttributes query fails', async () => { + await createComponentWithApollo({ + currentMilestoneSpy: jest.fn().mockRejectedValue(error), + }); + + expect(createFlash).toHaveBeenCalledWith({ + message: wrapper.vm.i18n.currentFetchError, + captureError: true, + error: expect.any(Error), + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js index 0aa5aa2f691..710fae8ddf7 100644 --- a/spec/frontend/sidebar/components/time_tracking/report_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js @@ -36,7 +36,7 @@ describe('Issuable Time Tracking Report', () => { issuableId: 1, issuableType, }, - propsData: { limitToHours }, + propsData: { limitToHours, issuableId: '1' }, localVue, apolloProvider: fakeApollo, }); diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js index f26cdcb8b20..e08bd80b18e 100644 --- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -1,7 +1,11 @@ import { mount } from '@vue/test-utils'; + import { stubTransition } from 'helpers/stub_transition'; import { createMockDirective } from 'helpers/vue_mock_directive'; import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; +import SidebarEventHub from '~/sidebar/event_hub'; + +import { issuableTimeTrackingResponse } from '../../mock_data'; describe('Issuable Time Tracker', () => { let wrapper; @@ -13,21 +17,39 @@ describe('Issuable Time Tracker', () => { const findReportLink = () => findByTestId('reportLink'); const defaultProps = { - timeEstimate: 10_000, // 2h 46m - timeSpent: 5_000, // 1h 23m - humanTimeEstimate: '2h 46m', - humanTimeSpent: '1h 23m', limitToHours: false, + fullPath: 'gitlab-org/gitlab-test', + issuableIid: '1', + initialTimeTracking: { + ...issuableTimeTrackingResponse.data.workspace.issuable, + }, }; - const mountComponent = ({ props = {} } = {}) => - mount(TimeTracker, { + const issuableTimeTrackingRefetchSpy = jest.fn(); + + const mountComponent = ({ props = {}, issuableType = 'issue', loading = false } = {}) => { + return mount(TimeTracker, { propsData: { ...defaultProps, ...props }, directives: { GlTooltip: createMockDirective() }, stubs: { transition: stubTransition(), }, + provide: { + issuableType, + }, + mocks: { + $apollo: { + queries: { + issuableTimeTracking: { + loading, + refetch: issuableTimeTrackingRefetchSpy, + query: jest.fn().mockResolvedValue(issuableTimeTrackingResponse), + }, + }, + }, + }, }); + }; afterEach(() => { wrapper.destroy(); @@ -44,13 +66,13 @@ describe('Issuable Time Tracker', () => { it('should correctly render timeEstimate', () => { expect(findByTestId('timeTrackingComparisonPane').html()).toContain( - defaultProps.humanTimeEstimate, + defaultProps.initialTimeTracking.humanTimeEstimate, ); }); - it('should correctly render time_spent', () => { + it('should correctly render totalTimeSpent', () => { expect(findByTestId('timeTrackingComparisonPane').html()).toContain( - defaultProps.humanTimeSpent, + defaultProps.initialTimeTracking.humanTotalTimeSpent, ); }); }); @@ -78,10 +100,12 @@ describe('Issuable Time Tracker', () => { beforeEach(() => { wrapper = mountComponent({ props: { - timeEstimate: 100_000, // 1d 3h - timeSpent: 5_000, // 1h 23m - humanTimeEstimate: '1d 3h', - humanTimeSpent: '1h 23m', + initialTimeTracking: { + timeEstimate: 100_000, // 1d 3h + totalTimeSpent: 5_000, // 1h 23m + humanTimeEstimate: '1d 3h', + humanTotalTimeSpent: '1h 23m', + }, }, }); }); @@ -108,8 +132,11 @@ describe('Issuable Time Tracker', () => { it('should display the remaining meter with the correct background color when over estimate', () => { wrapper = mountComponent({ props: { - timeEstimate: 10_000, // 2h 46m - timeSpent: 20_000_000, // 231 days + initialTimeTracking: { + ...defaultProps.initialTimeTracking, + timeEstimate: 10_000, // 2h 46m + totalTimeSpent: 20_000_000, // 231 days + }, }, }); @@ -122,8 +149,11 @@ describe('Issuable Time Tracker', () => { beforeEach(async () => { wrapper = mountComponent({ props: { - timeEstimate: 100_000, // 1d 3h limitToHours: true, + initialTimeTracking: { + ...defaultProps.initialTimeTracking, + timeEstimate: 100_000, // 1d 3h + }, }, }); }); @@ -140,10 +170,12 @@ describe('Issuable Time Tracker', () => { beforeEach(async () => { wrapper = mountComponent({ props: { - timeEstimate: 10_000, // 2h 46m - timeSpent: 0, - timeEstimateHumanReadable: '2h 46m', - timeSpentHumanReadable: '', + initialTimeTracking: { + timeEstimate: 10_000, // 2h 46m + totalTimeSpent: 0, + humanTimeEstimate: '2h 46m', + humanTotalTimeSpent: '', + }, }, }); await wrapper.vm.$nextTick(); @@ -159,10 +191,12 @@ describe('Issuable Time Tracker', () => { beforeEach(() => { wrapper = mountComponent({ props: { - timeEstimate: 0, - timeSpent: 5_000, // 1h 23m - timeEstimateHumanReadable: '2h 46m', - timeSpentHumanReadable: '1h 23m', + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 5_000, // 1h 23m + humanTimeEstimate: '2h 46m', + humanTotalTimeSpent: '1h 23m', + }, }, }); }); @@ -177,10 +211,12 @@ describe('Issuable Time Tracker', () => { beforeEach(() => { wrapper = mountComponent({ props: { - timeEstimate: 0, - timeSpent: 0, - timeEstimateHumanReadable: '', - timeSpentHumanReadable: '', + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 0, + humanTimeEstimate: '', + humanTotalTimeSpent: '', + }, }, }); }); @@ -198,8 +234,11 @@ describe('Issuable Time Tracker', () => { beforeEach(() => { wrapper = mountComponent({ props: { - timeSpent: 0, - timeSpentHumanReadable: '', + initialTimeTracking: { + ...defaultProps.initialTimeTracking, + totalTimeSpent: 0, + humanTotalTimeSpent: '', + }, }, }); }); @@ -210,13 +249,20 @@ describe('Issuable Time Tracker', () => { }); describe('When time spent', () => { - beforeEach(() => { + it('link should appear on issue', () => { wrapper = mountComponent(); + expect(findReportLink().exists()).toBe(true); }); - it('link should appear', () => { + it('link should appear on merge request', () => { + wrapper = mountComponent({ issuableType: 'merge_request' }); expect(findReportLink().exists()).toBe(true); }); + + it('link should not appear on milestone', () => { + wrapper = mountComponent({ issuableType: 'milestone' }); + expect(findReportLink().exists()).toBe(false); + }); }); }); @@ -225,7 +271,16 @@ describe('Issuable Time Tracker', () => { const findCloseHelpButton = () => findByTestId('closeHelpButton'); beforeEach(async () => { - wrapper = mountComponent({ props: { timeEstimate: 0, timeSpent: 0 } }); + wrapper = mountComponent({ + props: { + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 0, + humanTimeEstimate: '', + humanTotalTimeSpent: '', + }, + }, + }); await wrapper.vm.$nextTick(); }); @@ -254,4 +309,14 @@ describe('Issuable Time Tracker', () => { }); }); }); + + describe('Event listeners', () => { + it('refetches issuableTimeTracking query when eventHub emits `timeTracker:refresh` event', async () => { + SidebarEventHub.$emit('timeTracker:refresh'); + + await wrapper.vm.$nextTick(); + + expect(issuableTimeTrackingRefetchSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index b052038661a..d6287b93fb9 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -513,4 +513,100 @@ export const participantsQueryResponse = { }, }; +export const mockGroupPath = 'gitlab-org'; +export const mockProjectPath = `${mockGroupPath}/some-project`; + +export const mockIssue = { + projectPath: mockProjectPath, + iid: '1', + groupPath: mockGroupPath, +}; + +export const mockIssueId = 'gid://gitlab/Issue/1'; + +export const mockMilestone1 = { + __typename: 'Milestone', + id: 'gid://gitlab/Milestone/1', + title: 'Foobar Milestone', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/1', + state: 'active', +}; + +export const mockMilestone2 = { + __typename: 'Milestone', + id: 'gid://gitlab/Milestone/2', + title: 'Awesome Milestone', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/2', + state: 'active', +}; + +export const mockProjectMilestonesResponse = { + data: { + workspace: { + attributes: { + nodes: [mockMilestone1, mockMilestone2], + }, + __typename: 'MilestoneConnection', + }, + __typename: 'Project', + }, +}; + +export const noCurrentMilestoneResponse = { + data: { + workspace: { + issuable: { id: mockIssueId, attribute: null, __typename: 'Issue' }, + __typename: 'Project', + }, + }, +}; + +export const mockMilestoneMutationResponse = { + data: { + issuableSetAttribute: { + errors: [], + issuable: { + id: 'gid://gitlab/Issue/1', + attribute: { + id: 'gid://gitlab/Milestone/2', + title: 'Awesome Milestone', + state: 'active', + __typename: 'Milestone', + }, + __typename: 'Issue', + }, + __typename: 'UpdateIssuePayload', + }, + }, +}; + +export const emptyProjectMilestonesResponse = { + data: { + workspace: { + attributes: { + nodes: [], + }, + __typename: 'MilestoneConnection', + }, + __typename: 'Project', + }, +}; + +export const issuableTimeTrackingResponse = { + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/1', + title: 'Commodi incidunt eos eos libero dicta dolores sed.', + timeEstimate: 10_000, // 2h 46m + totalTimeSpent: 5_000, // 1h 23m + humanTimeEstimate: '2h 46m', + humanTotalTimeSpent: '1h 23m', + }, + }, + }, +}; + export default mockData; diff --git a/spec/frontend/sidebar/track_invite_members_spec.js b/spec/frontend/sidebar/track_invite_members_spec.js new file mode 100644 index 00000000000..6c96e4cfc76 --- /dev/null +++ b/spec/frontend/sidebar/track_invite_members_spec.js @@ -0,0 +1,37 @@ +import $ from 'jquery'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; + +describe('Track user dropdown open', () => { + let trackingSpy; + let dropdownElement; + + beforeEach(() => { + document.body.innerHTML = ` + <div id="dummy-wrapper-element"> + <div class="js-sidebar-assignee-dropdown"> + <div class="js-invite-members-track" data-track-event="_track_event_" data-track-label="_track_label_"> + </div> + </div> + </div> + `; + + dropdownElement = document.querySelector('.js-sidebar-assignee-dropdown'); + trackingSpy = mockTracking('_category_', dropdownElement, jest.spyOn); + document.body.dataset.page = 'some:page'; + + trackShowInviteMemberLink(dropdownElement); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('sends a tracking event when the dropdown is opened and contains Invite Members link', () => { + $(dropdownElement).trigger('shown.bs.dropdown'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, '_track_event_', { + label: '_track_label_', + }); + }); +}); |