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 @@
+
+
+
+
+
+ {{ s__('Label|Reviewer') }} {{ reviewer.name }}
+ @{{ reviewer.username }}
+
+
+ {{ reviewerCounterLabel }}
+
+
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',