diff --git a/app/assets/javascripts/issuable/components/merge_request_reviewers.vue b/app/assets/javascripts/issuable/components/merge_request_reviewers.vue new file mode 100644 index 0000000000000000000000000000000000000000..c6b6b38694afd51591ae55c10a9cfa77da80f38d --- /dev/null +++ b/app/assets/javascripts/issuable/components/merge_request_reviewers.vue @@ -0,0 +1,98 @@ + + diff --git a/app/assets/javascripts/merge_requests/list/components/merge_requests_list_app.vue b/app/assets/javascripts/merge_requests/list/components/merge_requests_list_app.vue index 3a7238b799ee5097c999c15d4535d1eda9f4823e..905b2c4f431d0317e2f1a08dd32bd3f164ce2aab 100644 --- a/app/assets/javascripts/merge_requests/list/components/merge_requests_list_app.vue +++ b/app/assets/javascripts/merge_requests/list/components/merge_requests_list_app.vue @@ -70,6 +70,7 @@ import { urlSortParams, } from '~/issues/list/constants'; import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; +import MergeRequestReviewers from '~/issuable/components/merge_request_reviewers.vue'; import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; import issuableEventHub from '~/issues/list/eventhub'; import { AutocompleteCache } from '../../utils/autocomplete_cache'; @@ -110,6 +111,7 @@ export default { CiIcon, MergeRequestStatistics, MergeRequestMoreActionsDropdown, + MergeRequestReviewers, ApprovalCount, EmptyState, IssuableMilestone, @@ -571,6 +573,9 @@ export default { } return undefined; }, + getReviewers(issuable) { + return issuable.reviewers?.nodes || []; + }, handleClickTab(state) { if (this.state === state) { return; @@ -799,6 +804,17 @@ export default { + + diff --git a/app/assets/javascripts/merge_requests/list/queries/merge_request.fragment.graphql b/app/assets/javascripts/merge_requests/list/queries/merge_request.fragment.graphql index d6ac8bd0fdc60e71ff059f13aa8bc5d5c03ec019..6c4375f1e0abb9c51e17520bde48ab74ccab07fa 100644 --- a/app/assets/javascripts/merge_requests/list/queries/merge_request.fragment.graphql +++ b/app/assets/javascripts/merge_requests/list/queries/merge_request.fragment.graphql @@ -17,6 +17,11 @@ fragment MergeRequestFragment on MergeRequest { ...User } } + reviewers @skip(if: $hideUsers) { + nodes { + ...User + } + } author @skip(if: $hideUsers) { ...User } diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index 32807ebdf8aa50d672010d9a66abb19da60dc46c..d47e97b82e430fa84f133d4f3833b2e4f63bf022 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -15,6 +15,7 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; import { isExternal, setUrlFragment, visitUrl } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; + import timeagoMixin from '~/vue_shared/mixins/timeago'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; @@ -487,6 +488,7 @@ export default { class="gl-flex gl-items-center" /> +
  • + diff --git a/locale/gitlab.pot b/locale/gitlab.pot index be8f71954fc43b36be379b8f54325630a900eb94..e0909aa2016b333f47fbfd2c3a46ef464e9ed1ec 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -737,6 +737,9 @@ msgid_plural "%{count} more releases" msgstr[0] "" msgstr[1] "" +msgid "%{count} more reviewers" +msgstr "" + msgid "%{count} of %{required} approvals from %{name}" msgstr "" @@ -31764,6 +31767,9 @@ msgstr "" msgid "Label|Assignee" msgstr "" +msgid "Label|Reviewer" +msgstr "" + msgid "Lacking permissions to the blocking merge request" msgstr "" @@ -46436,6 +46442,9 @@ msgstr "" msgid "Review changes" msgstr "" +msgid "Review requested from %{reviewerName}" +msgstr "" + msgid "Review requests" msgstr "" diff --git a/spec/frontend/issuable/components/merge_request_reviewers_spec.js b/spec/frontend/issuable/components/merge_request_reviewers_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..dd6db6aa8a73f1246647780c448f26f1add3b4d8 --- /dev/null +++ b/spec/frontend/issuable/components/merge_request_reviewers_spec.js @@ -0,0 +1,160 @@ +import { shallowMount } from '@vue/test-utils'; +import { mockAssigneesList as mockReviewersList } from 'jest/boards/mock_data'; +import MergeRequestReviewers from '~/issuable/components/merge_request_reviewers.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + +const TEST_CSS_CLASSES = 'test-classes'; +const TEST_MAX_VISIBLE = 4; +const TEST_ICON_SIZE = 16; + +function normalizeMockReviewers(data) { + return data.map((reviewer) => { + const normalizedReviewer = { ...reviewer }; + + normalizedReviewer.avatarUrl = reviewer.avatar_url; + delete normalizedReviewer.avatar_url; + + return normalizedReviewer; + }); +} + +describe('MergeRequestReviewersComponent', () => { + let wrapper; + let vm; + + const factory = (props) => { + wrapper = shallowMount(MergeRequestReviewers, { + propsData: { + reviewers: normalizeMockReviewers(mockReviewersList), + ...props, + }, + }); + vm = wrapper.vm; + }; + + const findTooltipText = () => wrapper.find('[data-testid=js-reviewer-tooltip]').text(); + const findAvatars = () => wrapper.findAllComponents(UserAvatarLink); + const findOverflowCounter = () => wrapper.find('.avatar-counter'); + + it('returns default data props', () => { + factory({ reviewers: mockReviewersList }); + expect(vm.iconSize).toBe(24); + expect(vm.maxVisible).toBe(3); + expect(vm.maxReviewers).toBe(99); + }); + + describe.each` + numReviewers | maxVisible | expectedShown | expectedHidden + ${0} | ${3} | ${0} | ${''} + ${1} | ${3} | ${1} | ${''} + ${2} | ${3} | ${2} | ${''} + ${3} | ${3} | ${3} | ${''} + ${4} | ${3} | ${2} | ${'+2'} + ${5} | ${2} | ${1} | ${'+4'} + ${1000} | ${5} | ${4} | ${'99+'} + `( + 'with reviewers ($numReviewers) and maxVisible ($maxVisible)', + ({ numReviewers, maxVisible, expectedShown, expectedHidden }) => { + beforeEach(() => { + factory({ reviewers: Array(numReviewers).fill({}), maxVisible }); + }); + + if (expectedShown) { + it('shows reviewer avatars', () => { + expect(findAvatars().length).toEqual(expectedShown); + }); + } else { + it('does not show reviewer avatars', () => { + expect(findAvatars().length).toEqual(0); + }); + } + + if (expectedHidden) { + it('shows overflow counter', () => { + const hiddenCount = numReviewers - expectedShown; + + expect(findOverflowCounter().exists()).toBe(true); + expect(findOverflowCounter().text()).toEqual(expectedHidden.toString()); + expect(findOverflowCounter().attributes('title')).toEqual( + `${hiddenCount} more reviewers`, + ); + }); + } else { + it('does not show overflow counter', () => { + expect(findOverflowCounter().exists()).toBe(false); + }); + } + }, + ); + + describe('when mounted', () => { + beforeEach(() => { + factory({ + imgCssClasses: TEST_CSS_CLASSES, + maxVisible: TEST_MAX_VISIBLE, + iconSize: TEST_ICON_SIZE, + }); + }); + + it('computes alt text for reviewer avatar', () => { + expect(vm.avatarUrlTitle(mockReviewersList[0])).toBe('Review requested from Terrell Graham'); + }); + + it('renders reviewer', () => { + const data = findAvatars().wrappers.map((x) => ({ + ...x.props(), + })); + + const expected = mockReviewersList.slice(0, TEST_MAX_VISIBLE - 1).map((x) => + expect.objectContaining({ + imgAlt: `Review requested from ${x.name}`, + imgCssClasses: TEST_CSS_CLASSES, + imgSrc: x.avatar_url, + imgSize: TEST_ICON_SIZE, + }), + ); + + expect(data).toEqual(expected); + }); + + describe('reviewer tooltips', () => { + it('renders "Reviewer" header', () => { + expect(findTooltipText()).toContain('Reviewer'); + }); + + it('renders reviewer name', () => { + expect(findTooltipText()).toContain('Terrell Graham'); + }); + + it('renders reviewer @username', () => { + expect(findTooltipText()).toContain('@monserrate.gleichner'); + }); + + it('does not render `@` when username not available', () => { + const userName = 'User without username'; + factory({ + reviewers: [ + { + name: userName, + }, + ], + }); + + const tooltipText = findTooltipText(); + + expect(tooltipText).toContain(userName); + expect(tooltipText).not.toContain('@'); + }); + }); + describe('Author Link', () => { + it('properly sets href on each reviewer', () => { + const template = findAvatars().wrappers.map((x) => x.props('linkHref')); + const expected = mockReviewersList + .slice(0, TEST_MAX_VISIBLE - 1) + .map((x) => `/${x.username}`); + + expect(template).toEqual(expected); + }); + }); + }); +}); diff --git a/spec/frontend/merge_requests/list/components/merge_requests_list_app_spec.js b/spec/frontend/merge_requests/list/components/merge_requests_list_app_spec.js index 1ce17e781372d825d4cac5369431c218625f02fe..cb81bf78ee85e2f5c1d0626176a3c7fc204bc043 100644 --- a/spec/frontend/merge_requests/list/components/merge_requests_list_app_spec.js +++ b/spec/frontend/merge_requests/list/components/merge_requests_list_app_spec.js @@ -43,6 +43,7 @@ import { BRANCH_LIST_REFRESH_INTERVAL } from '~/merge_requests/list/constants'; import getMergeRequestsQuery from '~/merge_requests/list/queries/get_merge_requests.query.graphql'; import getMergeRequestsCountQuery from '~/merge_requests/list/queries/get_merge_requests_counts.query.graphql'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import MergeRequestReviewers from '~/issuable/components/merge_request_reviewers.vue'; import issuableEventHub from '~/issues/list/eventhub'; Vue.use(VueApollo); @@ -505,6 +506,21 @@ describe('Merge requests list app', () => { ); }); + it('renders merge-request-reviewers component', async () => { + createComponent({ mountFn: mountExtended }); + + await waitForPromises(); + + const reviewersEl = wrapper.findComponent(MergeRequestReviewers); + + expect(reviewersEl.exists()).toBe(true); + expect(reviewersEl.props()).toMatchObject({ + reviewers: getQueryResponse.data.project.mergeRequests.nodes[0].reviewers.nodes, + iconSize: 16, + maxVisible: 4, + }); + }); + describe('bulk edit', () => { it('renders when user has permissions', () => { createComponent({ provide: { canBulkUpdate: true }, mountFn: mountExtended }); diff --git a/spec/frontend/merge_requests/list/mock_data.js b/spec/frontend/merge_requests/list/mock_data.js index ac6184c4758b69e50ebe921fd25ed051e3060d17..9cd4fa7437bb6585aa227eed0fba478fcc76959f 100644 --- a/spec/frontend/merge_requests/list/mock_data.js +++ b/spec/frontend/merge_requests/list/mock_data.js @@ -37,6 +37,19 @@ export const getQueryResponse = { }, ], }, + reviewers: { + nodes: [ + { + __typename: 'UserCore', + id: 'gid://gitlab/User/789', + avatarUrl: 'avatar/url', + name: 'Bart Simpson', + username: 'bsimpson', + webUrl: 'url/bsimpson', + webPath: '/bsimpson', + }, + ], + }, author: { __typename: 'UserCore', id: 'gid://gitlab/User/456',