diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-04-05 21:08:51 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-04-05 21:08:51 +0300 |
commit | 9c05a84cac5e6519ef545b14ead8989719c6f612 (patch) | |
tree | e93937c87050f9f9b5603bfe9b7f8aca86e146c8 /spec/frontend/time_tracking | |
parent | d4e0452ed946ca0cf4dd0537675abeda7a4c0ffa (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend/time_tracking')
3 files changed, 597 insertions, 0 deletions
diff --git a/spec/frontend/time_tracking/components/timelog_source_cell_spec.js b/spec/frontend/time_tracking/components/timelog_source_cell_spec.js new file mode 100644 index 00000000000..b9be4689c38 --- /dev/null +++ b/spec/frontend/time_tracking/components/timelog_source_cell_spec.js @@ -0,0 +1,136 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TimelogSourceCell from '~/time_tracking/components/timelog_source_cell.vue'; +import { + IssuableStatusText, + STATUS_CLOSED, + STATUS_MERGED, + STATUS_OPEN, + STATUS_LOCKED, + STATUS_REOPENED, +} from '~/issues/constants'; + +const createIssuableTimelogMock = ( + type, + { title, state, webUrl, reference } = { + title: 'Issuable title', + state: STATUS_OPEN, + webUrl: 'https://example.com/issuable_url', + reference: '#111', + }, +) => { + return { + timelog: { + project: { + fullPath: 'group/project', + }, + [type]: { + title, + state, + webUrl, + reference, + }, + }, + }; +}; + +describe('TimelogSourceCell component', () => { + Vue.use(VueApollo); + + let wrapper; + + const findTitleContainer = () => wrapper.findByTestId('title-container'); + const findReferenceContainer = () => wrapper.findByTestId('reference-container'); + const findStateContainer = () => wrapper.findByTestId('state-container'); + + const mountComponent = ({ timelog } = {}) => { + wrapper = shallowMountExtended(TimelogSourceCell, { + propsData: { + timelog, + }, + }); + }; + + describe('when the timelog is associated to an issue', () => { + it('shows the issue title as link to the issue', () => { + mountComponent( + createIssuableTimelogMock('issue', { + title: 'Issue title', + webUrl: 'https://example.com/issue_url', + }), + ); + + const titleContainer = findTitleContainer(); + + expect(titleContainer.text()).toBe('Issue title'); + expect(titleContainer.attributes('href')).toBe('https://example.com/issue_url'); + }); + + it('shows the issue full reference as link to the issue', () => { + mountComponent( + createIssuableTimelogMock('issue', { + reference: '#111', + webUrl: 'https://example.com/issue_url', + }), + ); + + const referenceContainer = findReferenceContainer(); + + expect(referenceContainer.text()).toBe('group/project#111'); + expect(referenceContainer.attributes('href')).toBe('https://example.com/issue_url'); + }); + + it.each` + state | stateDescription + ${STATUS_OPEN} | ${IssuableStatusText[STATUS_OPEN]} + ${STATUS_REOPENED} | ${IssuableStatusText[STATUS_REOPENED]} + ${STATUS_LOCKED} | ${IssuableStatusText[STATUS_LOCKED]} + ${STATUS_CLOSED} | ${IssuableStatusText[STATUS_CLOSED]} + `('shows $stateDescription when the state is $state', ({ state, stateDescription }) => { + mountComponent(createIssuableTimelogMock('issue', { state })); + + expect(findStateContainer().text()).toBe(stateDescription); + }); + }); + + describe('when the timelog is associated to a merge request', () => { + it('shows the merge request title as link to the merge request', () => { + mountComponent( + createIssuableTimelogMock('mergeRequest', { + title: 'MR title', + webUrl: 'https://example.com/mr_url', + }), + ); + + const titleContainer = findTitleContainer(); + + expect(titleContainer.text()).toBe('MR title'); + expect(titleContainer.attributes('href')).toBe('https://example.com/mr_url'); + }); + + it('shows the merge request full reference as link to the merge request', () => { + mountComponent( + createIssuableTimelogMock('mergeRequest', { + reference: '!111', + webUrl: 'https://example.com/mr_url', + }), + ); + + const referenceContainer = findReferenceContainer(); + + expect(referenceContainer.text()).toBe('group/project!111'); + expect(referenceContainer.attributes('href')).toBe('https://example.com/mr_url'); + }); + it.each` + state | stateDescription + ${STATUS_OPEN} | ${IssuableStatusText[STATUS_OPEN]} + ${STATUS_CLOSED} | ${IssuableStatusText[STATUS_CLOSED]} + ${STATUS_MERGED} | ${IssuableStatusText[STATUS_MERGED]} + `('shows $stateDescription when the state is $state', ({ state, stateDescription }) => { + mountComponent(createIssuableTimelogMock('mergeRequest', { state })); + + expect(findStateContainer().text()).toBe(stateDescription); + }); + }); +}); diff --git a/spec/frontend/time_tracking/components/timelogs_app_spec.js b/spec/frontend/time_tracking/components/timelogs_app_spec.js new file mode 100644 index 00000000000..ca470ce63ac --- /dev/null +++ b/spec/frontend/time_tracking/components/timelogs_app_spec.js @@ -0,0 +1,238 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import * as Sentry from '@sentry/browser'; +import { GlDatepicker, GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui'; +import getTimelogsEmptyResponse from 'test_fixtures/graphql/get_timelogs_empty_response.json'; +import getPaginatedTimelogsResponse from 'test_fixtures/graphql/get_paginated_timelogs_response.json'; +import getNonPaginatedTimelogsResponse from 'test_fixtures/graphql/get_non_paginated_timelogs_response.json'; +import { createAlert } from '~/alert'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import getTimelogsQuery from '~/time_tracking/components/queries/get_timelogs.query.graphql'; +import TimelogsApp from '~/time_tracking/components/timelogs_app.vue'; +import TimelogsTable from '~/time_tracking/components/timelogs_table.vue'; + +jest.mock('~/alert'); +jest.mock('@sentry/browser'); + +describe('Timelogs app', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const findForm = () => wrapper.find('form'); + const findUsernameInput = () => extendedWrapper(findForm()).findByTestId('form-username'); + const findTableContainer = () => wrapper.findByTestId('table-container'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findTotalTimeSpentContainer = () => wrapper.findByTestId('total-time-spent-container'); + const findTable = () => wrapper.findComponent(TimelogsTable); + const findPagination = () => wrapper.findComponent(GlKeysetPagination); + + const findFormDatePicker = (testId) => + findForm() + .findAllComponents(GlDatepicker) + .filter((c) => c.attributes('data-testid') === testId); + const findFromDatepicker = () => findFormDatePicker('form-from-date').at(0); + const findToDatepicker = () => findFormDatePicker('form-to-date').at(0); + + const submitForm = () => findForm().trigger('submit'); + + const resolvedEmptyListMock = jest.fn().mockResolvedValue(getTimelogsEmptyResponse); + const resolvedPaginatedListMock = jest.fn().mockResolvedValue(getPaginatedTimelogsResponse); + const resolvedNonPaginatedListMock = jest.fn().mockResolvedValue(getNonPaginatedTimelogsResponse); + const rejectedMock = jest.fn().mockRejectedValue({}); + + const mountComponent = ({ props, data } = {}, queryResolverMock = resolvedEmptyListMock) => { + fakeApollo = createMockApollo([[getTimelogsQuery, queryResolverMock]]); + + wrapper = mountExtended(TimelogsApp, { + data() { + return { + ...data, + }; + }, + propsData: { + limitToHours: false, + ...props, + }, + apolloProvider: fakeApollo, + }); + }; + + beforeEach(() => { + createAlert.mockClear(); + Sentry.captureException.mockClear(); + }); + + afterEach(() => { + fakeApollo = null; + }); + + describe('the content', () => { + it('shows the form and the loading icon when loading', () => { + mountComponent(); + + expect(findForm().exists()).toBe(true); + expect(findLoadingIcon().exists()).toBe(true); + expect(findTableContainer().exists()).toBe(false); + }); + + it('shows the form and the table container when finished loading', async () => { + mountComponent(); + + await waitForPromises(); + + expect(findForm().exists()).toBe(true); + expect(findLoadingIcon().exists()).toBe(false); + expect(findTableContainer().exists()).toBe(true); + }); + }); + + describe('the filter form', () => { + it('runs the query with the correct data', async () => { + mountComponent(); + + const username = 'johnsmith'; + const fromDate = new Date('2023-02-28'); + const toDate = new Date('2023-03-28'); + + findUsernameInput().vm.$emit('input', username); + findFromDatepicker().vm.$emit('input', fromDate); + findToDatepicker().vm.$emit('input', toDate); + + resolvedEmptyListMock.mockClear(); + + submitForm(); + + await waitForPromises(); + + expect(resolvedEmptyListMock).toHaveBeenCalledWith({ + username, + startDate: fromDate, + endDate: toDate, + groupId: null, + projectId: null, + first: 20, + last: null, + after: null, + before: null, + }); + expect(createAlert).not.toHaveBeenCalled(); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('runs the query with the correct data after the date filters are cleared', async () => { + mountComponent(); + + const username = 'johnsmith'; + + findUsernameInput().vm.$emit('input', username); + findFromDatepicker().vm.$emit('clear'); + findToDatepicker().vm.$emit('clear'); + + resolvedEmptyListMock.mockClear(); + + submitForm(); + + await waitForPromises(); + + expect(resolvedEmptyListMock).toHaveBeenCalledWith({ + username, + startDate: null, + endDate: null, + groupId: null, + projectId: null, + first: 20, + last: null, + after: null, + before: null, + }); + expect(createAlert).not.toHaveBeenCalled(); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('shows an alert an logs to sentry when the mutation is rejected', async () => { + mountComponent({}, rejectedMock); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Something went wrong. Please try again.', + }); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + }); + + describe('the total time spent container', () => { + it('is not visible when there are no timelogs', async () => { + mountComponent(); + + await waitForPromises(); + + expect(findTotalTimeSpentContainer().exists()).toBe(false); + }); + + it('shows the correct value when `limitToHours` is false', async () => { + mountComponent({}, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findTotalTimeSpentContainer().exists()).toBe(true); + expect(findTotalTimeSpentContainer().text()).toBe('3d'); + }); + + it('shows the correct value when `limitToHours` is true', async () => { + mountComponent({ props: { limitToHours: true } }, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findTotalTimeSpentContainer().exists()).toBe(true); + expect(findTotalTimeSpentContainer().text()).toBe('24h'); + }); + }); + + describe('the table', () => { + it('gets created with the right props when `limitToHours` is false', async () => { + mountComponent({}, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findTable().props()).toMatchObject({ + limitToHours: false, + entries: getNonPaginatedTimelogsResponse.data.timelogs.nodes, + }); + }); + + it('gets created with the right props when `limitToHours` is true', async () => { + mountComponent({ props: { limitToHours: true } }, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findTable().props()).toMatchObject({ + limitToHours: true, + entries: getNonPaginatedTimelogsResponse.data.timelogs.nodes, + }); + }); + }); + + describe('the pagination element', () => { + it('is not visible whene there is no pagination data', async () => { + mountComponent({}, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findPagination().exists()).toBe(false); + }); + + it('is visible whene there is pagination data', async () => { + mountComponent({}, resolvedPaginatedListMock); + + await waitForPromises(); + await nextTick(); + + expect(findPagination().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/time_tracking/components/timelogs_table_spec.js b/spec/frontend/time_tracking/components/timelogs_table_spec.js new file mode 100644 index 00000000000..980fb79e8fb --- /dev/null +++ b/spec/frontend/time_tracking/components/timelogs_table_spec.js @@ -0,0 +1,223 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlTable } from '@gitlab/ui'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import TimelogsTable from '~/time_tracking/components/timelogs_table.vue'; +import TimelogSourceCell from '~/time_tracking/components/timelog_source_cell.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { STATUS_OPEN, STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; + +const baseTimelogMock = { + timeSpent: 600, + project: { + fullPath: 'group/project', + }, + user: { + name: 'John Smith', + avatarUrl: 'https://example.gitlab.com/john.jpg', + webPath: 'https://example.gitlab.com/john', + }, + spentAt: '2023-03-27T21:00:00Z', + note: null, + summary: 'Summary from timelog field', + issue: { + title: 'Issue title', + webUrl: 'https://example.gitlab.com/issue_url_a', + state: STATUS_OPEN, + reference: '#111', + }, + mergeRequest: null, +}; + +const timelogsMock = [ + baseTimelogMock, + { + timeSpent: 3600, + project: { + fullPath: 'group/project_b', + }, + user: { + name: 'Paul Reed', + avatarUrl: 'https://example.gitlab.com/paul.jpg', + webPath: 'https://example.gitlab.com/paul', + }, + spentAt: '2023-03-28T16:00:00Z', + note: { + body: 'Summary from the body', + }, + summary: null, + issue: { + title: 'Other issue title', + webUrl: 'https://example.gitlab.com/issue_url_b', + state: STATUS_CLOSED, + reference: '#112', + }, + mergeRequest: null, + }, + { + timeSpent: 27 * 60 * 60, // 27h or 3d 3h (3 days of 8 hours) + project: { + fullPath: 'group/project_b', + }, + user: { + name: 'Les Gibbons', + avatarUrl: 'https://example.gitlab.com/les.jpg', + webPath: 'https://example.gitlab.com/les', + }, + spentAt: '2023-03-28T18:00:00Z', + note: null, + summary: 'Other timelog summary', + issue: null, + mergeRequest: { + title: 'MR title', + webUrl: 'https://example.gitlab.com/mr_url', + state: STATUS_MERGED, + reference: '!99', + }, + }, +]; + +describe('TimelogsTable component', () => { + Vue.use(VueApollo); + + let wrapper; + + const findTable = () => wrapper.findComponent(GlTable); + const findTableRows = () => findTable().find('tbody').findAll('tr'); + const findRowSpentAt = (rowIndex) => + extendedWrapper(findTableRows().at(rowIndex)).findByTestId('date-container'); + const findRowSource = (rowIndex) => findTableRows().at(rowIndex).findComponent(TimelogSourceCell); + const findRowUser = (rowIndex) => findTableRows().at(rowIndex).findComponent(UserAvatarLink); + const findRowTimeSpent = (rowIndex) => + extendedWrapper(findTableRows().at(rowIndex)).findByTestId('time-spent-container'); + const findRowSummary = (rowIndex) => + extendedWrapper(findTableRows().at(rowIndex)).findByTestId('summary-container'); + + const mountComponent = (props = {}) => { + wrapper = mountExtended(TimelogsTable, { + propsData: { + entries: timelogsMock, + limitToHours: false, + ...props, + }, + stubs: { GlTable }, + }); + }; + + describe('when there are no entries', () => { + it('show the empty table message and no rows', () => { + mountComponent({ entries: [] }); + + expect(findTable().text()).toContain('There are no records to show'); + expect(findTableRows()).toHaveLength(1); + }); + }); + + describe('when there are some entries', () => { + it('does not show the empty table message and has the correct number of rows', () => { + mountComponent(); + + expect(findTable().text()).not.toContain('There are no records to show'); + expect(findTableRows()).toHaveLength(3); + }); + + describe('Spent at column', () => { + it('shows the spent at value with in the correct format', () => { + mountComponent(); + + expect(findRowSpentAt(0).text()).toBe('March 27, 2023, 21:00 (UTC: +0000)'); + }); + }); + + describe('Source column', () => { + it('creates the source cell component passing the right props', () => { + mountComponent(); + + expect(findRowSource(0).props()).toMatchObject({ + timelog: timelogsMock[0], + }); + expect(findRowSource(1).props()).toMatchObject({ + timelog: timelogsMock[1], + }); + expect(findRowSource(2).props()).toMatchObject({ + timelog: timelogsMock[2], + }); + }); + }); + + describe('User column', () => { + it('creates the user avatar component passing the right props', () => { + mountComponent(); + + expect(findRowUser(0).props()).toMatchObject({ + linkHref: timelogsMock[0].user.webPath, + imgSrc: timelogsMock[0].user.avatarUrl, + imgSize: 16, + imgAlt: timelogsMock[0].user.name, + tooltipText: timelogsMock[0].user.name, + username: timelogsMock[0].user.name, + }); + expect(findRowUser(1).props()).toMatchObject({ + linkHref: timelogsMock[1].user.webPath, + imgSrc: timelogsMock[1].user.avatarUrl, + imgSize: 16, + imgAlt: timelogsMock[1].user.name, + tooltipText: timelogsMock[1].user.name, + username: timelogsMock[1].user.name, + }); + expect(findRowUser(2).props()).toMatchObject({ + linkHref: timelogsMock[2].user.webPath, + imgSrc: timelogsMock[2].user.avatarUrl, + imgSize: 16, + imgAlt: timelogsMock[2].user.name, + tooltipText: timelogsMock[2].user.name, + username: timelogsMock[2].user.name, + }); + }); + }); + + describe('Time spent column', () => { + it('shows the time spent value with the correct format when `limitToHours` is false', () => { + mountComponent(); + + expect(findRowTimeSpent(0).text()).toBe('10m'); + expect(findRowTimeSpent(1).text()).toBe('1h'); + expect(findRowTimeSpent(2).text()).toBe('3d 3h'); + }); + + it('shows the time spent value with the correct format when `limitToHours` is true', () => { + mountComponent({ limitToHours: true }); + + expect(findRowTimeSpent(0).text()).toBe('10m'); + expect(findRowTimeSpent(1).text()).toBe('1h'); + expect(findRowTimeSpent(2).text()).toBe('27h'); + }); + }); + + describe('Summary column', () => { + it('shows the summary from the note when note body is present and not empty', () => { + mountComponent({ + entries: [{ ...baseTimelogMock, note: { body: 'Summary from note body' } }], + }); + + expect(findRowSummary(0).text()).toBe('Summary from note body'); + }); + + it('shows the summary from the timelog note body is present but empty', () => { + mountComponent({ + entries: [{ ...baseTimelogMock, note: { body: '' } }], + }); + + expect(findRowSummary(0).text()).toBe('Summary from timelog field'); + }); + + it('shows the summary from the timelog note body is not present', () => { + mountComponent({ + entries: [baseTimelogMock], + }); + + expect(findRowSummary(0).text()).toBe('Summary from timelog field'); + }); + }); + }); +}); |