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 {
assignSelf() {
this.$emit('assign-self');
},
toggleAttentionRequired(data) {
this.$emit('toggle-attention-required', data);
},
},
};
</script>
......@@ -58,7 +61,12 @@ export default {
</template>
</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>
</template>
......@@ -32,6 +32,11 @@ export default {
return this.users.length === 0;
},
},
methods: {
toggleAttentionRequired(data) {
this.$emit('toggle-attention-required', data);
},
},
};
</script>
......@@ -61,6 +66,7 @@ export default {
:users="users"
:issuable-type="issuableType"
class="gl-text-gray-800 gl-mt-2 hide-collapsed"
@toggle-attention-required="toggleAttentionRequired"
/>
</div>
</template>
......@@ -125,6 +125,9 @@ export default {
availability: this.assigneeAvailabilityStatus[username] || '',
}));
},
toggleAttentionRequired(data) {
this.mediator.toggleAttentionRequired('assignee', data);
},
},
};
</script>
......@@ -152,6 +155,7 @@ export default {
:editable="store.editable"
:issuable-type="issuableType"
@assign-self="assignSelf"
@toggle-attention-required="toggleAttentionRequired"
/>
</div>
</template>
......@@ -2,6 +2,7 @@
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IssuableType } from '~/issue_show/constants';
import { __, sprintf } from '~/locale';
import AttentionRequiredToggle from '../attention_required_toggle.vue';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue';
......@@ -9,6 +10,7 @@ const DEFAULT_RENDER_COUNT = 5;
export default {
components: {
AttentionRequiredToggle,
AssigneeAvatarLink,
UserNameWithStatus,
},
......@@ -80,6 +82,9 @@ export default {
}
return u?.status?.availability || '';
},
toggleAttentionRequired(data) {
this.$emit('toggle-attention-required', data);
},
},
};
</script>
......@@ -108,6 +113,12 @@ export default {
}"
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
:user="user"
: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 {
requestReview(data) {
this.$emit('request-review', data);
},
toggleAttentionRequired(data) {
this.$emit('toggle-attention-required', data);
},
},
};
</script>
......@@ -70,6 +73,7 @@ export default {
:root-path="rootPath"
:issuable-type="issuableType"
@request-review="requestReview"
@toggle-attention-required="toggleAttentionRequired"
/>
</div>
</div>
......
......@@ -88,6 +88,9 @@ export default {
requestReview(data) {
this.mediator.requestReview(data);
},
toggleAttentionRequired(data) {
this.mediator.toggleAttentionRequired('reviewer', data);
},
},
};
</script>
......@@ -106,6 +109,7 @@ export default {
:editable="store.editable"
:issuable-type="issuableType"
@request-review="requestReview"
@toggle-attention-required="toggleAttentionRequired"
/>
</div>
</template>
<script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, sprintf, s__ } from '~/locale';
import AttentionRequiredToggle from '../attention_required_toggle.vue';
import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading';
......@@ -14,10 +16,12 @@ export default {
GlButton,
GlIcon,
ReviewerAvatarLink,
AttentionRequiredToggle,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
users: {
type: Array,
......@@ -76,6 +80,9 @@ export default {
this.loadingStates[userId] = null;
}
},
toggleAttentionRequired(data) {
this.$emit('toggle-attention-required', data);
},
},
LOADING_STATE,
SUCCESS_STATE,
......@@ -90,6 +97,12 @@ export default {
:class="{ 'gl-mb-3': index !== users.length - 1 }"
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">
<div class="gl-ml-3 gl-line-height-normal gl-display-grid">
<span>{{ user.name }}</span>
......@@ -113,7 +126,9 @@ export default {
data-testid="re-request-success"
/>
<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
:title="$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';
import axios from '~/lib/utils/axios_utils';
import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql';
import sidebarDetailsMRQuery from '../queries/sidebarDetailsMR.query.graphql';
import attentionRequiredMutation from '../queries/attention_required.mutation.graphql';
const queries = {
merge_request: sidebarDetailsMRQuery,
......@@ -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 createFlash from '~/flash';
import { __ } from '~/locale';
import { __, sprintf } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
import { visitUrl } from '../lib/utils/url_utility';
import Service from './services/sidebar_service';
......@@ -56,13 +56,55 @@ export default class SidebarMediator {
return this.service
.requestReview(userId)
.then(() => {
this.store.updateReviewer(userId);
this.store.updateReviewer(userId, 'reviewed');
toast(__('Requested review'));
callback(userId, true);
})
.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) {
this.store.setMoveToProjectId(projectId);
}
......
......@@ -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 });
if (reviewer) {
reviewer.reviewed = false;
reviewer[stateKey] = !reviewer[stateKey];
}
}
......
......@@ -28583,6 +28583,9 @@ msgstr ""
msgid "Remove assignee"
msgstr ""
msgid "Remove attention request"
msgstr ""
msgid "Remove avatar"
msgstr ""
......@@ -28718,6 +28721,9 @@ msgstr ""
msgid "Removed an issue from an epic."
msgstr ""
msgid "Removed attention request from @%{username}"
msgstr ""
msgid "Removed group can not be restored!"
msgstr ""
......@@ -29146,6 +29152,12 @@ msgstr ""
msgid "Request a new one"
msgstr ""
msgid "Request attention"
msgstr ""
msgid "Request attention to review"
msgstr ""
msgid "Request details"
msgstr ""
......@@ -29167,6 +29179,9 @@ msgstr ""
msgid "Requested %{time_ago}"
msgstr ""
msgid "Requested attention from @%{username}"
msgstr ""
msgid "Requested design version does not exist."
msgstr ""
......@@ -36898,6 +36913,9 @@ msgstr ""
msgid "Updating"
msgstr ""
msgid "Updating the attention request for %{username} failed."
msgstr ""
msgid "Updating…"
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 { 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 UncollapsedReviewerList from '~/sidebar/components/reviewers/uncollapsed_reviewer_list.vue';
import userDataMock from '../../user_data_mock';
......@@ -9,7 +10,7 @@ describe('UncollapsedReviewerList component', () => {
const reviewerApprovalIcons = () => wrapper.findAll('[data-testid="re-approved"]');
function createComponent(props = {}) {
function createComponent(props = {}, glFeatures = {}) {
const propsData = {
users: [],
rootPath: TEST_HOST,
......@@ -18,6 +19,9 @@ describe('UncollapsedReviewerList component', () => {
wrapper = shallowMount(UncollapsedReviewerList, {
propsData,
provide: {
glFeatures,
},
});
}
......@@ -110,4 +114,18 @@ describe('UncollapsedReviewerList component', () => {
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';
import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import toast from '~/vue_shared/plugins/global_toast';
import Mock from './mock_data';
jest.mock('~/vue_shared/plugins/global_toast');
describe('Sidebar mediator', () => {
const { mediator: mediatorMockData } = Mock;
let mock;
......@@ -115,4 +118,56 @@ describe('Sidebar mediator', () => {
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