diff --git a/app/assets/javascripts/repository/components/header_area/merge_request_list_item.vue b/app/assets/javascripts/repository/components/header_area/merge_request_list_item.vue new file mode 100644 index 0000000000000000000000000000000000000000..3416138d36bff23c2997061eda8aa06d4c8a5f8f --- /dev/null +++ b/app/assets/javascripts/repository/components/header_area/merge_request_list_item.vue @@ -0,0 +1,57 @@ + + + diff --git a/app/assets/javascripts/repository/components/header_area/open_mr_badge.vue b/app/assets/javascripts/repository/components/header_area/open_mr_badge.vue index bfadc6f16dbbd7867d6476e61073416f61b6e894..0929377e7e985a27a8ce0c31aa811d87ef4ee4b0 100644 --- a/app/assets/javascripts/repository/components/header_area/open_mr_badge.vue +++ b/app/assets/javascripts/repository/components/header_area/open_mr_badge.vue @@ -1,17 +1,22 @@ diff --git a/app/assets/javascripts/repository/queries/open_mr_counts.query.graphql b/app/assets/javascripts/repository/queries/open_mr_count.query.graphql similarity index 89% rename from app/assets/javascripts/repository/queries/open_mr_counts.query.graphql rename to app/assets/javascripts/repository/queries/open_mr_count.query.graphql index 407d3402f636142941594e2a1d319945b64b276b..953d07577137e3a6a4474567fb2f744fb5b303be 100644 --- a/app/assets/javascripts/repository/queries/open_mr_counts.query.graphql +++ b/app/assets/javascripts/repository/queries/open_mr_count.query.graphql @@ -1,4 +1,4 @@ -query getOpenMrCountsForBlobPath( +query getOpenMrCountForBlobPath( $projectPath: ID! $targetBranch: [String!] $blobPath: String! diff --git a/app/assets/javascripts/repository/queries/open_mrs.query.graphql b/app/assets/javascripts/repository/queries/open_mrs.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..8797bc8c959f241bee66c7f259f0e408af5b39ed --- /dev/null +++ b/app/assets/javascripts/repository/queries/open_mrs.query.graphql @@ -0,0 +1,36 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +query getOpenMrsForBlobPath( + $projectPath: ID! + $targetBranch: [String!] + $blobPath: String! + $createdAfter: Time! +) { + project(fullPath: $projectPath) { + id + mergeRequests( + state: opened + targetBranches: $targetBranch + blobPath: $blobPath + createdAfter: $createdAfter + ) { + nodes { + id + iid + title + createdAt + assignees { + nodes { + ...User + } + } + project { + id + fullPath + } + sourceBranch + } + count + } + } +} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 45c1263bd520b6a5c5a57c85b5c58023e4c28b34..a3cd5bc7c78daa3f725e4cf4a7000d26b38a252a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -785,9 +785,6 @@ msgstr "" msgid "%{count} of %{total}" msgstr "" -msgid "%{count} open" -msgstr "" - msgid "%{count} project" msgid_plural "%{count} projects" msgstr[0] "" @@ -40785,6 +40782,15 @@ msgstr "" msgid "OpenAPI Specification file path or URL" msgstr "" +msgid "OpenMrBadge|%{count} open" +msgstr "" + +msgid "OpenMrBadge|Open" +msgstr "" + +msgid "OpenMrBadge|Opened" +msgstr "" + msgid "OpenSSL version 3" msgstr "" diff --git a/spec/frontend/repository/components/header_area/merge_request_list_item_spec.js b/spec/frontend/repository/components/header_area/merge_request_list_item_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..78e0926dc6b59817d68c422353f7459c364dfd83 --- /dev/null +++ b/spec/frontend/repository/components/header_area/merge_request_list_item_spec.js @@ -0,0 +1,105 @@ +import { GlBadge, GlIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import MergeRequestListItem from '~/repository/components/header_area/merge_request_list_item.vue'; +import { getTimeago } from '~/lib/utils/datetime/timeago_utility'; + +jest.mock('~/lib/utils/datetime/timeago_utility', () => ({ + getTimeago: jest.fn(), +})); + +describe('MergeRequestListItem', () => { + let wrapper; + const mockTimeago = { + format: jest.fn().mockReturnValue('3 days ago'), + }; + + const mockAssignees = [ + { id: 'gid://gitlab/User/1', name: 'User One' }, + { id: 'gid://gitlab/User/2', name: 'User Two' }, + ]; + + const createMergeRequestMock = (overrides = {}) => ({ + id: 'gid://gitlab/MergeRequest/1', + iid: '123', + title: 'Test MR title', + createdAt: '2023-01-01T00:00:00Z', + sourceBranch: 'feature-branch', + project: { + fullPath: 'group/project', + }, + assignees: { + nodes: mockAssignees, + }, + ...overrides, + }); + + const createComponent = (props) => { + wrapper = shallowMountExtended(MergeRequestListItem, { + propsData: { + mergeRequest: createMergeRequestMock(), + ...props, + }, + }); + }; + + const findProjectInfo = () => wrapper.findByTestId('project-info'); + const findAssigneeInfo = () => wrapper.findAllByTestId('assignee-info'); + const findSourceBranchInfo = () => wrapper.findByTestId('source-branch-info'); + + beforeEach(() => { + getTimeago.mockReturnValue(mockTimeago); + createComponent(); + }); + + describe('rendering', () => { + it('renders the open badge with correct text', () => { + const badge = wrapper.findComponent(GlBadge); + expect(badge.exists()).toBe(true); + expect(badge.text()).toContain('Open'); + expect(badge.findComponent(GlIcon).props('name')).toBe('merge-request'); + }); + + it('renders the formatted creation time', () => { + expect(mockTimeago.format).toHaveBeenCalledWith('2023-01-01T00:00:00Z'); + expect(wrapper.find('time').text()).toBe('3 days ago'); + }); + + it('renders the merge request title', () => { + expect(wrapper.findByText('Test MR title').exists()).toBe(true); + }); + + it('renders the project path and MR ID', () => { + const projectInfo = findProjectInfo(); + expect(projectInfo.findComponent(GlIcon).props('name')).toBe('project'); + expect(projectInfo.text()).toContain('group/project !123'); + }); + + it('renders the source branch', () => { + const branchInfo = findSourceBranchInfo(); + expect(branchInfo.findComponent(GlIcon).props('name')).toBe('branch'); + expect(branchInfo.text()).toContain('feature-branch'); + }); + }); + + describe('assignees', () => { + it('renders all assignees', () => { + const assigneeInfos = findAssigneeInfo(); + expect(assigneeInfos.length).toBe(2); + + mockAssignees.forEach((mockUser, index) => { + const assigneeText = assigneeInfos.at(index).text().trim(); + expect(assigneeText).toContain(mockUser.name); + }); + }); + + it('handles merge requests with no assignees', () => { + const mrWithNoAssignees = createMergeRequestMock({ + assignees: { nodes: [] }, + }); + + createComponent({ mergeRequest: mrWithNoAssignees }); + + expect(findAssigneeInfo().length).toBe(0); + }); + }); +}); diff --git a/spec/frontend/repository/components/header_area/mock_data.js b/spec/frontend/repository/components/header_area/mock_data.js index 724e4130396a540a6c4bb352a69c34c9c3114145..abbe67d2e8de74ad6692de73233e1b53fc5adf3b 100644 --- a/spec/frontend/repository/components/header_area/mock_data.js +++ b/spec/frontend/repository/components/header_area/mock_data.js @@ -21,3 +21,42 @@ export const zeroOpenMRQueryResult = jest.fn().mockResolvedValue({ }, }, }); + +const mockMergeRequests = [ + { + id: '111', + iid: '123', + title: 'MR 1', + createdAt: '2020-07-07T00:00:00Z', + assignees: { nodes: [{ name: 'root' }] }, + project: { + id: '1', + fullPath: 'full/path/to/project', + }, + sourceBranch: 'main', + }, + { + id: '222', + iid: '456', + title: 'MR 2', + createdAt: '2020-07-09T00:00:00Z', + assignees: { nodes: [{ name: 'homer' }] }, + project: { + id: '1', + fullPath: 'full/path/to/project', + }, + sourceBranch: 'main', + }, +]; + +export const openMRsDetailResult = jest.fn().mockResolvedValue({ + data: { + project: { + id: '1', + mergeRequests: { + nodes: mockMergeRequests, + count: 2, + }, + }, + }, +}); diff --git a/spec/frontend/repository/components/header_area/open_mr_badge_spec.js b/spec/frontend/repository/components/header_area/open_mr_badge_spec.js index 88e8b57ceb147fd3a0c8f43c0848a5dd1dcfcb6a..f2eb713546c1531cddd8e031f8b0ff99afdd0d3d 100644 --- a/spec/frontend/repository/components/header_area/open_mr_badge_spec.js +++ b/spec/frontend/repository/components/header_area/open_mr_badge_spec.js @@ -1,15 +1,17 @@ -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlPopover, GlSkeletonLoader } from '@gitlab/ui'; import OpenMrBadge from '~/repository/components/header_area/open_mr_badge.vue'; -import getOpenMrCountsForBlobPath from '~/repository/queries/open_mr_counts.query.graphql'; +import getOpenMrCountsForBlobPath from '~/repository/queries/open_mr_count.query.graphql'; +import getOpenMrsForBlobPath from '~/repository/queries/open_mrs.query.graphql'; +import MergeRequestListItem from '~/repository/components/header_area/merge_request_list_item.vue'; import { logError } from '~/lib/logger'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import { useFakeDate } from 'helpers/fake_date'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import { openMRQueryResult, zeroOpenMRQueryResult } from './mock_data'; +import { openMRQueryResult, zeroOpenMRQueryResult, openMRsDetailResult } from './mock_data'; Vue.use(VueApollo); jest.mock('~/lib/logger'); @@ -17,16 +19,26 @@ jest.mock('~/sentry/sentry_browser_wrapper'); describe('OpenMrBadge', () => { let wrapper; - let requestHandler; + let openMrsCountQueryHandler; + let openMrsQueryHandler; const defaultProps = { projectPath: 'group/project', blobPath: 'path/to/file.js', }; - function createComponent(props = {}, mockResolver = openMRQueryResult) { - requestHandler = mockResolver; - const mockApollo = createMockApollo([[getOpenMrCountsForBlobPath, mockResolver]]); + function createComponent( + props = {}, + mockResolver = openMRQueryResult, + mrDetailResolver = openMRsDetailResult, + ) { + openMrsCountQueryHandler = mockResolver; + openMrsQueryHandler = mrDetailResolver; + + const mockApollo = createMockApollo([ + [getOpenMrCountsForBlobPath, mockResolver], + [getOpenMrsForBlobPath, mrDetailResolver], + ]); wrapper = shallowMount(OpenMrBadge, { propsData: { @@ -40,6 +52,10 @@ describe('OpenMrBadge', () => { }); } + const findPopover = () => wrapper.findComponent(GlPopover); + const findAllMergeRequestItems = () => wrapper.findAllComponents(MergeRequestListItem); + const findLoader = () => wrapper.findComponent(GlSkeletonLoader); + describe('rendering', () => { it('does not render badge when query is loading', () => { createComponent(); @@ -52,17 +68,6 @@ describe('OpenMrBadge', () => { expect(wrapper.findComponent(GlBadge).exists()).toBe(false); }); - - it('renders badge when when there are open MRs', async () => { - createComponent(); - await waitForPromises(); - - const badge = wrapper.findComponent(GlBadge); - expect(badge.exists()).toBe(true); - expect(badge.props('variant')).toBe('success'); - expect(badge.props('icon')).toBe('merge-request'); - expect(wrapper.text()).toBe('3 open'); - }); }); describe('computed properties', () => { @@ -73,7 +78,7 @@ describe('OpenMrBadge', () => { }); it('computes queryVariables correctly', () => { - expect(requestHandler).toHaveBeenCalledWith({ + expect(openMrsCountQueryHandler).toHaveBeenCalledWith({ blobPath: 'path/to/file.js', createdAfter: '2020-06-07', projectPath: 'group/project', @@ -83,17 +88,91 @@ describe('OpenMrBadge', () => { }); describe('apollo query', () => { - it('handles apollo error correctly', async () => { - const mockError = new Error(); - createComponent({}, jest.fn().mockRejectedValueOnce(mockError)); + describe('fetchOpenMrCount', () => { + it('fetch mr count and render badge correctly', async () => { + createComponent(); + await waitForPromises(); + + const badge = wrapper.findComponent(GlBadge); + expect(badge.exists()).toBe(true); + expect(badge.props('variant')).toBe('success'); + expect(badge.props('icon')).toBe('merge-request'); + expect(wrapper.text()).toBe('3 open'); + }); + + it('handles errors when fetching MR count', async () => { + const mockError = new Error(); + createComponent({}, jest.fn().mockRejectedValueOnce(mockError)); + await waitForPromises(); + + expect(wrapper.findComponent(GlBadge).exists()).toBe(false); + expect(logError).toHaveBeenCalledWith( + 'Failed to fetch merge request count. See exception details for more information.', + mockError, + ); + expect(Sentry.captureException).toHaveBeenCalledWith(mockError); + }); + }); + + describe('fetchOpenMrs', () => { + it('fetches MRs and updates data', async () => { + createComponent(); + findPopover().vm.$emit('show'); + await waitForPromises(); + + expect(openMrsQueryHandler).toHaveBeenCalledWith({ + blobPath: 'path/to/file.js', + createdAfter: '2020-06-07', + projectPath: 'group/project', + targetBranch: ['main'], + }); + + expect(findAllMergeRequestItems().length).toEqual(2); + expect(findLoader().exists()).toBe(false); + }); + + it('handles errors when fetching MRs', async () => { + const mockError = new Error('Failed to fetch MRs'); + const errorResolver = jest.fn().mockRejectedValue(mockError); + + createComponent({}, openMRQueryResult, errorResolver); + await waitForPromises(); + + findPopover().vm.$emit('show'); + await waitForPromises(); + + expect(logError).toHaveBeenCalledWith( + 'Failed to fetch merge requests. See exception details for more information.', + mockError, + ); + expect(Sentry.captureException).toHaveBeenCalledWith(mockError); + }); + }); + }); + + describe('popover functionality', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets correct props on the popover', () => { + expect(findPopover().props()).toMatchObject({ + target: 'open-mr-badge', + boundary: 'viewport', + placement: 'bottomleft', + }); + }); + + it('shows skeleton loader when loading MRs', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('calls fetchOpenMrs when popover is shown', async () => { await waitForPromises(); + findPopover().vm.$emit('show'); + await nextTick(); - expect(wrapper.findComponent(GlBadge).exists()).toBe(false); - expect(logError).toHaveBeenCalledWith( - 'Failed to fetch merge request count. See exception details for more information.', - mockError, - ); - expect(Sentry.captureException).toHaveBeenCalledWith(mockError); + expect(openMrsQueryHandler).toHaveBeenCalled(); }); }); });