From d7db5611ad5bee86717caeacf6bc96d71887564e Mon Sep 17 00:00:00 2001 From: Konstantin Greif Date: Tue, 30 Sep 2025 17:55:39 +0200 Subject: [PATCH 1/2] Refactor homepage work items and MRs --- .../homepage/components/homepage_app.vue | 188 ++++++++++- .../components/merge_requests_widget.vue | 196 ----------- .../components/user_items_count_widget.vue | 101 ++++++ .../homepage/components/work_items_widget.vue | 207 ------------ .../homepage/tracking_constants.js | 2 +- locale/gitlab.pot | 9 - .../homepage/components/homepage_app_spec.js | 315 +++++++++++++++++- .../components/merge_requests_widget_spec.js | 288 ---------------- ...ge_requests_widget_metadata_query_mocks.js | 35 +- .../work_items_widget_metadata_query_mocks.js | 4 +- .../user_items_count_widget_spec.js | 114 +++++++ .../components/work_items_widget_spec.js | 275 --------------- 12 files changed, 729 insertions(+), 1005 deletions(-) delete mode 100644 app/assets/javascripts/homepage/components/merge_requests_widget.vue create mode 100644 app/assets/javascripts/homepage/components/user_items_count_widget.vue delete mode 100644 app/assets/javascripts/homepage/components/work_items_widget.vue delete mode 100644 spec/frontend/homepage/components/merge_requests_widget_spec.js create mode 100644 spec/frontend/homepage/components/user_items_count_widget_spec.js delete mode 100644 spec/frontend/homepage/components/work_items_widget_spec.js diff --git a/app/assets/javascripts/homepage/components/homepage_app.vue b/app/assets/javascripts/homepage/components/homepage_app.vue index 1b9176a1c59153..009d66d31a77e6 100644 --- a/app/assets/javascripts/homepage/components/homepage_app.vue +++ b/app/assets/javascripts/homepage/components/homepage_app.vue @@ -1,26 +1,47 @@ @@ -65,16 +193,52 @@ export default {
-
- + + + - -
+ diff --git a/app/assets/javascripts/homepage/components/merge_requests_widget.vue b/app/assets/javascripts/homepage/components/merge_requests_widget.vue deleted file mode 100644 index d1267d63bcaf97..00000000000000 --- a/app/assets/javascripts/homepage/components/merge_requests_widget.vue +++ /dev/null @@ -1,196 +0,0 @@ - - - diff --git a/app/assets/javascripts/homepage/components/user_items_count_widget.vue b/app/assets/javascripts/homepage/components/user_items_count_widget.vue new file mode 100644 index 00000000000000..a181e17beea772 --- /dev/null +++ b/app/assets/javascripts/homepage/components/user_items_count_widget.vue @@ -0,0 +1,101 @@ + + + diff --git a/app/assets/javascripts/homepage/components/work_items_widget.vue b/app/assets/javascripts/homepage/components/work_items_widget.vue deleted file mode 100644 index 892ff20a90d86a..00000000000000 --- a/app/assets/javascripts/homepage/components/work_items_widget.vue +++ /dev/null @@ -1,207 +0,0 @@ - - - diff --git a/app/assets/javascripts/homepage/tracking_constants.js b/app/assets/javascripts/homepage/tracking_constants.js index 256a0307cf5f15..b5ba8506d5f6de 100644 --- a/app/assets/javascripts/homepage/tracking_constants.js +++ b/app/assets/javascripts/homepage/tracking_constants.js @@ -4,7 +4,7 @@ export const EVENT_USER_CLICKS_LINK_ON_ACTIVITY_FEED = 'user_clicks_link_in_acti // Labels export const TRACKING_LABEL_MERGE_REQUESTS = 'Merge requests'; -export const TRACKING_LABEL_ISSUES = 'Issues'; +export const TRACKING_LABEL_WORK_ITEMS = 'Issues'; export const TRACKING_LABEL_TODO_ITEMS = 'To-do items'; export const TRACKING_LABEL_RECENTLY_VIEWED = 'Recently viewed'; export const TRACKING_LABEL_FEEDBACK = 'Feedback'; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bca5122b82574e..a6a0c6e4a1a392 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -33186,9 +33186,6 @@ msgstr "" msgid "HomePageMergeRequestsWidget|Merge requests waiting for your review" msgstr "" -msgid "HomePageMergeRequestsWidget|Merge requests with review requested" -msgstr "" - msgid "HomePageMergeRequestsWidget|The number of merge requests is not available. Please refresh the page to try again, or visit the dashboard." msgstr "" @@ -33207,12 +33204,6 @@ msgstr "" msgid "HomePageWorkItemsWidget|The number of issues is not available. Please refresh the page to try again, or visit the issue list." msgstr "" -msgid "HomePageWorkItemsWidget|Work items assigned to you" -msgstr "" - -msgid "HomePageWorkItemsWidget|Work items authored by you" -msgstr "" - msgid "Homepage" msgstr "" diff --git a/spec/frontend/homepage/components/homepage_app_spec.js b/spec/frontend/homepage/components/homepage_app_spec.js index 5df38b7a450036..4d225c3786a371 100644 --- a/spec/frontend/homepage/components/homepage_app_spec.js +++ b/spec/frontend/homepage/components/homepage_app_spec.js @@ -1,27 +1,74 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper'; import HomepageApp from '~/homepage/components/homepage_app.vue'; -import MergeRequestsWidget from '~/homepage/components/merge_requests_widget.vue'; -import WorkItemsWidget from '~/homepage/components/work_items_widget.vue'; import PickUpWidget from '~/homepage/components/pick_up_widget.vue'; import FeedbackWidget from '~/homepage/components/feedback_widget.vue'; +import BaseWidget from '~/homepage/components/base_widget.vue'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import mergeRequestsWidgetMetadataQuery from '~/homepage/graphql/queries/merge_requests_widget_metadata.query.graphql'; +import workItemsWidgetMetadataQuery from '~/homepage/graphql/queries/work_items_widget_metadata.query.graphql'; +import { + EVENT_USER_FOLLOWS_LINK_ON_HOMEPAGE, + TRACKING_LABEL_MERGE_REQUESTS, + TRACKING_LABEL_WORK_ITEMS, + TRACKING_PROPERTY_REVIEW_REQUESTED, + TRACKING_PROPERTY_ASSIGNED_TO_YOU, + TRACKING_PROPERTY_AUTHORED_BY_YOU, +} from '~/homepage/tracking_constants'; +import { userCounts } from '~/super_sidebar/user_counts_manager'; import { lastPushEvent } from './mocks/last_push_event_mock'; +import { mergeRequestsDataWithItems } from './mocks/merge_requests_widget_metadata_query_mocks'; +import { workItemsDataWithItems } from './mocks/work_items_widget_metadata_query_mocks'; + +jest.mock('~/super_sidebar/user_counts_manager', () => ({ + userCounts: { assigned_issues: 0 }, + createUserCountsManager: jest.fn(), + useCachedUserCounts: jest.fn(), +})); +jest.mock('~/sentry/sentry_browser_wrapper'); +jest.mock('~/super_sidebar/user_counts_fetch'); describe('HomepageApp', () => { const MOCK_MERGE_REQUESTS_REVIEW_REQUESTED_PATH = '/merge/requests/review/requested/path'; + const MOCK_MERGE_REQUESTS_REVIEW_REQUESTED_ERROR_TEXT = + 'The number of merge requests is not available. Please refresh the page to try again, or visit the dashboard.'; + const MOCK_MERGE_REQUESTS_REVIEW_REQUESTED_TEXT = 'Merge requests waiting for your review'; const MOCK_ASSIGNED_MERGE_REQUESTS_PATH = '/merge/requests/assigned/to/you/path'; + const MOCK_ASSIGNED_MERGE_REQUESTS_ERROR_TEXT = + 'The number of merge requests is not available. Please refresh the page to try again, or visit the dashboard.'; + const MOCK_ASSIGNED_MERGE_REQUESTS_TEXT = 'Merge requests assigned to you'; const MOCK_ASSIGNED_WORK_ITEMS_PATH = '/work/items/assigned/to/you/path'; + const MOCK_ASSIGNED_WORK_ITEMS_ERROR_TEXT = + 'The number of issues is not available. Please refresh the page to try again, or visit the issue list.'; + const MOCK_ASSIGNED_WORK_ITEMS_TEXT = 'Issues assigned to you'; const MOCK_AUTHORED_WORK_ITEMS_PATH = '/work/items/authored/to/you/path'; + const MOCK_AUTHORED_WORK_ITEMS_ERROR_TEXT = + 'The number of issues is not available. Please refresh the page to try again, or visit the issue list.'; + const MOCK_AUTHORED_WORK_ITEMS_TEXT = 'Issues authored by you'; const MOCK_ACTIVITY_PATH = '/activity/path'; + const MOCK_DUO_CODE_REVIEW_BOT_USERNAME = 'GitLabDuo'; let wrapper; - const findMergeRequestsWidget = () => wrapper.findComponent(MergeRequestsWidget); - const findWorkItemsWidget = () => wrapper.findComponent(WorkItemsWidget); + const findReviewRequestedWidget = () => wrapper.findByTestId('review-requested-widget'); + const findAssignedMergeRequestsWidget = () => + wrapper.findByTestId('assigned-merge-requests-widget'); + const findAssignedWorkItemsWidget = () => wrapper.findByTestId('assigned-work-items-widget'); + const findAuthoredWorkItemsWidget = () => wrapper.findByTestId('authored-work-items-widget'); + const findBaseWidget = () => wrapper.findComponent(BaseWidget); const findPickUpWidget = () => wrapper.findComponent(PickUpWidget); const findFeedbackWidget = () => wrapper.findComponent(FeedbackWidget); function createWrapper(props = {}) { wrapper = shallowMountExtended(HomepageApp, { + provide: { + duoCodeReviewBotUsername: MOCK_DUO_CODE_REVIEW_BOT_USERNAME, + }, propsData: { reviewRequestedPath: MOCK_MERGE_REQUESTS_REVIEW_REQUESTED_PATH, assignedMergeRequestsPath: MOCK_ASSIGNED_MERGE_REQUESTS_PATH, @@ -34,21 +81,263 @@ describe('HomepageApp', () => { }); } + const mergeRequestsWidgetMetadataQuerySuccessHandler = (data) => + jest.fn().mockResolvedValue(data); + + const workItemsWidgetMetadataQuerySuccessHandler = (data) => jest.fn().mockResolvedValue(data); + + function createApolloWrapper({ + mergeRequestsWidgetMetadataQueryHandler = mergeRequestsWidgetMetadataQuerySuccessHandler( + mergeRequestsDataWithItems, + ), + workItemsWidgetMetadataQueryHandler = workItemsWidgetMetadataQuerySuccessHandler( + workItemsDataWithItems, + ), + assignedWorkItemsCount = 5, + } = {}) { + userCounts.assigned_issues = assignedWorkItemsCount; + + const mockApollo = createMockApollo([ + [mergeRequestsWidgetMetadataQuery, mergeRequestsWidgetMetadataQueryHandler], + [workItemsWidgetMetadataQuery, workItemsWidgetMetadataQueryHandler], + ]); + wrapper = shallowMountExtended(HomepageApp, { + apolloProvider: mockApollo, + provide: { + duoCodeReviewBotUsername: MOCK_DUO_CODE_REVIEW_BOT_USERNAME, + }, + propsData: { + reviewRequestedPath: MOCK_MERGE_REQUESTS_REVIEW_REQUESTED_PATH, + assignedMergeRequestsPath: MOCK_ASSIGNED_MERGE_REQUESTS_PATH, + assignedWorkItemsPath: MOCK_ASSIGNED_WORK_ITEMS_PATH, + authoredWorkItemsPath: MOCK_AUTHORED_WORK_ITEMS_PATH, + activityPath: MOCK_ACTIVITY_PATH, + lastPushEvent, + }, + stubs: { + GlSprintf, + BaseWidget, + }, + }); + } + beforeEach(() => { createWrapper(); }); - it('passes the correct props to the `MergeRequestsWidget` component', () => { - expect(findMergeRequestsWidget().props()).toEqual({ - reviewRequestedPath: MOCK_MERGE_REQUESTS_REVIEW_REQUESTED_PATH, - assignedToYouPath: MOCK_ASSIGNED_MERGE_REQUESTS_PATH, - }); + afterEach(() => { + userCounts.assigned_issues = 0; }); - it('passes the correct props to the `WorkItemsWidget` component', () => { - expect(findWorkItemsWidget().props()).toEqual({ - assignedToYouPath: MOCK_ASSIGNED_WORK_ITEMS_PATH, - authoredByYouPath: MOCK_AUTHORED_WORK_ITEMS_PATH, + describe('userItemsCountWidgets', () => { + Vue.use(VueApollo); + + beforeEach(() => { + createApolloWrapper(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('passes the correct props to the `review requested widget`', async () => { + await waitForPromises(); + + expect(findReviewRequestedWidget().props()).toEqual({ + errorText: MOCK_MERGE_REQUESTS_REVIEW_REQUESTED_ERROR_TEXT, + hasError: false, + linkText: MOCK_MERGE_REQUESTS_REVIEW_REQUESTED_TEXT, + path: MOCK_MERGE_REQUESTS_REVIEW_REQUESTED_PATH, + userItems: mergeRequestsDataWithItems.data.currentUser.reviewRequestedMergeRequests, + iconName: 'merge-request', + }); + }); + + it('passes the correct props to the `assigned merge requests widget`', async () => { + await waitForPromises(); + + expect(findAssignedMergeRequestsWidget().props()).toEqual({ + errorText: MOCK_ASSIGNED_MERGE_REQUESTS_ERROR_TEXT, + hasError: false, + linkText: MOCK_ASSIGNED_MERGE_REQUESTS_TEXT, + path: MOCK_ASSIGNED_MERGE_REQUESTS_PATH, + userItems: mergeRequestsDataWithItems.data.currentUser.assignedMergeRequests, + iconName: 'merge-request', + }); + }); + + it('passes the correct props to the `assigned work items widget`', async () => { + await waitForPromises(); + + expect(findAssignedWorkItemsWidget().props()).toEqual({ + errorText: MOCK_ASSIGNED_WORK_ITEMS_ERROR_TEXT, + hasError: false, + linkText: MOCK_ASSIGNED_WORK_ITEMS_TEXT, + path: MOCK_ASSIGNED_WORK_ITEMS_PATH, + userItems: workItemsDataWithItems.data.currentUser.assigned, + iconName: 'work-item-issue', + }); + }); + + it('passes the correct props to the `authored work items widget`', async () => { + await waitForPromises(); + + expect(findAuthoredWorkItemsWidget().props()).toEqual({ + errorText: MOCK_AUTHORED_WORK_ITEMS_ERROR_TEXT, + hasError: false, + linkText: MOCK_AUTHORED_WORK_ITEMS_TEXT, + path: MOCK_AUTHORED_WORK_ITEMS_PATH, + userItems: workItemsDataWithItems.data.currentUser.authored, + iconName: 'work-item-issue', + }); + }); + + describe('query errors', () => { + it('provides error to both merge request widgets, if the query errors out', async () => { + createApolloWrapper({ + mergeRequestsWidgetMetadataQueryHandler: () => jest.fn().mockRejectedValue(), + }); + + await waitForPromises(); + + expect(findReviewRequestedWidget().props()).toEqual( + expect.objectContaining({ + hasError: true, + userItems: null, + }), + ); + expect(findAssignedMergeRequestsWidget().props()).toEqual( + expect.objectContaining({ + hasError: true, + userItems: null, + }), + ); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + + it('provides error to both work item widgets, if the query errors out', async () => { + createApolloWrapper({ + workItemsWidgetMetadataQueryHandler: () => jest.fn().mockRejectedValue(), + }); + + await waitForPromises(); + + expect(findAssignedWorkItemsWidget().props()).toEqual( + expect.objectContaining({ + hasError: true, + userItems: null, + }), + ); + expect(findAuthoredWorkItemsWidget().props()).toEqual( + expect.objectContaining({ + hasError: true, + userItems: null, + }), + ); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + }); + + describe('tracking', () => { + const { bindInternalEventDocument } = useMockInternalEventsTracking(); + + beforeEach(async () => { + createWrapper(); + await waitForPromises(); + }); + + it('tracks emit of review requested widget', () => { + const { trackEventSpy } = bindInternalEventDocument(wrapper.element); + + findReviewRequestedWidget().vm.$emit('click-link'); + + expect(trackEventSpy).toHaveBeenCalledWith( + EVENT_USER_FOLLOWS_LINK_ON_HOMEPAGE, + { + label: TRACKING_LABEL_MERGE_REQUESTS, + property: TRACKING_PROPERTY_REVIEW_REQUESTED, + }, + undefined, + ); + }); + + it('tracks emit of assigned merge requests widget', () => { + const { trackEventSpy } = bindInternalEventDocument(wrapper.element); + + findAssignedMergeRequestsWidget().vm.$emit('click-link'); + + expect(trackEventSpy).toHaveBeenCalledWith( + EVENT_USER_FOLLOWS_LINK_ON_HOMEPAGE, + { + label: TRACKING_LABEL_MERGE_REQUESTS, + property: TRACKING_PROPERTY_ASSIGNED_TO_YOU, + }, + undefined, + ); + }); + + it('tracks emit of assigned work items widget', () => { + const { trackEventSpy } = bindInternalEventDocument(wrapper.element); + + findAssignedWorkItemsWidget().vm.$emit('click-link'); + + expect(trackEventSpy).toHaveBeenCalledWith( + EVENT_USER_FOLLOWS_LINK_ON_HOMEPAGE, + { + label: TRACKING_LABEL_WORK_ITEMS, + property: TRACKING_PROPERTY_ASSIGNED_TO_YOU, + }, + undefined, + ); + }); + + it('tracks emit of authored work items widget', () => { + const { trackEventSpy } = bindInternalEventDocument(wrapper.element); + + findAuthoredWorkItemsWidget().vm.$emit('click-link'); + + expect(trackEventSpy).toHaveBeenCalledWith( + EVENT_USER_FOLLOWS_LINK_ON_HOMEPAGE, + { + label: TRACKING_LABEL_WORK_ITEMS, + property: TRACKING_PROPERTY_AUTHORED_BY_YOU, + }, + undefined, + ); + }); + }); + + describe('BaseWidget integration', () => { + it('renders BaseWidget without styling', () => { + const baseWidget = findBaseWidget(); + + expect(baseWidget.exists()).toBe(true); + expect(baseWidget.props('applyDefaultStyling')).toBe(false); + }); + + it('handles visible event from BaseWidget', async () => { + const mergeRequestsHandler = jest.fn().mockResolvedValue(mergeRequestsDataWithItems); + const workItemsHandler = jest.fn().mockResolvedValue(workItemsDataWithItems); + + createApolloWrapper({ + mergeRequestsWidgetMetadataQueryHandler: mergeRequestsHandler, + workItemsWidgetMetadataQueryHandler: workItemsHandler, + }); + + await waitForPromises(); + + // queried on initial mount + expect(mergeRequestsHandler).toHaveBeenCalledTimes(1); + expect(workItemsHandler).toHaveBeenCalledTimes(1); + + const baseWidget = findBaseWidget(); + baseWidget.vm.$emit('visible'); + + await waitForPromises(); + + // queried after visibility change + expect(mergeRequestsHandler).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/spec/frontend/homepage/components/merge_requests_widget_spec.js b/spec/frontend/homepage/components/merge_requests_widget_spec.js deleted file mode 100644 index 0d3839a68d9b11..00000000000000 --- a/spec/frontend/homepage/components/merge_requests_widget_spec.js +++ /dev/null @@ -1,288 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { GlSprintf, GlLink } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { useFakeDate } from 'helpers/fake_date'; -import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper'; -import MergeRequestsWidget from '~/homepage/components/merge_requests_widget.vue'; -import BaseWidget from '~/homepage/components/base_widget.vue'; -import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import mergeRequestsWidgetMetadataQuery from '~/homepage/graphql/queries/merge_requests_widget_metadata.query.graphql'; -import { - EVENT_USER_FOLLOWS_LINK_ON_HOMEPAGE, - TRACKING_LABEL_MERGE_REQUESTS, - TRACKING_PROPERTY_REVIEW_REQUESTED, - TRACKING_PROPERTY_ASSIGNED_TO_YOU, -} from '~/homepage/tracking_constants'; -import { withItems, withoutItems } from './mocks/merge_requests_widget_metadata_query_mocks'; - -jest.mock('~/sentry/sentry_browser_wrapper'); - -describe('MergeRequestsWidget', () => { - Vue.use(VueApollo); - - const MOCK_DUO_CODE_REVIEW_BOT_USERNAME = 'GitLabDuo'; - const MOCK_REVIEW_REQUESTED_PATH = '/review/requested/path'; - const MOCK_ASSIGNED_TO_YOU_PATH = '/assigned/to/you/path'; - const MOCK_CURRENT_TIME = new Date('2025-06-12T18:13:25Z'); - - useFakeDate(MOCK_CURRENT_TIME); - - const mergeRequestsWidgetMetadataQuerySuccessHandler = (data) => - jest.fn().mockResolvedValue(data); - - let wrapper; - - const findBaseWidget = () => wrapper.findComponent(BaseWidget); - const findReviewRequestedCard = () => wrapper.findAllComponents(GlLink).at(0); - const findAssignedToYouCard = () => wrapper.findAllComponents(GlLink).at(1); - const findReviewRequestedCount = () => wrapper.findByTestId('review-requested-count'); - const findReviewRequestedLastUpdatedAt = () => - wrapper.findByTestId('review-requested-last-updated-at'); - const findAssignedCount = () => wrapper.findByTestId('assigned-count'); - const findAssignedLastUpdatedAt = () => wrapper.findByTestId('assigned-last-updated-at'); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - function createWrapper({ - mergeRequestsWidgetMetadataQueryHandler = mergeRequestsWidgetMetadataQuerySuccessHandler( - withItems, - ), - } = {}) { - const mockApollo = createMockApollo([ - [mergeRequestsWidgetMetadataQuery, mergeRequestsWidgetMetadataQueryHandler], - ]); - wrapper = shallowMountExtended(MergeRequestsWidget, { - apolloProvider: mockApollo, - provide: { - duoCodeReviewBotUsername: MOCK_DUO_CODE_REVIEW_BOT_USERNAME, - }, - propsData: { - reviewRequestedPath: MOCK_REVIEW_REQUESTED_PATH, - assignedToYouPath: MOCK_ASSIGNED_TO_YOU_PATH, - }, - stubs: { - GlSprintf, - BaseWidget, - }, - }); - } - - describe('cards', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders the "Review requested" card', () => { - const card = findReviewRequestedCard(); - - expect(card.exists()).toBe(true); - expect(card.text()).toMatch('Merge requests waiting for your review'); - }); - - it('renders the "Assigned to you" card', () => { - const card = findAssignedToYouCard(); - - expect(card.exists()).toBe(true); - expect(card.text()).toMatch('Merge requests assigned to you'); - }); - }); - - describe('metadata', () => { - it("shows the counts' loading state and no timestamp until the query has resolved", () => { - createWrapper(); - - expect(findReviewRequestedLastUpdatedAt().exists()).toBe(false); - expect(findAssignedLastUpdatedAt().exists()).toBe(false); - - expect(findReviewRequestedCount().text()).toBe('-'); - expect(findAssignedCount().text()).toBe('-'); - }); - - it('shows the metadata once the query has resolved', async () => { - createWrapper(); - await waitForPromises(); - - expect(findReviewRequestedCount().text()).toBe('12'); - expect(findReviewRequestedLastUpdatedAt().text()).toBe('3 hours ago'); - expect(findAssignedCount().text()).toBe('4'); - expect(findAssignedLastUpdatedAt().text()).toBe('2 days ago'); - }); - - it('shows partial metadata when the user has no relevant items', async () => { - createWrapper({ - mergeRequestsWidgetMetadataQueryHandler: - mergeRequestsWidgetMetadataQuerySuccessHandler(withoutItems), - }); - await waitForPromises(); - - expect(findReviewRequestedLastUpdatedAt().exists()).toBe(false); - expect(findAssignedLastUpdatedAt().exists()).toBe(false); - - expect(findReviewRequestedCount().text()).toBe('0'); - expect(findAssignedCount().text()).toBe('0'); - }); - - it('shows error messages in both cards if the query errors out', async () => { - createWrapper({ - mergeRequestsWidgetMetadataQueryHandler: () => jest.fn().mockRejectedValue(), - }); - await waitForPromises(); - - expect(findReviewRequestedCard().text()).toContain( - 'The number of merge requests is not available. Please refresh the page to try again, or visit the dashboard.', - ); - expect(findAssignedToYouCard().text()).toContain( - 'The number of merge requests is not available. Please refresh the page to try again, or visit the dashboard.', - ); - expect(Sentry.captureException).toHaveBeenCalled(); - - expect(findReviewRequestedCard().text()).not.toMatch( - 'Merge requests waiting for your review', - ); - expect(findAssignedToYouCard().text()).not.toMatch('Merge requests assigned to you'); - }); - - it('shows error icons in both cards when in error state', async () => { - createWrapper({ - mergeRequestsWidgetMetadataQueryHandler: () => jest.fn().mockRejectedValue(), - }); - await waitForPromises(); - - const allIcons = wrapper.findAllComponents({ name: 'GlIcon' }); - - let errorIconCount = 0; - for (let i = 0; i < allIcons.length; i += 1) { - const icon = allIcons.at(i); - if (icon.props('name') === 'error') { - expect(icon.props('size')).toBe(16); - expect(icon.classes('gl-text-red-500')).toBe(true); - errorIconCount += 1; - } - } - - expect(errorIconCount).toBe(2); - }); - }); - - describe('BaseWidget integration', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders BaseWidget without styling', () => { - const baseWidget = findBaseWidget(); - - expect(baseWidget.exists()).toBe(true); - expect(baseWidget.props('applyDefaultStyling')).toBe(false); - }); - - it('handles visible event from BaseWidget', async () => { - const mergeRequestsWidgetMetadataQueryHandler = - mergeRequestsWidgetMetadataQuerySuccessHandler(withItems); - - createWrapper({ mergeRequestsWidgetMetadataQueryHandler }); - await waitForPromises(); - - // queried on initial mount - expect(mergeRequestsWidgetMetadataQueryHandler).toHaveBeenCalledTimes(1); - - const baseWidget = findBaseWidget(); - baseWidget.vm.$emit('visible'); - - await waitForPromises(); - - // queried after visibility change - expect(mergeRequestsWidgetMetadataQueryHandler).toHaveBeenCalledTimes(2); - }); - }); - - describe('number formatting', () => { - it('formats large counts using formatCount', async () => { - const mockData = { - data: { - currentUser: { - id: 'gid://gitlab/User/1', - reviewRequestedMergeRequests: { - count: 25000, - nodes: [ - { - id: 'gid://gitlab/MergeRequest/1', - updatedAt: '2025-06-11T18:13:25Z', - __typename: 'MergeRequest', - }, - ], - __typename: 'MergeRequestConnection', - }, - assignedMergeRequests: { - count: 750000, - nodes: [ - { - id: 'gid://gitlab/MergeRequest/2', - updatedAt: '2025-06-10T18:13:25Z', - __typename: 'MergeRequest', - }, - ], - __typename: 'MergeRequestConnection', - }, - __typename: 'CurrentUser', - }, - }, - }; - - createWrapper({ - mergeRequestsWidgetMetadataQueryHandler: - mergeRequestsWidgetMetadataQuerySuccessHandler(mockData), - }); - await waitForPromises(); - - expect(findReviewRequestedCount().text()).toBe('25K'); - expect(findAssignedCount().text()).toBe('750K'); - }); - }); - - describe('tracking', () => { - const { bindInternalEventDocument } = useMockInternalEventsTracking(); - - beforeEach(async () => { - createWrapper(); - await waitForPromises(); - }); - - it('tracks click on "Review requested" card', () => { - const { trackEventSpy } = bindInternalEventDocument(wrapper.element); - const reviewRequestedCard = findReviewRequestedCard(); - - reviewRequestedCard.vm.$emit('click'); - - expect(trackEventSpy).toHaveBeenCalledWith( - EVENT_USER_FOLLOWS_LINK_ON_HOMEPAGE, - { - label: TRACKING_LABEL_MERGE_REQUESTS, - property: TRACKING_PROPERTY_REVIEW_REQUESTED, - }, - undefined, - ); - }); - - it('tracks click on "Assigned to you" card', () => { - const { trackEventSpy } = bindInternalEventDocument(wrapper.element); - const assignedCard = findAssignedToYouCard(); - - assignedCard.vm.$emit('click'); - - expect(trackEventSpy).toHaveBeenCalledWith( - EVENT_USER_FOLLOWS_LINK_ON_HOMEPAGE, - { - label: TRACKING_LABEL_MERGE_REQUESTS, - property: TRACKING_PROPERTY_ASSIGNED_TO_YOU, - }, - undefined, - ); - }); - }); -}); diff --git a/spec/frontend/homepage/components/mocks/merge_requests_widget_metadata_query_mocks.js b/spec/frontend/homepage/components/mocks/merge_requests_widget_metadata_query_mocks.js index b94ec8bcc59a50..da716076b67649 100644 --- a/spec/frontend/homepage/components/mocks/merge_requests_widget_metadata_query_mocks.js +++ b/spec/frontend/homepage/components/mocks/merge_requests_widget_metadata_query_mocks.js @@ -1,4 +1,4 @@ -export const withItems = { +export const mergeRequestsDataWithItems = { data: { currentUser: { id: 'gid://gitlab/User/1', @@ -29,7 +29,7 @@ export const withItems = { }, }; -export const withoutItems = { +export const mergeRequestsDataWithoutItems = { data: { currentUser: { id: 'gid://gitlab/User/1', @@ -47,3 +47,34 @@ export const withoutItems = { }, }, }; + +export const mergeRequestsDataWithHugeCount = { + data: { + currentUser: { + id: 'gid://gitlab/User/1', + reviewRequestedMergeRequests: { + count: 750000, + nodes: [ + { + __typename: 'MergeRequest', + id: 'gid://gitlab/MergeRequest/30', + updatedAt: '2025-06-12T15:13:25Z', + }, + ], + __typename: 'MergeRequestConnection', + }, + assignedMergeRequests: { + count: 25000, + nodes: [ + { + __typename: 'MergeRequest', + id: 'gid://gitlab/MergeRequest/30', + updatedAt: '2025-06-12T15:13:25Z', + }, + ], + __typename: 'MergeRequestConnection', + }, + __typename: 'CurrentUser', + }, + }, +}; diff --git a/spec/frontend/homepage/components/mocks/work_items_widget_metadata_query_mocks.js b/spec/frontend/homepage/components/mocks/work_items_widget_metadata_query_mocks.js index 69b49171fc28ad..18cab5501cdfbb 100644 --- a/spec/frontend/homepage/components/mocks/work_items_widget_metadata_query_mocks.js +++ b/spec/frontend/homepage/components/mocks/work_items_widget_metadata_query_mocks.js @@ -1,4 +1,4 @@ -export const withItems = { +export const workItemsDataWithItems = { data: { currentUser: { id: 'gid://gitlab/User/1', @@ -29,7 +29,7 @@ export const withItems = { }, }; -export const withoutItems = { +export const workItemsDataWithoutItems = { data: { currentUser: { id: 'gid://gitlab/User/1', diff --git a/spec/frontend/homepage/components/user_items_count_widget_spec.js b/spec/frontend/homepage/components/user_items_count_widget_spec.js new file mode 100644 index 00000000000000..57dd3fe32d6202 --- /dev/null +++ b/spec/frontend/homepage/components/user_items_count_widget_spec.js @@ -0,0 +1,114 @@ +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { useFakeDate } from 'helpers/fake_date'; +import BaseWidget from '~/homepage/components/base_widget.vue'; +import UserItemsCountWidget from '~/homepage/components/user_items_count_widget.vue'; +import { + mergeRequestsDataWithItems, + mergeRequestsDataWithoutItems, + mergeRequestsDataWithHugeCount, +} from './mocks/merge_requests_widget_metadata_query_mocks'; + +describe('UserItemsCountWidget', () => { + const MOCK_DUO_CODE_REVIEW_BOT_USERNAME = 'GitLabDuo'; + const MOCK_PATH = '/merge/requests/review/requested/path'; + const MOCK_ARIA_LABEL = 'Merge requests with review requested'; + const MOCK_ERROR_TEXT = + 'The number of merge requests is not available. Please refresh the page to try again, or visit the dashboard.'; + const MOCK_LINK_TEXT = 'Merge requests waiting for your review'; + const MOCK_CURRENT_TIME = new Date('2025-06-12T18:13:25Z'); + + useFakeDate(MOCK_CURRENT_TIME); + + let wrapper; + + const findLink = () => wrapper.findComponent(GlLink); + const findCount = () => wrapper.findByTestId('count'); + const findLastUpdatedAt = () => wrapper.findByTestId('last-updated-at'); + + function createWrapper(props = {}) { + wrapper = shallowMountExtended(UserItemsCountWidget, { + provide: { + duoCodeReviewBotUsername: MOCK_DUO_CODE_REVIEW_BOT_USERNAME, + }, + propsData: { + hasError: false, + path: MOCK_PATH, + linkAriaLabel: MOCK_ARIA_LABEL, + errorText: MOCK_ERROR_TEXT, + linkText: MOCK_LINK_TEXT, + userItems: mergeRequestsDataWithItems.data.currentUser.reviewRequestedMergeRequests, + ...props, + }, + stubs: { + GlSprintf, + BaseWidget, + }, + }); + } + + describe('with valid data', () => { + beforeEach(() => { + createWrapper(); + }); + + it('shows the metadata', () => { + expect(findCount().text()).toBe('12'); + expect(findLastUpdatedAt().text()).toBe('3 hours ago'); + }); + + it('formats large counts using formatCount', async () => { + createWrapper({ + userItems: mergeRequestsDataWithHugeCount.data.currentUser.reviewRequestedMergeRequests, + }); + await waitForPromises(); + + expect(findCount().text()).toBe('750K'); + }); + + it('tracks click on card', () => { + findLink().vm.$emit('click'); + + expect(wrapper.emitted('click-link')).toHaveLength(1); + }); + }); + + describe('with missing data', () => { + it("shows the counts' loading state and no timestamp when workitems is null", () => { + createWrapper({ userItems: null }); + + expect(findLastUpdatedAt().exists()).toBe(false); + + expect(findCount().text()).toBe('-'); + }); + + it('shows partial metadata when the user has no relevant items', () => { + createWrapper({ + userItems: mergeRequestsDataWithoutItems.data.currentUser.reviewRequestedMergeRequests, + }); + + expect(findLastUpdatedAt().exists()).toBe(false); + + expect(findCount().text()).toBe('0'); + }); + }); + + describe('with error', () => { + it('shows error message', () => { + createWrapper({ hasError: true }); + + expect(findLink().text()).toContain(MOCK_ERROR_TEXT); + + expect(findLink().text()).not.toMatch(MOCK_LINK_TEXT); + }); + + it('shows error icon', () => { + createWrapper({ hasError: true }); + + const errorIcon = wrapper.findComponent({ name: 'GlIcon' }); + + expect(errorIcon.exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/homepage/components/work_items_widget_spec.js b/spec/frontend/homepage/components/work_items_widget_spec.js deleted file mode 100644 index a8990c0faa1a61..00000000000000 --- a/spec/frontend/homepage/components/work_items_widget_spec.js +++ /dev/null @@ -1,275 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { useFakeDate } from 'helpers/fake_date'; -import WorkItemsWidget from '~/homepage/components/work_items_widget.vue'; -import BaseWidget from '~/homepage/components/base_widget.vue'; -import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import workItemsWidgetMetadataQuery from '~/homepage/graphql/queries/work_items_widget_metadata.query.graphql'; -import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper'; -import { - EVENT_USER_FOLLOWS_LINK_ON_HOMEPAGE, - TRACKING_LABEL_ISSUES, - TRACKING_PROPERTY_ASSIGNED_TO_YOU, - TRACKING_PROPERTY_AUTHORED_BY_YOU, -} from '~/homepage/tracking_constants'; -import { userCounts } from '~/super_sidebar/user_counts_manager'; -import { withItems, withoutItems } from './mocks/work_items_widget_metadata_query_mocks'; - -jest.mock('~/super_sidebar/user_counts_manager', () => ({ - userCounts: { assigned_issues: 0 }, - createUserCountsManager: jest.fn(), - useCachedUserCounts: jest.fn(), -})); -jest.mock('~/sentry/sentry_browser_wrapper'); -jest.mock('~/super_sidebar/user_counts_fetch'); - -describe('WorkItemsWidget', () => { - Vue.use(VueApollo); - - const MOCK_ASSIGNED_TO_YOU_PATH = '/assigned/to/you/path'; - const MOCK_AUTHORED_BY_YOU_PATH = '/authored/to/you/path'; - const MOCK_CURRENT_TIME = new Date('2025-06-29T18:13:25Z'); - - useFakeDate(MOCK_CURRENT_TIME); - - const workItemsWidgetMetadataQuerySuccessHandler = (data) => jest.fn().mockResolvedValue(data); - - let wrapper; - - const findBaseWidget = () => wrapper.findComponent(BaseWidget); - const findAssignedCard = () => wrapper.findAllComponents(GlLink).at(0); - const findAuthoredCard = () => wrapper.findAllComponents(GlLink).at(1); - const findAssignedCount = () => wrapper.findByTestId('assigned-count'); - const findAssignedLastUpdatedAt = () => wrapper.findByTestId('assigned-last-updated-at'); - const findAuthoredCount = () => wrapper.findByTestId('authored-count'); - const findAuthoredLastUpdatedAt = () => wrapper.findByTestId('authored-last-updated-at'); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - function createWrapper({ - workItemsWidgetMetadataQueryHandler = workItemsWidgetMetadataQuerySuccessHandler(withItems), - assignedIssuesCount = 0, - } = {}) { - userCounts.assigned_issues = assignedIssuesCount; - - const mockApollo = createMockApollo([ - [workItemsWidgetMetadataQuery, workItemsWidgetMetadataQueryHandler], - ]); - wrapper = shallowMountExtended(WorkItemsWidget, { - apolloProvider: mockApollo, - propsData: { - assignedToYouPath: MOCK_ASSIGNED_TO_YOU_PATH, - authoredByYouPath: MOCK_AUTHORED_BY_YOU_PATH, - }, - stubs: { - GlSprintf, - BaseWidget, - }, - }); - } - - afterEach(() => { - userCounts.assigned_issues = 0; - }); - - describe('cards', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders the "Issues assigned to you" card', () => { - const card = findAssignedCard(); - - expect(card.exists()).toBe(true); - expect(card.text()).toMatch('Issues assigned to you'); - }); - - it('renders the "Issues authored by you" card', () => { - const card = findAuthoredCard(); - - expect(card.exists()).toBe(true); - expect(card.text()).toMatch('Issues authored by you'); - }); - }); - - describe('metadata', () => { - it('shows the metadata once the query has resolved', async () => { - createWrapper(); - await waitForPromises(); - - expect(findAssignedLastUpdatedAt().text()).toBe('1 day ago'); - expect(findAuthoredLastUpdatedAt().text()).toBe('4 days ago'); - }); - - it('shows partial metadata when the user has no relevant items', async () => { - createWrapper({ - workItemsWidgetMetadataQueryHandler: - workItemsWidgetMetadataQuerySuccessHandler(withoutItems), - }); - await waitForPromises(); - - expect(findAssignedLastUpdatedAt().exists()).toBe(false); - expect(findAuthoredLastUpdatedAt().exists()).toBe(false); - - expect(findAssignedCount().text()).toBe('0'); - expect(findAuthoredCount().text()).toBe('0'); - }); - - it('shows error messages in both cards if the query errors out', async () => { - createWrapper({ - workItemsWidgetMetadataQueryHandler: () => jest.fn().mockRejectedValue(), - }); - await waitForPromises(); - - expect(findAssignedCard().text()).toContain( - 'The number of issues is not available. Please refresh the page to try again, or visit the issue list.', - ); - expect(findAuthoredCard().text()).toContain( - 'The number of issues is not available. Please refresh the page to try again, or visit the issue list.', - ); - expect(Sentry.captureException).toHaveBeenCalled(); - - expect(findAssignedCard().text()).not.toMatch('Issues assigned to you'); - expect(findAuthoredCard().text()).not.toMatch('Issues authored by you'); - }); - - it('shows error icons in both cards when in error state', async () => { - createWrapper({ - workItemsWidgetMetadataQueryHandler: () => jest.fn().mockRejectedValue(), - }); - await waitForPromises(); - const allIcons = wrapper.findAllComponents({ name: 'GlIcon' }); - - let errorIconCount = 0; - for (let i = 0; i < allIcons.length; i += 1) { - const icon = allIcons.at(i); - if (icon.props('name') === 'error') { - expect(icon.props('size')).toBe(16); - expect(icon.classes('gl-text-red-500')).toBe(true); - errorIconCount += 1; - } - } - - expect(errorIconCount).toBe(2); - }); - }); - - describe('BaseWidget integration', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders BaseWidget without styling', () => { - const baseWidget = findBaseWidget(); - - expect(baseWidget.exists()).toBe(true); - expect(baseWidget.props('applyDefaultStyling')).toBe(false); - }); - - it('handles visible event from BaseWidget', async () => { - const workItemsWidgetMetadataQueryHandler = - workItemsWidgetMetadataQuerySuccessHandler(withItems); - - createWrapper({ workItemsWidgetMetadataQueryHandler }); - await waitForPromises(); - expect(workItemsWidgetMetadataQueryHandler).toHaveBeenCalledTimes(1); - - const baseWidget = findBaseWidget(); - baseWidget.vm.$emit('visible'); - - await waitForPromises(); - expect(workItemsWidgetMetadataQueryHandler).toHaveBeenCalledTimes(2); - }); - }); - - describe('number formatting', () => { - it('formats large counts using formatNumberWithScale', async () => { - const mockData = { - data: { - currentUser: { - id: 'gid://gitlab/User/1', - assigned: { - count: 15000, - nodes: [ - { - id: 'gid://gitlab/WorkItem/1', - updatedAt: '2025-06-28T18:13:25Z', - __typename: 'WorkItem', - }, - ], - __typename: 'WorkItemConnection', - }, - authored: { - count: 1500000, - nodes: [ - { - id: 'gid://gitlab/WorkItem/2', - updatedAt: '2025-06-25T18:13:25Z', - __typename: 'WorkItem', - }, - ], - __typename: 'WorkItemConnection', - }, - __typename: 'CurrentUser', - }, - }, - }; - - createWrapper({ - workItemsWidgetMetadataQueryHandler: workItemsWidgetMetadataQuerySuccessHandler(mockData), - assignedIssuesCount: 15000, - }); - await waitForPromises(); - - expect(findAssignedCount().text()).toBe('15K'); - expect(findAuthoredCount().text()).toBe('1.5M'); - }); - }); - - describe('tracking', () => { - const { bindInternalEventDocument } = useMockInternalEventsTracking(); - - beforeEach(async () => { - createWrapper(); - await waitForPromises(); - }); - - it('tracks click on "Issues assigned to you" card', () => { - const { trackEventSpy } = bindInternalEventDocument(wrapper.element); - const assignedCard = findAssignedCard(); - - assignedCard.vm.$emit('click'); - - expect(trackEventSpy).toHaveBeenCalledWith( - EVENT_USER_FOLLOWS_LINK_ON_HOMEPAGE, - { - label: TRACKING_LABEL_ISSUES, - property: TRACKING_PROPERTY_ASSIGNED_TO_YOU, - }, - undefined, - ); - }); - - it('tracks click on "Issues authored by you" card', () => { - const { trackEventSpy } = bindInternalEventDocument(wrapper.element); - const authoredCard = findAuthoredCard(); - - authoredCard.vm.$emit('click'); - - expect(trackEventSpy).toHaveBeenCalledWith( - EVENT_USER_FOLLOWS_LINK_ON_HOMEPAGE, - { - label: TRACKING_LABEL_ISSUES, - property: TRACKING_PROPERTY_AUTHORED_BY_YOU, - }, - undefined, - ); - }); - }); -}); -- GitLab From 56f9bca28b853434ea49d7b8b66bb0aa47915a00 Mon Sep 17 00:00:00 2001 From: Konstantin Greif Date: Tue, 7 Oct 2025 15:28:57 +0200 Subject: [PATCH 2/2] Use design tokens --- .../homepage/components/user_items_count_widget.vue | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/homepage/components/user_items_count_widget.vue b/app/assets/javascripts/homepage/components/user_items_count_widget.vue index a181e17beea772..3099f8c63ae152 100644 --- a/app/assets/javascripts/homepage/components/user_items_count_widget.vue +++ b/app/assets/javascripts/homepage/components/user_items_count_widget.vue @@ -73,8 +73,8 @@ export default { >
- -

+ +

{{ errorText }}

@@ -89,11 +89,7 @@ export default {

{{ linkText }}

- + {{ timeFormatted(lastUpdatedAt) }}
-- GitLab