Commit 348da757 authored by Phil Hughes's avatar Phil Hughes

Added attention required icon to assignees and reviewers

For now this does not call the correct API as that is in a different
merge request.

Closes https://gitlab.com/gitlab-org/gitlab/-/issues/343326/
parent 7bfc7b71
...@@ -39,6 +39,9 @@ export default { ...@@ -39,6 +39,9 @@ export default {
assignSelf() { assignSelf() {
this.$emit('assign-self'); this.$emit('assign-self');
}, },
toggleAttentionRequired(data) {
this.$emit('toggle-attention-required', data);
},
}, },
}; };
</script> </script>
...@@ -58,7 +61,12 @@ export default { ...@@ -58,7 +61,12 @@ export default {
</template> </template>
</span> </span>
<uncollapsed-assignee-list v-else :users="sortedAssigness" :issuable-type="issuableType" /> <uncollapsed-assignee-list
v-else
:users="sortedAssigness"
:issuable-type="issuableType"
@toggle-attention-required="toggleAttentionRequired"
/>
</div> </div>
</div> </div>
</template> </template>
...@@ -32,6 +32,11 @@ export default { ...@@ -32,6 +32,11 @@ export default {
return this.users.length === 0; return this.users.length === 0;
}, },
}, },
methods: {
toggleAttentionRequired(data) {
this.$emit('toggle-attention-required', data);
},
},
}; };
</script> </script>
...@@ -61,6 +66,7 @@ export default { ...@@ -61,6 +66,7 @@ export default {
:users="users" :users="users"
:issuable-type="issuableType" :issuable-type="issuableType"
class="gl-text-gray-800 gl-mt-2 hide-collapsed" class="gl-text-gray-800 gl-mt-2 hide-collapsed"
@toggle-attention-required="toggleAttentionRequired"
/> />
</div> </div>
</template> </template>
...@@ -125,6 +125,9 @@ export default { ...@@ -125,6 +125,9 @@ export default {
availability: this.assigneeAvailabilityStatus[username] || '', availability: this.assigneeAvailabilityStatus[username] || '',
})); }));
}, },
toggleAttentionRequired(data) {
this.mediator.toggleAttentionRequired('assignee', data);
},
}, },
}; };
</script> </script>
...@@ -152,6 +155,7 @@ export default { ...@@ -152,6 +155,7 @@ export default {
:editable="store.editable" :editable="store.editable"
:issuable-type="issuableType" :issuable-type="issuableType"
@assign-self="assignSelf" @assign-self="assignSelf"
@toggle-attention-required="toggleAttentionRequired"
/> />
</div> </div>
</template> </template>
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IssuableType } from '~/issue_show/constants'; import { IssuableType } from '~/issue_show/constants';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import AttentionRequiredToggle from '../attention_required_toggle.vue';
import AssigneeAvatarLink from './assignee_avatar_link.vue'; import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue'; import UserNameWithStatus from './user_name_with_status.vue';
...@@ -9,6 +10,7 @@ const DEFAULT_RENDER_COUNT = 5; ...@@ -9,6 +10,7 @@ const DEFAULT_RENDER_COUNT = 5;
export default { export default {
components: { components: {
AttentionRequiredToggle,
AssigneeAvatarLink, AssigneeAvatarLink,
UserNameWithStatus, UserNameWithStatus,
}, },
...@@ -80,6 +82,9 @@ export default { ...@@ -80,6 +82,9 @@ export default {
} }
return u?.status?.availability || ''; return u?.status?.availability || '';
}, },
toggleAttentionRequired(data) {
this.$emit('toggle-attention-required', data);
},
}, },
}; };
</script> </script>
...@@ -108,6 +113,12 @@ export default { ...@@ -108,6 +113,12 @@ export default {
}" }"
class="gl-display-inline-block" class="gl-display-inline-block"
> >
<attention-required-toggle
v-if="showVerticalList && user.can_update_merge_request"
:user="user"
type="assignee"
@toggle-attention-required="toggleAttentionRequired"
/>
<assignee-avatar-link <assignee-avatar-link
:user="user" :user="user"
:issuable-type="issuableType" :issuable-type="issuableType"
......
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
export default {
i18n: {
attentionRequiredReviewer: __('Request attention to review'),
attentionRequiredAssignee: __('Request attention'),
removeAttentionRequired: __('Remove attention request'),
},
components: {
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
type: {
type: String,
required: true,
},
user: {
type: Object,
required: true,
},
},
data() {
return {
loading: false,
};
},
computed: {
tooltipTitle() {
if (this.user.attention_required) {
return this.$options.i18n.removeAttentionRequired;
}
return this.type === 'reviewer'
? this.$options.i18n.attentionRequiredReviewer
: this.$options.i18n.attentionRequiredAssignee;
},
},
methods: {
toggleAttentionRequired() {
if (this.loading) return;
this.$root.$emit(BV_HIDE_TOOLTIP);
this.loading = true;
this.$emit('toggle-attention-required', {
user: this.user,
callback: this.toggleAttentionRequiredComplete,
});
},
toggleAttentionRequiredComplete() {
this.loading = false;
},
},
};
</script>
<template>
<span v-gl-tooltip.left.viewport="tooltipTitle">
<gl-button
:loading="loading"
:variant="user.attention_required ? 'warning' : 'default'"
:icon="user.attention_required ? 'star' : 'star-o'"
:aria-label="tooltipTitle"
size="small"
category="tertiary"
@click="toggleAttentionRequired"
/>
</span>
</template>
...@@ -49,6 +49,9 @@ export default { ...@@ -49,6 +49,9 @@ export default {
requestReview(data) { requestReview(data) {
this.$emit('request-review', data); this.$emit('request-review', data);
}, },
toggleAttentionRequired(data) {
this.$emit('toggle-attention-required', data);
},
}, },
}; };
</script> </script>
...@@ -70,6 +73,7 @@ export default { ...@@ -70,6 +73,7 @@ export default {
:root-path="rootPath" :root-path="rootPath"
:issuable-type="issuableType" :issuable-type="issuableType"
@request-review="requestReview" @request-review="requestReview"
@toggle-attention-required="toggleAttentionRequired"
/> />
</div> </div>
</div> </div>
......
...@@ -88,6 +88,9 @@ export default { ...@@ -88,6 +88,9 @@ export default {
requestReview(data) { requestReview(data) {
this.mediator.requestReview(data); this.mediator.requestReview(data);
}, },
toggleAttentionRequired(data) {
this.mediator.toggleAttentionRequired('reviewer', data);
},
}, },
}; };
</script> </script>
...@@ -106,6 +109,7 @@ export default { ...@@ -106,6 +109,7 @@ export default {
:editable="store.editable" :editable="store.editable"
:issuable-type="issuableType" :issuable-type="issuableType"
@request-review="requestReview" @request-review="requestReview"
@toggle-attention-required="toggleAttentionRequired"
/> />
</div> </div>
</template> </template>
<script> <script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, sprintf, s__ } from '~/locale'; import { __, sprintf, s__ } from '~/locale';
import AttentionRequiredToggle from '../attention_required_toggle.vue';
import ReviewerAvatarLink from './reviewer_avatar_link.vue'; import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading'; const LOADING_STATE = 'loading';
...@@ -14,10 +16,12 @@ export default { ...@@ -14,10 +16,12 @@ export default {
GlButton, GlButton,
GlIcon, GlIcon,
ReviewerAvatarLink, ReviewerAvatarLink,
AttentionRequiredToggle,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
users: { users: {
type: Array, type: Array,
...@@ -76,6 +80,9 @@ export default { ...@@ -76,6 +80,9 @@ export default {
this.loadingStates[userId] = null; this.loadingStates[userId] = null;
} }
}, },
toggleAttentionRequired(data) {
this.$emit('toggle-attention-required', data);
},
}, },
LOADING_STATE, LOADING_STATE,
SUCCESS_STATE, SUCCESS_STATE,
...@@ -90,6 +97,12 @@ export default { ...@@ -90,6 +97,12 @@ export default {
:class="{ 'gl-mb-3': index !== users.length - 1 }" :class="{ 'gl-mb-3': index !== users.length - 1 }"
data-testid="reviewer" data-testid="reviewer"
> >
<attention-required-toggle
v-if="glFeatures.mrAttentionRequests && user.can_update_merge_request"
:user="user"
type="reviewer"
@toggle-attention-required="toggleAttentionRequired"
/>
<reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType"> <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType">
<div class="gl-ml-3 gl-line-height-normal gl-display-grid"> <div class="gl-ml-3 gl-line-height-normal gl-display-grid">
<span>{{ user.name }}</span> <span>{{ user.name }}</span>
...@@ -113,7 +126,9 @@ export default { ...@@ -113,7 +126,9 @@ export default {
data-testid="re-request-success" data-testid="re-request-success"
/> />
<gl-button <gl-button
v-else-if="user.can_update_merge_request && user.reviewed" v-else-if="
user.can_update_merge_request && user.reviewed && !glFeatures.mrAttentionRequests
"
v-gl-tooltip.left v-gl-tooltip.left
:title="$options.i18n.reRequestReview" :title="$options.i18n.reRequestReview"
:aria-label="$options.i18n.reRequestReview" :aria-label="$options.i18n.reRequestReview"
......
mutation mergeRequestAttentionRequired($projectPath: ID!, $iid: String!, $userId: ID!) {
mergeRequestAttentionRequired(input: { projectPath: $projectPath, iid: $iid, userId: $userId }) {
errors
}
}
...@@ -5,6 +5,7 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql'; ...@@ -5,6 +5,7 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql'; import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql';
import sidebarDetailsMRQuery from '../queries/sidebarDetailsMR.query.graphql'; import sidebarDetailsMRQuery from '../queries/sidebarDetailsMR.query.graphql';
import attentionRequiredMutation from '../queries/attention_required.mutation.graphql';
const queries = { const queries = {
merge_request: sidebarDetailsMRQuery, merge_request: sidebarDetailsMRQuery,
...@@ -90,4 +91,15 @@ export default class SidebarService { ...@@ -90,4 +91,15 @@ export default class SidebarService {
}, },
}); });
} }
attentionRequired(userId) {
return gqClient.mutate({
mutation: attentionRequiredMutation,
variables: {
userId: convertToGraphQLId(TYPE_USER, `${userId}`),
projectPath: this.fullPath,
iid: this.iid.toString(),
},
});
}
} }
import Store from 'ee_else_ce/sidebar/stores/sidebar_store'; import Store from 'ee_else_ce/sidebar/stores/sidebar_store';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __, sprintf } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast'; import toast from '~/vue_shared/plugins/global_toast';
import { visitUrl } from '../lib/utils/url_utility'; import { visitUrl } from '../lib/utils/url_utility';
import Service from './services/sidebar_service'; import Service from './services/sidebar_service';
...@@ -56,13 +56,55 @@ export default class SidebarMediator { ...@@ -56,13 +56,55 @@ export default class SidebarMediator {
return this.service return this.service
.requestReview(userId) .requestReview(userId)
.then(() => { .then(() => {
this.store.updateReviewer(userId); this.store.updateReviewer(userId, 'reviewed');
toast(__('Requested review')); toast(__('Requested review'));
callback(userId, true); callback(userId, true);
}) })
.catch(() => callback(userId, false)); .catch(() => callback(userId, false));
} }
async toggleAttentionRequired(type, { user, callback }) {
try {
const isReviewer = type === 'reviewer';
const reviewerOrAssignee = isReviewer
? this.store.findReviewer(user)
: this.store.findAssignee(user);
if (reviewerOrAssignee.attention_required) {
toast(
sprintf(__('Removed attention request from @%{username}'), {
username: user.username,
}),
);
} else {
await this.service.attentionRequired(user.id);
toast(sprintf(__('Requested attention from @%{username}'), { username: user.username }));
}
if (isReviewer) {
this.store.updateReviewer(user.id, 'attention_required');
} else {
this.store.updateAssignee(user.id, 'attention_required');
}
callback();
} catch (error) {
callback();
createFlash({
message: sprintf(__('Updating the attention request for %{username} failed.'), {
username: user.username,
}),
error,
captureError: true,
actionConfig: {
title: __('Try again'),
clickHandler: () => this.toggleAttentionRequired(type, { user, callback }),
},
});
}
}
setMoveToProjectId(projectId) { setMoveToProjectId(projectId) {
this.store.setMoveToProjectId(projectId); this.store.setMoveToProjectId(projectId);
} }
......
...@@ -82,11 +82,19 @@ export default class SidebarStore { ...@@ -82,11 +82,19 @@ export default class SidebarStore {
} }
} }
updateReviewer(id) { updateAssignee(id, stateKey) {
const assignee = this.findAssignee({ id });
if (assignee) {
assignee[stateKey] = !assignee[stateKey];
}
}
updateReviewer(id, stateKey) {
const reviewer = this.findReviewer({ id }); const reviewer = this.findReviewer({ id });
if (reviewer) { if (reviewer) {
reviewer.reviewed = false; reviewer[stateKey] = !reviewer[stateKey];
} }
} }
......
...@@ -28583,6 +28583,9 @@ msgstr "" ...@@ -28583,6 +28583,9 @@ msgstr ""
msgid "Remove assignee" msgid "Remove assignee"
msgstr "" msgstr ""
msgid "Remove attention request"
msgstr ""
msgid "Remove avatar" msgid "Remove avatar"
msgstr "" msgstr ""
...@@ -28718,6 +28721,9 @@ msgstr "" ...@@ -28718,6 +28721,9 @@ msgstr ""
msgid "Removed an issue from an epic." msgid "Removed an issue from an epic."
msgstr "" msgstr ""
msgid "Removed attention request from @%{username}"
msgstr ""
msgid "Removed group can not be restored!" msgid "Removed group can not be restored!"
msgstr "" msgstr ""
...@@ -29146,6 +29152,12 @@ msgstr "" ...@@ -29146,6 +29152,12 @@ msgstr ""
msgid "Request a new one" msgid "Request a new one"
msgstr "" msgstr ""
msgid "Request attention"
msgstr ""
msgid "Request attention to review"
msgstr ""
msgid "Request details" msgid "Request details"
msgstr "" msgstr ""
...@@ -29167,6 +29179,9 @@ msgstr "" ...@@ -29167,6 +29179,9 @@ msgstr ""
msgid "Requested %{time_ago}" msgid "Requested %{time_ago}"
msgstr "" msgstr ""
msgid "Requested attention from @%{username}"
msgstr ""
msgid "Requested design version does not exist." msgid "Requested design version does not exist."
msgstr "" msgstr ""
...@@ -36898,6 +36913,9 @@ msgstr "" ...@@ -36898,6 +36913,9 @@ msgstr ""
msgid "Updating" msgid "Updating"
msgstr "" msgstr ""
msgid "Updating the attention request for %{username} failed."
msgstr ""
msgid "Updating…" msgid "Updating…"
msgstr "" msgstr ""
......
import { GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import AttentionRequiredToggle from '~/sidebar/components/attention_required_toggle.vue';
let wrapper;
function factory(propsData = {}) {
wrapper = mount(AttentionRequiredToggle, { propsData });
}
const findToggle = () => wrapper.findComponent(GlButton);
describe('Attention require toggle', () => {
afterEach(() => {
wrapper.destroy();
});
it('renders button', () => {
factory({ type: 'reviewer', user: { attention_required: false } });
expect(findToggle().exists()).toBe(true);
});
it.each`
attentionRequired | icon
${true} | ${'star'}
${false} | ${'star-o'}
`(
'renders $icon icon when attention_required is $attentionRequired',
({ attentionRequired, icon }) => {
factory({ type: 'reviewer', user: { attention_required: attentionRequired } });
expect(findToggle().props('icon')).toBe(icon);
},
);
it.each`
attentionRequired | variant
${true} | ${'warning'}
${false} | ${'default'}
`(
'renders button with variant $variant when attention_required is $attentionRequired',
({ attentionRequired, variant }) => {
factory({ type: 'reviewer', user: { attention_required: attentionRequired } });
expect(findToggle().props('variant')).toBe(variant);
},
);
it('emits toggle-attention-required on click', async () => {
factory({ type: 'reviewer', user: { attention_required: true } });
await findToggle().trigger('click');
expect(wrapper.emitted('toggle-attention-required')[0]).toEqual([
{
user: { attention_required: true },
callback: expect.anything(),
},
]);
});
it('sets loading on click', async () => {
factory({ type: 'reviewer', user: { attention_required: true } });
await findToggle().trigger('click');
expect(findToggle().props('loading')).toBe(true);
});
it.each`
type | attentionRequired | tooltip
${'reviewer'} | ${true} | ${AttentionRequiredToggle.i18n.removeAttentionRequired}
${'reviewer'} | ${false} | ${AttentionRequiredToggle.i18n.attentionRequiredReviewer}
${'assignee'} | ${false} | ${AttentionRequiredToggle.i18n.attentionRequiredAssignee}
`(
'sets tooltip as $tooltip when attention_required is $attentionRequired and type is $type',
({ type, attentionRequired, tooltip }) => {
factory({ type, user: { attention_required: attentionRequired } });
expect(findToggle().attributes('aria-label')).toBe(tooltip);
},
);
});
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import AttentionRequiredToggle from '~/sidebar/components/attention_required_toggle.vue';
import ReviewerAvatarLink from '~/sidebar/components/reviewers/reviewer_avatar_link.vue'; import ReviewerAvatarLink from '~/sidebar/components/reviewers/reviewer_avatar_link.vue';
import UncollapsedReviewerList from '~/sidebar/components/reviewers/uncollapsed_reviewer_list.vue'; import UncollapsedReviewerList from '~/sidebar/components/reviewers/uncollapsed_reviewer_list.vue';
import userDataMock from '../../user_data_mock'; import userDataMock from '../../user_data_mock';
...@@ -9,7 +10,7 @@ describe('UncollapsedReviewerList component', () => { ...@@ -9,7 +10,7 @@ describe('UncollapsedReviewerList component', () => {
const reviewerApprovalIcons = () => wrapper.findAll('[data-testid="re-approved"]'); const reviewerApprovalIcons = () => wrapper.findAll('[data-testid="re-approved"]');
function createComponent(props = {}) { function createComponent(props = {}, glFeatures = {}) {
const propsData = { const propsData = {
users: [], users: [],
rootPath: TEST_HOST, rootPath: TEST_HOST,
...@@ -18,6 +19,9 @@ describe('UncollapsedReviewerList component', () => { ...@@ -18,6 +19,9 @@ describe('UncollapsedReviewerList component', () => {
wrapper = shallowMount(UncollapsedReviewerList, { wrapper = shallowMount(UncollapsedReviewerList, {
propsData, propsData,
provide: {
glFeatures,
},
}); });
} }
...@@ -110,4 +114,18 @@ describe('UncollapsedReviewerList component', () => { ...@@ -110,4 +114,18 @@ describe('UncollapsedReviewerList component', () => {
expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true); expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true);
}); });
}); });
it('hides re-request review button when attentionRequired feature flag is enabled', () => {
createComponent({ users: [userDataMock()] }, { mrAttentionRequests: true });
expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(0);
});
it('emits toggle-attention-required', () => {
createComponent({ users: [userDataMock()] }, { mrAttentionRequests: true });
wrapper.find(AttentionRequiredToggle).vm.$emit('toggle-attention-required', 'data');
expect(wrapper.emitted('toggle-attention-required')[0]).toEqual(['data']);
});
}); });
...@@ -4,8 +4,11 @@ import * as urlUtility from '~/lib/utils/url_utility'; ...@@ -4,8 +4,11 @@ import * as urlUtility from '~/lib/utils/url_utility';
import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service'; import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service';
import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarStore from '~/sidebar/stores/sidebar_store'; import SidebarStore from '~/sidebar/stores/sidebar_store';
import toast from '~/vue_shared/plugins/global_toast';
import Mock from './mock_data'; import Mock from './mock_data';
jest.mock('~/vue_shared/plugins/global_toast');
describe('Sidebar mediator', () => { describe('Sidebar mediator', () => {
const { mediator: mediatorMockData } = Mock; const { mediator: mediatorMockData } = Mock;
let mock; let mock;
...@@ -115,4 +118,56 @@ describe('Sidebar mediator', () => { ...@@ -115,4 +118,56 @@ describe('Sidebar mediator', () => {
urlSpy.mockRestore(); urlSpy.mockRestore();
}); });
}); });
describe('toggleAttentionRequired', () => {
let attentionRequiredService;
beforeEach(() => {
attentionRequiredService = jest
.spyOn(mediator.service, 'attentionRequired')
.mockResolvedValue();
});
it('calls attentionRequired service method', async () => {
mediator.store.reviewers = [{ id: 1, attention_required: false, username: 'root' }];
await mediator.toggleAttentionRequired('reviewer', {
user: { id: 1, username: 'root' },
callback: jest.fn(),
});
expect(attentionRequiredService).toHaveBeenCalledWith(1);
});
it.each`
type | method
${'reviewer'} | ${'findReviewer'}
`('finds $type', ({ type, method }) => {
const methodSpy = jest.spyOn(mediator.store, method);
mediator.toggleAttentionRequired(type, { user: { id: 1 }, callback: jest.fn() });
expect(methodSpy).toHaveBeenCalledWith({ id: 1 });
});
it.each`
attentionRequired | toastMessage
${true} | ${'Removed attention request from @root'}
${false} | ${'Requested attention from @root'}
`(
'it creates toast $toastMessage when attention_required is $attentionRequired',
async ({ attentionRequired, toastMessage }) => {
mediator.store.reviewers = [
{ id: 1, attention_required: attentionRequired, username: 'root' },
];
await mediator.toggleAttentionRequired('reviewer', {
user: { id: 1, username: 'root' },
callback: jest.fn(),
});
expect(toast).toHaveBeenCalledWith(toastMessage);
},
);
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment