Commit a63d8ad6 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska Committed by Brandon Labuschagne

When REMOVING a user, warn Admin user is part of an on-call schedule

parent 617cb171
......@@ -42,6 +42,11 @@ export default {
required: false,
default: false,
},
oncallSchedules: {
type: Object,
required: false,
default: () => {},
},
},
computed: {
...mapState({
......@@ -52,6 +57,9 @@ export default {
computedMemberPath() {
return this.memberPath.replace(':id', this.memberId);
},
stringifiedSchedules() {
return JSON.stringify(this.oncallSchedules);
},
},
};
</script>
......@@ -69,6 +77,7 @@ export default {
:data-is-access-request="isAccessRequest"
:data-is-invite="isInvite"
:data-message="message"
:data-oncall-schedules="stringifiedSchedules"
data-qa-selector="delete_member_button"
/>
</template>
......@@ -33,7 +33,7 @@ export default {
if (user) {
return sprintf(
s__('Members|Are you sure you want to remove %{usersName} from "%{source}"'),
s__('Members|Are you sure you want to remove %{usersName} from "%{source}"?'),
{
usersName: user.name,
source: source.fullName,
......@@ -42,12 +42,16 @@ export default {
}
return sprintf(
s__('Members|Are you sure you want to remove this orphaned member from "%{source}"'),
s__('Members|Are you sure you want to remove this orphaned member from "%{source}"?'),
{
source: source.fullName,
},
);
},
oncallScheduleUserData() {
const { user: { name, oncallSchedules: schedules } = {} } = this.member;
return { name, schedules };
},
},
};
</script>
......@@ -60,6 +64,7 @@ export default {
v-else
:member-id="member.id"
:member-type="member.type"
:oncall-schedules="oncallScheduleUserData"
:message="message"
:title="s__('Member|Remove member')"
/>
......
......@@ -3,6 +3,7 @@ import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
import { LEAVE_MODAL_ID } from '../../constants';
export default {
......@@ -19,7 +20,7 @@ export default {
csrf,
modalId: LEAVE_MODAL_ID,
modalContent: s__('Members|Are you sure you want to leave "%{source}"?'),
components: { GlModal, GlForm, GlSprintf },
components: { GlModal, GlForm, GlSprintf, OncallSchedulesList },
directives: {
GlTooltip: GlTooltipDirective,
},
......@@ -42,6 +43,12 @@ export default {
modalTitle() {
return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName });
},
schedules() {
return this.member.user?.oncallSchedules;
},
isPartOfOnCallSchedules() {
return this.schedules?.length;
},
},
methods: {
handlePrimary() {
......@@ -58,7 +65,6 @@ export default {
:title="modalTitle"
:action-primary="$options.actionPrimary"
:action-cancel="$options.actionCancel"
size="sm"
@primary="handlePrimary"
>
<gl-form ref="form" :action="leavePath" method="post">
......@@ -68,6 +74,12 @@ export default {
</gl-sprintf>
</p>
<oncall-schedules-list
v-if="isPartOfOnCallSchedules"
:schedules="schedules"
:is-current-user="true"
/>
<input type="hidden" name="_method" value="delete" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
</gl-form>
......
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
export default {
components: {
GlSprintf,
GlLink,
},
props: {
schedules: {
type: Array,
required: true,
},
userName: {
type: String,
required: false,
default: null,
},
isCurrentUser: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
title() {
return this.isCurrentUser
? s__('OnCallSchedules|You are currently a part of:')
: sprintf(s__('OnCallSchedules|User %{name} is currently part of:'), {
name: this.userName,
});
},
footer() {
return this.isCurrentUser
? s__(
'OnCallSchedules|Removing yourself may put your on-call team at risk of missing a notification.',
)
: s__(
'OnCallSchedules|Removing this user may put their on-call team at risk of missing a notification.',
);
},
},
};
</script>
<template>
<div>
<p data-testid="title">{{ title }}</p>
<ul data-testid="schedules-list">
<li v-for="(schedule, index) in schedules" :key="`${schedule.name}-${index}`">
<gl-sprintf
:message="s__('OnCallSchedules|On-call schedule %{schedule} in Project %{project}')"
>
<template #schedule>
<gl-link :href="schedule.scheduleUrl" target="_blank">{{ schedule.name }}</gl-link>
</template>
<template #project>
<gl-link :href="schedule.projectUrl" target="_blank">{{
schedule.projectName
}}</gl-link>
</template>
</gl-sprintf>
</li>
</ul>
<p data-testid="footer">{{ footer }}</p>
</div>
</template>
<script>
import { GlFormCheckbox, GlModal } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { parseBoolean } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
import { s__, __ } from '~/locale';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
export default {
actionCancel: {
......@@ -12,6 +14,7 @@ export default {
components: {
GlFormCheckbox,
GlModal,
OncallSchedulesList,
},
data() {
return {
......@@ -48,6 +51,18 @@ export default {
showUnassignIssuablesCheckbox() {
return !this.isAccessRequest && !this.isInvite;
},
isPartOfOncallSchedules() {
return !this.isAccessRequest && this.oncallSchedules.schedules?.length;
},
oncallSchedules() {
let schedules = {};
try {
schedules = JSON.parse(this.modalData.oncallSchedules);
} catch (e) {
Sentry.captureException(e);
}
return schedules;
},
},
mounted() {
document.addEventListener('click', this.handleClick);
......@@ -83,6 +98,12 @@ export default {
<form ref="form" :action="modalData.memberPath" method="post">
<p data-testid="modal-message">{{ modalData.message }}</p>
<oncall-schedules-list
v-if="isPartOfOncallSchedules"
:schedules="oncallSchedules.schedules"
:user-name="oncallSchedules.name"
/>
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
<gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships">
......
---
title: "When removing a user, warn Admin user is part of an on-call schedule"
merge_request: 57397
author:
type: added
......@@ -19640,10 +19640,10 @@ msgstr ""
msgid "Members|Are you sure you want to remove \"%{groupName}\"?"
msgstr ""
msgid "Members|Are you sure you want to remove %{usersName} from \"%{source}\""
msgid "Members|Are you sure you want to remove %{usersName} from \"%{source}\"?"
msgstr ""
msgid "Members|Are you sure you want to remove this orphaned member from \"%{source}\""
msgid "Members|Are you sure you want to remove this orphaned member from \"%{source}\"?"
msgstr ""
msgid "Members|Are you sure you want to revoke the invitation for %{inviteEmail} to join \"%{source}\""
......@@ -22011,12 +22011,21 @@ msgstr ""
msgid "OnCallSchedules|For this rotation, on-call will be:"
msgstr ""
msgid "OnCallSchedules|On-call schedule %{schedule} in Project %{project}"
msgstr ""
msgid "OnCallSchedules|On-call schedules"
msgstr ""
msgid "OnCallSchedules|Please note, rotations with shifts that are less than four hours are currently not supported in the weekly view."
msgstr ""
msgid "OnCallSchedules|Removing this user may put their on-call team at risk of missing a notification."
msgstr ""
msgid "OnCallSchedules|Removing yourself may put your on-call team at risk of missing a notification."
msgstr ""
msgid "OnCallSchedules|Restrict to time intervals"
msgstr ""
......@@ -22071,12 +22080,18 @@ msgstr ""
msgid "OnCallSchedules|Try adding a rotation"
msgstr ""
msgid "OnCallSchedules|User %{name} is currently part of:"
msgstr ""
msgid "OnCallSchedules|View next timeframe"
msgstr ""
msgid "OnCallSchedules|View previous timeframe"
msgstr ""
msgid "OnCallSchedules|You are currently a part of:"
msgstr ""
msgid "OnCallSchedules|Your schedule has been successfully created and all alerts from this project will now be routed to this schedule. Currently, only one schedule can be created per project. More coming soon! To add individual users to this schedule, use the add a rotation button."
msgstr ""
......
......@@ -38,6 +38,7 @@ describe('RemoveMemberButton', () => {
title: 'Remove member',
isAccessRequest: true,
isInvite: true,
oncallSchedules: { name: 'user', schedules: [] },
...propsData,
},
directives: {
......@@ -59,6 +60,7 @@ describe('RemoveMemberButton', () => {
'data-message': 'Are you sure you want to remove John Smith?',
'data-is-access-request': 'true',
'data-is-invite': 'true',
'data-oncall-schedules': '{"name":"user","schedules":[]}',
'aria-label': 'Remove member',
title: 'Remove member',
icon: 'remove',
......
......@@ -40,11 +40,15 @@ describe('UserActionButtons', () => {
expect(findRemoveMemberButton().props()).toEqual({
memberId: member.id,
memberType: 'GroupMember',
message: `Are you sure you want to remove ${member.user.name} from "${member.source.fullName}"`,
message: `Are you sure you want to remove ${member.user.name} from "${member.source.fullName}"?`,
title: 'Remove member',
isAccessRequest: false,
isInvite: false,
icon: 'remove',
oncallSchedules: {
name: member.user.name,
schedules: member.user.oncallSchedules,
},
});
});
......@@ -58,7 +62,7 @@ describe('UserActionButtons', () => {
});
expect(findRemoveMemberButton().props('message')).toBe(
`Are you sure you want to remove this orphaned member from "${orphanedMember.source.fullName}"`,
`Are you sure you want to remove this orphaned member from "${orphanedMember.source.fullName}"?`,
);
});
});
......
import { GlModal, GlForm } from '@gitlab/ui';
import { within } from '@testing-library/dom';
import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import { nextTick } from 'vue';
import Vuex from 'vuex';
import LeaveModal from '~/members/components/modals/leave_modal.vue';
import { LEAVE_MODAL_ID, MEMBER_TYPES } from '~/members/constants';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
import { member } from '../../mock_data';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
......@@ -47,9 +49,9 @@ describe('LeaveModal', () => {
});
};
const findModal = () => wrapper.find(GlModal);
const findForm = () => findModal().find(GlForm);
const findModal = () => wrapper.findComponent(GlModal);
const findForm = () => findModal().findComponent(GlForm);
const findOncallSchedulesList = () => findModal().findComponent(OncallSchedulesList);
const getByText = (text, options) =>
createWrapper(within(findModal().element).getByText(text, options));
......@@ -87,6 +89,24 @@ describe('LeaveModal', () => {
);
});
describe('On-call schedules list', () => {
it("displays oncall schedules list when member's user is part of on-call schedules ", () => {
const schedulesList = findOncallSchedulesList();
expect(schedulesList.exists()).toBe(true);
expect(schedulesList.props()).toMatchObject({
isCurrentUser: true,
schedules: member.user.oncallSchedules,
});
});
it("does NOT display oncall schedules list when member's user is NOT a part of on-call schedules ", () => {
const memberWithoutOncallSchedules = cloneDeep(member);
delete (memberWithoutOncallSchedules, 'user.oncallSchedules');
createComponent({ member: memberWithoutOncallSchedules });
expect(findOncallSchedulesList().exists()).toBe(false);
});
});
it('submits the form when "Leave" button is clicked', () => {
const submitSpy = jest.spyOn(findForm().element, 'submit');
......
......@@ -20,6 +20,7 @@ export const member = {
avatarUrl: 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon',
blocked: false,
twoFactorEnabled: false,
oncallSchedules: [{ name: 'schedule 1' }],
},
id: 238,
createdAt: '2020-07-17T16:22:46.923Z',
......
import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
const mockSchedules = JSON.stringify({
schedules: [
{
id: 1,
name: 'Schedule 1',
},
],
name: 'User1',
});
describe('RemoveMemberModal', () => {
const memberPath = '/gitlab-org/gitlab-test/-/project_members/90';
let wrapper;
const findForm = () => wrapper.find({ ref: 'form' });
const findGlModal = () => wrapper.find(GlModal);
const findGlModal = () => wrapper.findComponent(GlModal);
const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList);
afterEach(() => {
wrapper.destroy();
......@@ -15,11 +27,11 @@ describe('RemoveMemberModal', () => {
});
describe.each`
state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message
${'removing a group member'} | ${'GroupMember'} | ${'false'} | ${'false'} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'}
${'removing a project member'} | ${'ProjectMember'} | ${'false'} | ${'false'} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'}
${'denying an access request'} | ${'ProjectMember'} | ${'true'} | ${'false'} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"}
${'revoking invite'} | ${'ProjectMember'} | ${'false'} | ${'true'} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'}
state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules
${'removing a group member'} | ${'GroupMember'} | ${false} | ${'false'} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${`{}`}
${'removing a project member'} | ${'ProjectMember'} | ${false} | ${'false'} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules}
${'denying an access request'} | ${'ProjectMember'} | ${true} | ${'false'} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${`{}`}
${'revoking invite'} | ${'ProjectMember'} | ${false} | ${'true'} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules}
`(
'when $state',
({
......@@ -30,6 +42,7 @@ describe('RemoveMemberModal', () => {
message,
removeSubMembershipsCheckboxExpected,
unassignIssuablesCheckboxExpected,
onCallSchedules,
}) => {
beforeEach(() => {
wrapper = shallowMount(RemoveMemberModal, {
......@@ -41,12 +54,16 @@ describe('RemoveMemberModal', () => {
message,
memberPath,
memberType,
onCallSchedules,
},
};
},
});
});
const parsedSchedules = JSON.parse(onCallSchedules);
const isPartOfOncallSchedules = Boolean(isAccessRequest && parsedSchedules.schedules?.length);
it(`has the title ${actionText}`, () => {
expect(findGlModal().attributes('title')).toBe(actionText);
});
......@@ -75,6 +92,10 @@ describe('RemoveMemberModal', () => {
);
});
it(`shows ${isPartOfOncallSchedules ? 'all' : 'no'} related on-call schedules`, () => {
expect(findOnCallSchedulesList().exists()).toBe(isPartOfOncallSchedules);
});
it('submits the form when the modal is submitted', () => {
const spy = jest.spyOn(findForm().element, 'submit');
......
import { GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
const mockSchedules = [
{
name: 'Schedule 1',
scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/-/oncall_schedules',
projectName: 'Shell',
projectUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/',
},
{
name: 'Schedule 2',
scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/-/oncall_schedules',
projectName: 'UI',
projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/',
},
];
const userName = 'User 1';
describe('On-call schedules list', () => {
let wrapper;
function createComponent(props) {
wrapper = extendedWrapper(
shallowMount(OncallSchedulesList, {
propsData: {
schedules: mockSchedules,
userName,
...props,
},
stubs: {
GlSprintf,
},
}),
);
}
afterEach(() => {
wrapper.destroy();
});
const findLinks = () => wrapper.findAllComponents(GlLink);
const findTitle = () => wrapper.findByTestId('title');
const findFooter = () => wrapper.findByTestId('footer');
const findSchedules = () => wrapper.findByTestId('schedules-list');
describe.each`
isCurrentUser | titleText | footerText
${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'}
${false} | ${`User ${userName} is currently part of:`} | ${'Removing this user may put their on-call team at risk of missing a notification.'}
`('when current user ', ({ isCurrentUser, titleText, footerText }) => {
it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call schedule`, async () => {
createComponent({
isCurrentUser,
});
expect(findTitle().text()).toBe(titleText);
expect(findFooter().text()).toBe(footerText);
});
});
describe.each(mockSchedules)(
'renders each on-call schedule data',
({ name, scheduleUrl, projectName, projectUrl }) => {
beforeEach(() => {
createComponent({ schedules: [{ name, scheduleUrl, projectName, projectUrl }] });
});
it(`renders schedule ${name}'s name and link`, () => {
const msg = findSchedules().text();
expect(msg).toContain(`On-call schedule ${name}`);
expect(findLinks().at(0).attributes('href')).toBe(scheduleUrl);
});
it(`renders project ${projectName}'s name and link`, () => {
const msg = findSchedules().text();
expect(msg).toContain(`in Project ${projectName}`);
expect(findLinks().at(1).attributes('href')).toBe(projectUrl);
});
},
);
});
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