Commit de25310c authored by Sarah Yasonik's avatar Sarah Yasonik Committed by Vitaly Slobodin

Warn users of impacted escalation policies on leave/delete

When a user is attempting to leave a namespace or an admin
is attempting to delete a user, we want to let them know
if there are any escalation policies which notify the
target user directly, so they can mitigate any missed
notifications.

Changelog: added
EE: true
parent ce9b3e4f
...@@ -14,7 +14,7 @@ export default { ...@@ -14,7 +14,7 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
oncallSchedules: { userDeletionObstacles: {
type: Array, type: Array,
required: false, required: false,
default: () => [], default: () => [],
...@@ -29,7 +29,7 @@ export default { ...@@ -29,7 +29,7 @@ export default {
:username="username" :username="username"
:paths="paths" :paths="paths"
:delete-path="paths.delete" :delete-path="paths.delete"
:oncall-schedules="oncallSchedules" :user-deletion-obstacles="userDeletionObstacles"
> >
<slot></slot> <slot></slot>
</shared-delete-action> </shared-delete-action>
......
...@@ -14,7 +14,7 @@ export default { ...@@ -14,7 +14,7 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
oncallSchedules: { userDeletionObstacles: {
type: Array, type: Array,
required: false, required: false,
default: () => [], default: () => [],
...@@ -29,7 +29,7 @@ export default { ...@@ -29,7 +29,7 @@ export default {
:username="username" :username="username"
:paths="paths" :paths="paths"
:delete-path="paths.deleteWithContributions" :delete-path="paths.deleteWithContributions"
:oncall-schedules="oncallSchedules" :user-deletion-obstacles="userDeletionObstacles"
> >
<slot></slot> <slot></slot>
</shared-delete-action> </shared-delete-action>
......
...@@ -22,7 +22,7 @@ export default { ...@@ -22,7 +22,7 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
oncallSchedules: { userDeletionObstacles: {
type: Array, type: Array,
required: true, required: true,
}, },
...@@ -34,7 +34,7 @@ export default { ...@@ -34,7 +34,7 @@ export default {
'data-delete-user-url': this.deletePath, 'data-delete-user-url': this.deletePath,
'data-gl-modal-action': this.modalType, 'data-gl-modal-action': this.modalType,
'data-username': this.username, 'data-username': this.username,
'data-oncall-schedules': JSON.stringify(this.oncallSchedules), 'data-user-deletion-obstacles': JSON.stringify(this.userDeletionObstacles),
}; };
}, },
}, },
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
export default { export default {
components: { components: {
...@@ -10,7 +10,7 @@ export default { ...@@ -10,7 +10,7 @@ export default {
GlButton, GlButton,
GlFormInput, GlFormInput,
GlSprintf, GlSprintf,
OncallSchedulesList, UserDeletionObstaclesList,
}, },
props: { props: {
title: { title: {
...@@ -45,7 +45,7 @@ export default { ...@@ -45,7 +45,7 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
oncallSchedules: { userDeletionObstacles: {
type: String, type: String,
required: false, required: false,
default: '[]', default: '[]',
...@@ -66,9 +66,9 @@ export default { ...@@ -66,9 +66,9 @@ export default {
canSubmit() { canSubmit() {
return this.enteredUsername === this.username; return this.enteredUsername === this.username;
}, },
schedules() { obstacles() {
try { try {
return JSON.parse(this.oncallSchedules); return JSON.parse(this.userDeletionObstacles);
} catch (e) { } catch (e) {
Sentry.captureException(e); Sentry.captureException(e);
} }
...@@ -112,7 +112,11 @@ export default { ...@@ -112,7 +112,11 @@ export default {
</gl-sprintf> </gl-sprintf>
</p> </p>
<oncall-schedules-list v-if="schedules.length" :schedules="schedules" :user-name="username" /> <user-deletion-obstacles-list
v-if="obstacles.length"
:obstacles="obstacles"
:user-name="username"
/>
<p> <p>
<gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')"> <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')">
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import { convertArrayToCamelCase } from '~/lib/utils/common_utils'; import { convertArrayToCamelCase } from '~/lib/utils/common_utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import { I18N_USER_ACTIONS } from '../constants'; import { I18N_USER_ACTIONS } from '../constants';
import { generateUserPaths } from '../utils'; import { generateUserPaths } from '../utils';
import Actions from './actions'; import Actions from './actions';
...@@ -72,6 +73,9 @@ export default { ...@@ -72,6 +73,9 @@ export default {
href: this.userPaths.edit, href: this.userPaths.edit,
}; };
}, },
obstaclesForUserDeletion() {
return parseUserDeletionObstacles(this.user);
},
}, },
methods: { methods: {
isLdapAction(action) { isLdapAction(action) {
...@@ -141,7 +145,7 @@ export default { ...@@ -141,7 +145,7 @@ export default {
:key="action" :key="action"
:paths="userPaths" :paths="userPaths"
:username="user.name" :username="user.name"
:oncall-schedules="user.oncallSchedules" :user-deletion-obstacles="obstaclesForUserDeletion"
:data-testid="`delete-${action}`" :data-testid="`delete-${action}`"
> >
{{ $options.i18n[action] }} {{ $options.i18n[action] }}
......
...@@ -42,7 +42,7 @@ export default { ...@@ -42,7 +42,7 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
oncallSchedules: { userDeletionObstacles: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), default: () => ({}),
...@@ -61,7 +61,7 @@ export default { ...@@ -61,7 +61,7 @@ export default {
memberPath: this.memberPath.replace(':id', this.memberId), memberPath: this.memberPath.replace(':id', this.memberId),
memberType: this.memberType, memberType: this.memberType,
message: this.message, message: this.message,
oncallSchedules: this.oncallSchedules, userDeletionObstacles: this.userDeletionObstacles,
}; };
}, },
}, },
......
<script> <script>
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import ActionButtonGroup from './action_button_group.vue'; import ActionButtonGroup from './action_button_group.vue';
import LeaveButton from './leave_button.vue'; import LeaveButton from './leave_button.vue';
import RemoveMemberButton from './remove_member_button.vue'; import RemoveMemberButton from './remove_member_button.vue';
...@@ -49,9 +50,11 @@ export default { ...@@ -49,9 +50,11 @@ export default {
}, },
); );
}, },
oncallScheduleUserData() { userDeletionObstaclesUserData() {
const { user: { name, oncallSchedules: schedules } = {} } = this.member; return {
return { name, schedules }; name: this.member.user?.name,
obstacles: parseUserDeletionObstacles(this.member.user),
};
}, },
}, },
}; };
...@@ -65,7 +68,7 @@ export default { ...@@ -65,7 +68,7 @@ export default {
v-else v-else
:member-id="member.id" :member-id="member.id"
:member-type="member.type" :member-type="member.type"
:oncall-schedules="oncallScheduleUserData" :user-deletion-obstacles="userDeletionObstaclesUserData"
:message="message" :message="message"
:title="s__('Member|Remove member')" :title="s__('Member|Remove member')"
/> />
......
...@@ -3,7 +3,8 @@ import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; ...@@ -3,7 +3,8 @@ import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import csrf from '~/lib/utils/csrf'; import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import { LEAVE_MODAL_ID } from '../../constants'; import { LEAVE_MODAL_ID } from '../../constants';
export default { export default {
...@@ -20,7 +21,7 @@ export default { ...@@ -20,7 +21,7 @@ export default {
csrf, csrf,
modalId: LEAVE_MODAL_ID, modalId: LEAVE_MODAL_ID,
modalContent: s__('Members|Are you sure you want to leave "%{source}"?'), modalContent: s__('Members|Are you sure you want to leave "%{source}"?'),
components: { GlModal, GlForm, GlSprintf, OncallSchedulesList }, components: { GlModal, GlForm, GlSprintf, UserDeletionObstaclesList },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
...@@ -43,11 +44,11 @@ export default { ...@@ -43,11 +44,11 @@ export default {
modalTitle() { modalTitle() {
return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName }); return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName });
}, },
schedules() { obstacles() {
return this.member.user?.oncallSchedules; return parseUserDeletionObstacles(this.member.user);
}, },
isPartOfOnCallSchedules() { hasObstaclesToUserDeletion() {
return this.schedules?.length; return this.obstacles?.length;
}, },
}, },
methods: { methods: {
...@@ -74,9 +75,9 @@ export default { ...@@ -74,9 +75,9 @@ export default {
</gl-sprintf> </gl-sprintf>
</p> </p>
<oncall-schedules-list <user-deletion-obstacles-list
v-if="isPartOfOnCallSchedules" v-if="hasObstaclesToUserDeletion"
:schedules="schedules" :obstacles="obstacles"
:is-current-user="true" :is-current-user="true"
/> />
......
...@@ -3,7 +3,7 @@ import { GlFormCheckbox, GlModal } from '@gitlab/ui'; ...@@ -3,7 +3,7 @@ import { GlFormCheckbox, GlModal } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import csrf from '~/lib/utils/csrf'; import csrf from '~/lib/utils/csrf';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
export default { export default {
actionCancel: { actionCancel: {
...@@ -13,7 +13,7 @@ export default { ...@@ -13,7 +13,7 @@ export default {
components: { components: {
GlFormCheckbox, GlFormCheckbox,
GlModal, GlModal,
OncallSchedulesList, UserDeletionObstaclesList,
}, },
inject: ['namespace'], inject: ['namespace'],
computed: { computed: {
...@@ -33,8 +33,8 @@ export default { ...@@ -33,8 +33,8 @@ export default {
message(state) { message(state) {
return state[this.namespace].removeMemberModalData.message; return state[this.namespace].removeMemberModalData.message;
}, },
oncallSchedules(state) { userDeletionObstacles(state) {
return state[this.namespace].removeMemberModalData.oncallSchedules ?? {}; return state[this.namespace].removeMemberModalData.userDeletionObstacles ?? {};
}, },
removeMemberModalVisible(state) { removeMemberModalVisible(state) {
return state[this.namespace].removeMemberModalVisible; return state[this.namespace].removeMemberModalVisible;
...@@ -60,11 +60,11 @@ export default { ...@@ -60,11 +60,11 @@ export default {
}, },
}; };
}, },
showUnassignIssuablesCheckbox() { hasWorkspaceAccess() {
return !this.isAccessRequest && !this.isInvite; return !this.isAccessRequest && !this.isInvite;
}, },
isPartOfOncallSchedules() { hasObstaclesToUserDeletion() {
return !this.isAccessRequest && this.oncallSchedules.schedules?.length; return this.hasWorkspaceAccess && this.userDeletionObstacles.obstacles?.length;
}, },
}, },
methods: { methods: {
...@@ -95,10 +95,10 @@ export default { ...@@ -95,10 +95,10 @@ export default {
<form ref="form" :action="memberPath" method="post"> <form ref="form" :action="memberPath" method="post">
<p>{{ message }}</p> <p>{{ message }}</p>
<oncall-schedules-list <user-deletion-obstacles-list
v-if="isPartOfOncallSchedules" v-if="hasObstaclesToUserDeletion"
:schedules="oncallSchedules.schedules" :obstacles="userDeletionObstacles.obstacles"
:user-name="oncallSchedules.name" :user-name="userDeletionObstacles.name"
/> />
<input ref="method" type="hidden" name="_method" value="delete" /> <input ref="method" type="hidden" name="_method" value="delete" />
...@@ -106,7 +106,7 @@ export default { ...@@ -106,7 +106,7 @@ export default {
<gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships"> <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships">
{{ __('Also remove direct user membership from subgroups and projects') }} {{ __('Also remove direct user membership from subgroups and projects') }}
</gl-form-checkbox> </gl-form-checkbox>
<gl-form-checkbox v-if="showUnassignIssuablesCheckbox" name="unassign_issuables"> <gl-form-checkbox v-if="hasWorkspaceAccess" name="unassign_issuables">
{{ __('Also unassign this user from related issues and merge requests') }} {{ __('Also unassign this user from related issues and merge requests') }}
</gl-form-checkbox> </gl-form-checkbox>
</form> </form>
......
// Types of obstacles to user deletion
export const OBSTACLE_TYPES = Object.freeze({
oncallSchedules: 'ONCALL_SCHEDULE',
escalationPolicies: 'ESCALATION_POLICY',
});
/* eslint-disable @gitlab/require-i18n-strings */
import { OBSTACLE_TYPES } from './constants';
import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue';
export default {
component: UserDeletionObstaclesList,
title: 'vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list',
};
const Template = (args, { argTypes }) => ({
components: { UserDeletionObstaclesList },
props: Object.keys(argTypes),
template: '<user-deletion-obstacles-list v-bind="$props" v-on="$props" />',
});
export const Default = Template.bind({});
Default.args = {
obstacles: [
{
type: OBSTACLE_TYPES.oncallSchedules,
name: 'APAC',
url: 'https://domain.com/group/main-application/oncall_schedules',
projectName: 'main-application',
projectUrl: 'https://domain.com/group/main-application',
},
{
type: OBSTACLE_TYPES.escalationPolicies,
name: 'Engineering On-call',
url: 'https://domain.com/group/microservice-backend/escalation_policies',
projectName: 'Microservice Backend',
projectUrl: 'https://domain.com/group/microservice-backend',
},
],
userName: 'Thomspon Smith',
isCurrentUser: false,
};
<script> <script>
import { GlSprintf, GlLink } from '@gitlab/ui'; import { GlSprintf, GlLink } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale'; import { sprintf, s__ } from '~/locale';
import { OBSTACLE_TYPES } from './constants';
const OBSTACLE_TEXT = {
[OBSTACLE_TYPES.oncallSchedules]: s__(
'OnCallSchedules|On-call schedule %{obstacle} in Project %{project}',
),
[OBSTACLE_TYPES.escalationPolicies]: s__(
'EscalationPolicies|Escalation policy %{obstacle} in Project %{project}',
),
};
export default { export default {
components: { components: {
...@@ -8,7 +18,7 @@ export default { ...@@ -8,7 +18,7 @@ export default {
GlLink, GlLink,
}, },
props: { props: {
schedules: { obstacles: {
type: Array, type: Array,
required: true, required: true,
}, },
...@@ -45,6 +55,15 @@ export default { ...@@ -45,6 +55,15 @@ export default {
); );
}, },
}, },
methods: {
textForObstacle(obstacle) {
return OBSTACLE_TEXT[obstacle.type];
},
urlForObstacle(obstacle) {
// Fallback to scheduleUrl for backwards compatibility
return obstacle.url || obstacle.scheduleUrl;
},
},
}; };
</script> </script>
...@@ -52,17 +71,15 @@ export default { ...@@ -52,17 +71,15 @@ export default {
<div> <div>
<p data-testid="title">{{ title }}</p> <p data-testid="title">{{ title }}</p>
<ul data-testid="schedules-list"> <ul data-testid="obstacles-list">
<li v-for="(schedule, index) in schedules" :key="`${schedule.name}-${index}`"> <li v-for="(obstacle, index) in obstacles" :key="`${obstacle.name}-${index}`">
<gl-sprintf <gl-sprintf :message="textForObstacle(obstacle)">
:message="s__('OnCallSchedules|On-call schedule %{schedule} in Project %{project}')" <template #obstacle>
> <gl-link :href="urlForObstacle(obstacle)" target="_blank">{{ obstacle.name }}</gl-link>
<template #schedule>
<gl-link :href="schedule.scheduleUrl" target="_blank">{{ schedule.name }}</gl-link>
</template> </template>
<template #project> <template #project>
<gl-link :href="schedule.projectUrl" target="_blank">{{ <gl-link :href="obstacle.projectUrl" target="_blank">{{
schedule.projectName obstacle.projectName
}}</gl-link> }}</gl-link>
</template> </template>
</gl-sprintf> </gl-sprintf>
......
import { OBSTACLE_TYPES } from './constants';
const addTypeToObstacles = (obstacles, type) => {
if (!obstacles) return [];
return obstacles?.map((obstacle) => ({ type, ...obstacle }));
};
// For use with user objects formatted via internal REST API.
// If the removal/deletion of a user could cause critical
// problems, return a single array containing all affected
// associations including their type.
export const parseUserDeletionObstacles = (user) => {
if (!user) return [];
return Object.keys(OBSTACLE_TYPES).flatMap((type) => {
return addTypeToObstacles(user[type], OBSTACLE_TYPES[type]);
});
};
...@@ -21,7 +21,7 @@ module EE ...@@ -21,7 +21,7 @@ module EE
override :users_with_included_associations override :users_with_included_associations
def users_with_included_associations(users) def users_with_included_associations(users)
super.includes(:oncall_schedules) # rubocop: disable CodeReuse/ActiveRecord super.includes(:oncall_schedules, :escalation_policies) # rubocop: disable CodeReuse/ActiveRecord
end end
override :log_impersonation_event override :log_impersonation_event
......
...@@ -9,8 +9,8 @@ module EE ...@@ -9,8 +9,8 @@ module EE
super super
ActiveRecord::Associations::Preloader.new.preload(members, user: { group_saml_identities: :saml_provider }) ActiveRecord::Associations::Preloader.new.preload(members, user: { group_saml_identities: :saml_provider })
ActiveRecord::Associations::Preloader.new.preload(members, user: { oncall_participants: { rotation: :schedule } })
ActiveRecord::Associations::Preloader.new.preload(members, user: :oncall_schedules) ActiveRecord::Associations::Preloader.new.preload(members, user: :oncall_schedules)
ActiveRecord::Associations::Preloader.new.preload(members, user: :escalation_policies)
ActiveRecord::Associations::Preloader.new.preload(members, user: :user_detail) ActiveRecord::Associations::Preloader.new.preload(members, user: :user_detail)
end end
end end
......
...@@ -74,9 +74,11 @@ module EE ...@@ -74,9 +74,11 @@ module EE
has_many :user_permission_export_uploads has_many :user_permission_export_uploads
has_many :oncall_participants, class_name: 'IncidentManagement::OncallParticipant', inverse_of: :user has_many :oncall_participants, -> { not_removed }, class_name: 'IncidentManagement::OncallParticipant', inverse_of: :user
has_many :oncall_rotations, class_name: 'IncidentManagement::OncallRotation', through: :oncall_participants, source: :rotation has_many :oncall_rotations, class_name: 'IncidentManagement::OncallRotation', through: :oncall_participants, source: :rotation
has_many :oncall_schedules, class_name: 'IncidentManagement::OncallSchedule', through: :oncall_rotations, source: :schedule has_many :oncall_schedules, -> { distinct }, class_name: 'IncidentManagement::OncallSchedule', through: :oncall_rotations, source: :schedule
has_many :escalation_rules, -> { not_removed }, class_name: 'IncidentManagement::EscalationRule', inverse_of: :user
has_many :escalation_policies, -> { distinct }, class_name: 'IncidentManagement::EscalationPolicy', through: :escalation_rules, source: :policy
scope :not_managed, ->(group: nil) { scope :not_managed, ->(group: nil) {
scope = where(managing_group_id: nil) scope = where(managing_group_id: nil)
......
...@@ -12,6 +12,10 @@ module IncidentManagement ...@@ -12,6 +12,10 @@ module IncidentManagement
validates :name, presence: true, uniqueness: { scope: [:project_id] }, length: { maximum: 72 } validates :name, presence: true, uniqueness: { scope: [:project_id] }, length: { maximum: 72 }
validates :description, length: { maximum: 160 } validates :description, length: { maximum: 160 }
scope :for_project, -> (project) { where(project: project) }
accepts_nested_attributes_for :rules accepts_nested_attributes_for :rules
delegate :name, to: :project, prefix: true
end end
end end
...@@ -7,10 +7,7 @@ module EE ...@@ -7,10 +7,7 @@ module EE
prepended do prepended do
expose :oncall_schedules, with: ::IncidentManagement::OncallScheduleEntity expose :oncall_schedules, with: ::IncidentManagement::OncallScheduleEntity
expose :escalation_policies, with: ::IncidentManagement::EscalationPolicyEntity
def oncall_schedules
object.oncall_schedules.uniq
end
end end
end end
end end
......
...@@ -3,20 +3,32 @@ ...@@ -3,20 +3,32 @@
module EE module EE
module MemberUserEntity module MemberUserEntity
extend ActiveSupport::Concern extend ActiveSupport::Concern
include ::Gitlab::Utils::StrongMemoize
prepended do prepended do
unexpose :gitlab_employee unexpose :gitlab_employee
unexpose :email unexpose :email
expose :oncall_schedules, with: ::IncidentManagement::OncallScheduleEntity expose :oncall_schedules, with: ::IncidentManagement::OncallScheduleEntity
expose :escalation_policies, with: ::IncidentManagement::EscalationPolicyEntity
# options[:source] is required to scope the schedules
# It should be either a Group or Project
def oncall_schedules def oncall_schedules
return [] unless options[:source].present? object.oncall_schedules.for_project(project_ids)
end
def escalation_policies
object.escalation_policies.for_project(project_ids)
end
end
private
project_ids = options[:source].is_a?(Group) ? options[:source].project_ids : [options[:source].id] # options[:source] is required to scope oncall schedules or policies
# It should be either a Group or Project
def project_ids
strong_memoize(:project_ids) do
next [] unless options[:source].present?
object.oncall_schedules.select { |schedule| project_ids.include?(schedule.project_id) } options[:source].is_a?(Group) ? options[:source].project_ids : [options[:source].id]
end end
end end
end end
......
# frozen_string_literal: true
module IncidentManagement
class EscalationPolicyEntity < Grape::Entity
include ::Gitlab::Routing
expose :name
expose :url do |policy|
project_incident_management_escalation_policies_url(policy.project)
end
expose :project_name
expose :project_url do |policy|
project_url(policy.project)
end
end
end
...@@ -5,11 +5,14 @@ module IncidentManagement ...@@ -5,11 +5,14 @@ module IncidentManagement
include Gitlab::Routing include Gitlab::Routing
expose :name expose :name
expose :project_name expose :schedule_url do |schedule| # for backwards compatibility
expose :schedule_url do |schedule| project_incident_management_oncall_schedules_url(schedule.project)
end
expose :url do |schedule|
project_incident_management_oncall_schedules_url(schedule.project) project_incident_management_oncall_schedules_url(schedule.project)
end end
expose :project_name
expose :project_url do |schedule| expose :project_url do |schedule|
project_url(schedule.project) project_url(schedule.project)
end end
......
...@@ -10,6 +10,15 @@ RSpec.describe Admin::UsersController do ...@@ -10,6 +10,15 @@ RSpec.describe Admin::UsersController do
sign_in(admin) sign_in(admin)
end end
describe 'GET #index' do
it 'eager loads obstacles to user deletion' do
get :index
expect(assigns(:users).first.association(:oncall_schedules)).to be_loaded
expect(assigns(:users).first.association(:escalation_policies)).to be_loaded
end
end
describe 'POST update' do describe 'POST update' do
context 'updating name' do context 'updating name' do
shared_examples_for 'admin can update the name of a user' do shared_examples_for 'admin can update the name of a user' do
......
...@@ -3,25 +3,44 @@ ...@@ -3,25 +3,44 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe EE::MembersPreloader do RSpec.describe EE::MembersPreloader do
include OncallHelpers
describe '#preload_all' do describe '#preload_all' do
let(:group) { create(:group) } let_it_be(:group) { create(:group) }
let(:saml_provider) { create(:saml_provider, group: group) } let_it_be(:project) { create(:project, group: group) }
let_it_be(:saml_provider) { create(:saml_provider, group: group) }
let_it_be(:escalation_policy) { create(:incident_management_escalation_policy, project: project, rule_count: 0) }
it 'preloads associations to avoid N+1 queries' do
member = create(:group_member, group: group)
create_member_associations(member)
control = ActiveRecord::QueryRecorder.new { access_group_with_preload([member]) }
def group_sso_with_preload(members) members = create_list(:group_member, 3, group: group)
create_member_associations(members.first)
create_member_associations(members.last)
expect { access_group_with_preload(members) }.not_to exceed_query_limit(control)
end
def access_group_with_preload(members)
MembersPreloader.new(members).preload_all MembersPreloader.new(members).preload_all
MembersPresenter.new(members, current_user: nil).map(&:group_sso?) MembersPresenter.new(members, current_user: nil).map(&:group_sso?)
members.each do |member|
member.user.oncall_schedules.any?
member.user.escalation_policies.any?
member.user.user_detail
end
end end
it 'preloads SAML identities to avoid N+1 queries in MembersPresenter' do def create_member_associations(member)
member = create(:group_member, group: group)
create(:group_saml_identity, user: member.user, saml_provider: saml_provider) create(:group_saml_identity, user: member.user, saml_provider: saml_provider)
control = ActiveRecord::QueryRecorder.new { group_sso_with_preload([member]) } create_schedule_with_user(project, member.user)
create(:incident_management_escalation_rule, :with_user, policy: escalation_policy, user: member.user)
members = create_list(:group_member, 3, group: group) member.user.user_detail.save!
create(:group_saml_identity, user: members.first.user, saml_provider: saml_provider) member.reload
create(:group_saml_identity, user: members.last.user, saml_provider: saml_provider)
expect { group_sso_with_preload(members) }.not_to exceed_query_limit(control)
end end
end end
end end
...@@ -31,6 +31,8 @@ RSpec.describe User do ...@@ -31,6 +31,8 @@ RSpec.describe User do
it { is_expected.to have_many(:oncall_participants).class_name('IncidentManagement::OncallParticipant') } it { is_expected.to have_many(:oncall_participants).class_name('IncidentManagement::OncallParticipant') }
it { is_expected.to have_many(:oncall_rotations).class_name('IncidentManagement::OncallRotation').through(:oncall_participants) } it { is_expected.to have_many(:oncall_rotations).class_name('IncidentManagement::OncallRotation').through(:oncall_participants) }
it { is_expected.to have_many(:oncall_schedules).class_name('IncidentManagement::OncallSchedule').through(:oncall_rotations) } it { is_expected.to have_many(:oncall_schedules).class_name('IncidentManagement::OncallSchedule').through(:oncall_rotations) }
it { is_expected.to have_many(:escalation_rules).class_name('IncidentManagement::EscalationRule') }
it { is_expected.to have_many(:escalation_policies).class_name('IncidentManagement::EscalationPolicy').through(:escalation_rules) }
it { is_expected.to have_many(:epic_board_recent_visits).inverse_of(:user) } it { is_expected.to have_many(:epic_board_recent_visits).inverse_of(:user) }
end end
...@@ -1841,4 +1843,42 @@ RSpec.describe User do ...@@ -1841,4 +1843,42 @@ RSpec.describe User do
is_expected.to be(false) is_expected.to be(false)
end end
end end
describe '.oncall_schedules' do
let_it_be(:user) { create(:user) }
let_it_be(:participant, reload: true) { create(:incident_management_oncall_participant, user: user) }
let_it_be(:schedule, reload: true) { participant.rotation.schedule }
it 'excludes removed participants' do
participant.update!(is_removed: true)
expect(user.oncall_schedules).to be_empty
end
it 'excludes duplicates' do
create(:incident_management_oncall_rotation, schedule: schedule) do |rotation|
create(:incident_management_oncall_participant, user: user, rotation: rotation)
end
expect(user.oncall_schedules).to contain_exactly(schedule)
end
end
describe '.escalation_policies' do
let_it_be(:rule, reload: true) { create(:incident_management_escalation_rule, :with_user) }
let_it_be(:policy, reload: true) { rule.policy }
let_it_be(:user) { rule.user }
it 'excludes removed rules' do
rule.update!(is_removed: true)
expect(user.escalation_policies).to be_empty
end
it 'excludes duplicates' do
create(:incident_management_escalation_rule, :with_user, :resolved, policy: policy, user: user)
expect(user.escalation_policies).to contain_exactly(policy)
end
end
end end
...@@ -30,4 +30,20 @@ RSpec.describe IncidentManagement::EscalationPolicy do ...@@ -30,4 +30,20 @@ RSpec.describe IncidentManagement::EscalationPolicy do
it { is_expected.to validate_length_of(:name).is_at_most(72) } it { is_expected.to validate_length_of(:name).is_at_most(72) }
it { is_expected.to validate_length_of(:description).is_at_most(160) } it { is_expected.to validate_length_of(:description).is_at_most(160) }
end end
describe 'delegations' do
it { is_expected.to delegate_method(:name).to(:project).with_prefix }
end
describe 'scopes' do
let_it_be(:project) { create(:project) }
let_it_be(:policy) { create(:incident_management_escalation_policy, project: project) }
let_it_be(:other_policy) { create(:incident_management_escalation_policy) }
describe '.for_project' do
subject { described_class.for_project(project) }
it { is_expected.to contain_exactly(policy) }
end
end
end end
...@@ -15,7 +15,7 @@ RSpec.describe Admin::UserEntity do ...@@ -15,7 +15,7 @@ RSpec.describe Admin::UserEntity do
subject { entity.as_json&.keys } subject { entity.as_json&.keys }
it 'exposes correct attributes' do it 'exposes correct attributes' do
is_expected.to include(:oncall_schedules) is_expected.to include(:oncall_schedules, :escalation_policies)
end end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe IncidentManagement::EscalationPolicyEntity do
let(:policy) { create(:incident_management_escalation_policy) }
let(:url) { Gitlab::Routing.url_helpers.project_incident_management_escalation_policies_url(policy.project) }
let(:project_url) { Gitlab::Routing.url_helpers.project_url(policy.project) }
subject { described_class.new(policy) }
describe '.as_json' do
it 'includes escalation policy attributes' do
attributes = subject.as_json
expect(attributes[:name]).to eq(policy.name)
expect(attributes[:url]).to eq(url)
expect(attributes[:project_name]).to eq(policy.project.name)
expect(attributes[:project_url]).to eq(project_url)
end
end
end
...@@ -15,7 +15,7 @@ RSpec.describe IncidentManagement::OncallScheduleEntity do ...@@ -15,7 +15,7 @@ RSpec.describe IncidentManagement::OncallScheduleEntity do
expect(attributes[:name]).to eq(schedule.name) expect(attributes[:name]).to eq(schedule.name)
expect(attributes[:project_name]).to eq(schedule.project.name) expect(attributes[:project_name]).to eq(schedule.project.name)
expect(attributes[:schedule_url]).to eq(schedule_url) expect(attributes[:url]).to eq(schedule_url)
expect(attributes[:project_url]).to eq(project_url) expect(attributes[:project_url]).to eq(project_url)
end end
end end
......
...@@ -15,44 +15,84 @@ RSpec.describe MemberUserEntity do ...@@ -15,44 +15,84 @@ RSpec.describe MemberUserEntity do
expect(entity.to_json).to match_schema('entities/member_user') expect(entity.to_json).to match_schema('entities/member_user')
end end
context 'with oncall schedules' do context 'when using on-call management' do
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:project_1) { create(:project, group: group )} let_it_be(:project_1) { create(:project, group: group )}
let_it_be(:project_2) { create(:project, group: group )} let_it_be(:project_2) { create(:project, group: group )}
let_it_be(:oncall_schedule_1) { create_schedule_with_user(project_1, user) } context 'with oncall schedules' do
let_it_be(:oncall_schedule_2) { create_schedule_with_user(project_2, user) } let_it_be(:oncall_schedule_1) { create_schedule_with_user(project_1, user) }
let_it_be(:oncall_schedule_2) { create_schedule_with_user(project_2, user) }
it 'returns an empty array if no source option is given' do subject { entity_hash[:oncall_schedules] }
expect(entity_hash[:oncall_schedules]).to eq []
end context 'with no source given' do
it { is_expected.to eq [] }
end
context 'source is project' do
let(:source) { project_1 }
it { is_expected.to contain_exactly(expected_hash(oncall_schedule_1)) }
end
context 'source is project' do context 'source is group' do
let(:source) { project_1 } let(:source) { group }
it { is_expected.to contain_exactly(expected_hash(oncall_schedule_1), expected_hash(oncall_schedule_2)) }
end
private
def get_url(schedule)
Gitlab::Routing.url_helpers.project_incident_management_oncall_schedules_url(schedule.project)
end
it 'correctly exposes `oncall_schedules`' do def expected_hash(schedule)
expect(entity_hash[:oncall_schedules]).to contain_exactly(schedule_hash(oncall_schedule_1)) # for backwards compatibility
super.merge(schedule_url: get_url(schedule))
end end
end end
context 'source is group' do context 'with escalation policies' do
let(:source) { group } let_it_be(:policy_1) { create(:incident_management_escalation_policy, project: project_1, rule_count: 0) }
let_it_be(:rule_1) { create(:incident_management_escalation_rule, :with_user, policy: policy_1, user: user) }
let_it_be(:policy_2) { create(:incident_management_escalation_policy, project: project_2, rule_count: 0) }
let_it_be(:rule_2) { create(:incident_management_escalation_rule, :with_user, policy: policy_2, user: user) }
subject { entity_hash[:escalation_policies] }
context 'with no source given' do
it { is_expected.to eq [] }
end
context 'source is project' do
let(:source) { project_1 }
it { is_expected.to contain_exactly(expected_hash(policy_1)) }
end
context 'source is group' do
let(:source) { group }
it { is_expected.to contain_exactly(expected_hash(policy_1), expected_hash(policy_2)) }
end
private
it 'correctly exposes `oncall_schedules`' do def get_url(policy)
expect(entity_hash[:oncall_schedules]).to contain_exactly(schedule_hash(oncall_schedule_1), schedule_hash(oncall_schedule_2)) Gitlab::Routing.url_helpers.project_incident_management_escalation_policies_url(policy.project)
end end
end end
private private
def schedule_hash(schedule) def expected_hash(oncall_object)
schedule_url = Gitlab::Routing.url_helpers.project_incident_management_oncall_schedules_url(schedule.project)
project_url = Gitlab::Routing.url_helpers.project_url(schedule.project)
{ {
name: schedule.name, name: oncall_object.name,
project_name: schedule.project.name, url: get_url(oncall_object),
schedule_url: schedule_url, project_name: oncall_object.project.name,
project_url: project_url project_url: Gitlab::Routing.url_helpers.project_url(oncall_object.project)
} }
end end
end end
......
...@@ -13531,6 +13531,9 @@ msgstr "" ...@@ -13531,6 +13531,9 @@ msgstr ""
msgid "EscalationPolicies|Escalation policies" msgid "EscalationPolicies|Escalation policies"
msgstr "" msgstr ""
msgid "EscalationPolicies|Escalation policy %{obstacle} in Project %{project}"
msgstr ""
msgid "EscalationPolicies|Escalation rules" msgid "EscalationPolicies|Escalation rules"
msgstr "" msgstr ""
...@@ -23534,7 +23537,7 @@ msgstr "" ...@@ -23534,7 +23537,7 @@ msgstr ""
msgid "OnCallSchedules|For this rotation, on-call will be:" msgid "OnCallSchedules|For this rotation, on-call will be:"
msgstr "" msgstr ""
msgid "OnCallSchedules|On-call schedule %{schedule} in Project %{project}" msgid "OnCallSchedules|On-call schedule %{obstacle} in Project %{project}"
msgstr "" msgstr ""
msgid "OnCallSchedules|On-call schedules" msgid "OnCallSchedules|On-call schedules"
......
...@@ -5,6 +5,7 @@ import { nextTick } from 'vue'; ...@@ -5,6 +5,7 @@ import { nextTick } from 'vue';
import Actions from '~/admin/users/components/actions'; import Actions from '~/admin/users/components/actions';
import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue'; import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants';
import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants'; import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants';
import { paths } from '../../mock_data'; import { paths } from '../../mock_data';
...@@ -46,7 +47,10 @@ describe('Action components', () => { ...@@ -46,7 +47,10 @@ describe('Action components', () => {
}); });
describe('DELETE_ACTION_COMPONENTS', () => { describe('DELETE_ACTION_COMPONENTS', () => {
const oncallSchedules = [{ name: 'schedule1' }, { name: 'schedule2' }]; const userDeletionObstacles = [
{ name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules },
{ name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies },
];
it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))( it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))(
'renders a dropdown item for "%s"', 'renders a dropdown item for "%s"',
...@@ -56,7 +60,7 @@ describe('Action components', () => { ...@@ -56,7 +60,7 @@ describe('Action components', () => {
props: { props: {
username: 'John Doe', username: 'John Doe',
paths, paths,
oncallSchedules, userDeletionObstacles,
}, },
stubs: { SharedDeleteAction }, stubs: { SharedDeleteAction },
}); });
...@@ -69,8 +73,8 @@ describe('Action components', () => { ...@@ -69,8 +73,8 @@ describe('Action components', () => {
expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath); expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath);
expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action)); expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action));
expect(sharedAction.attributes('data-username')).toBe('John Doe'); expect(sharedAction.attributes('data-username')).toBe('John Doe');
expect(sharedAction.attributes('data-oncall-schedules')).toBe( expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe(
JSON.stringify(oncallSchedules), JSON.stringify(userDeletionObstacles),
); );
expect(findDropdownItem().exists()).toBe(true); expect(findDropdownItem().exists()).toBe(true);
}, },
......
...@@ -8,8 +8,8 @@ exports[`User Operation confirmation modal renders modal with form included 1`] ...@@ -8,8 +8,8 @@ exports[`User Operation confirmation modal renders modal with form included 1`]
/> />
</p> </p>
<oncall-schedules-list-stub <user-deletion-obstacles-list-stub
schedules="schedule1,schedule2" obstacles="schedule1,policy1"
username="username" username="username"
/> />
......
import { GlButton, GlFormInput } from '@gitlab/ui'; import { GlButton, GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue'; import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
import ModalStub from './stubs/modal_stub'; import ModalStub from './stubs/modal_stub';
const TEST_DELETE_USER_URL = 'delete-url'; const TEST_DELETE_USER_URL = 'delete-url';
...@@ -25,7 +25,7 @@ describe('User Operation confirmation modal', () => { ...@@ -25,7 +25,7 @@ describe('User Operation confirmation modal', () => {
const getUsername = () => findUsernameInput().attributes('value'); const getUsername = () => findUsernameInput().attributes('value');
const getMethodParam = () => new FormData(findForm().element).get('_method'); const getMethodParam = () => new FormData(findForm().element).get('_method');
const getFormAction = () => findForm().attributes('action'); const getFormAction = () => findForm().attributes('action');
const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList); const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList);
const setUsername = (username) => { const setUsername = (username) => {
findUsernameInput().vm.$emit('input', username); findUsernameInput().vm.$emit('input', username);
...@@ -33,7 +33,7 @@ describe('User Operation confirmation modal', () => { ...@@ -33,7 +33,7 @@ describe('User Operation confirmation modal', () => {
const username = 'username'; const username = 'username';
const badUsername = 'bad_username'; const badUsername = 'bad_username';
const oncallSchedules = '["schedule1", "schedule2"]'; const userDeletionObstacles = '["schedule1", "policy1"]';
const createComponent = (props = {}) => { const createComponent = (props = {}) => {
wrapper = shallowMount(DeleteUserModal, { wrapper = shallowMount(DeleteUserModal, {
...@@ -46,7 +46,7 @@ describe('User Operation confirmation modal', () => { ...@@ -46,7 +46,7 @@ describe('User Operation confirmation modal', () => {
deleteUserUrl: TEST_DELETE_USER_URL, deleteUserUrl: TEST_DELETE_USER_URL,
blockUserUrl: TEST_BLOCK_USER_URL, blockUserUrl: TEST_BLOCK_USER_URL,
csrfToken: TEST_CSRF, csrfToken: TEST_CSRF,
oncallSchedules, userDeletionObstacles,
...props, ...props,
}, },
stubs: { stubs: {
...@@ -150,18 +150,18 @@ describe('User Operation confirmation modal', () => { ...@@ -150,18 +150,18 @@ describe('User Operation confirmation modal', () => {
}); });
}); });
describe('Related oncall-schedules list', () => { describe('Related user-deletion-obstacles list', () => {
it('does NOT render the list when user has no related schedules', () => { it('does NOT render the list when user has no related obstacles', () => {
createComponent({ oncallSchedules: '[]' }); createComponent({ userDeletionObstacles: '[]' });
expect(findOnCallSchedulesList().exists()).toBe(false); expect(findUserDeletionObstaclesList().exists()).toBe(false);
}); });
it('renders the list when user has related schedules', () => { it('renders the list when user has related obstalces', () => {
createComponent(); createComponent();
const schedules = findOnCallSchedulesList(); const obstacles = findUserDeletionObstaclesList();
expect(schedules.exists()).toBe(true); expect(obstacles.exists()).toBe(true);
expect(schedules.props('schedules')).toEqual(JSON.parse(oncallSchedules)); expect(obstacles.props('obstacles')).toEqual(JSON.parse(userDeletionObstacles));
}); });
}); });
}); });
...@@ -45,7 +45,7 @@ describe('RemoveMemberButton', () => { ...@@ -45,7 +45,7 @@ describe('RemoveMemberButton', () => {
title: 'Remove member', title: 'Remove member',
isAccessRequest: true, isAccessRequest: true,
isInvite: true, isInvite: true,
oncallSchedules: { name: 'user', schedules: [] }, userDeletionObstacles: { name: 'user', obstacles: [] },
...propsData, ...propsData,
}, },
directives: { directives: {
......
...@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import LeaveButton from '~/members/components/action_buttons/leave_button.vue'; import LeaveButton from '~/members/components/action_buttons/leave_button.vue';
import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue';
import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue'; import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import { member, orphanedMember } from '../../mock_data'; import { member, orphanedMember } from '../../mock_data';
describe('UserActionButtons', () => { describe('UserActionButtons', () => {
...@@ -45,9 +46,9 @@ describe('UserActionButtons', () => { ...@@ -45,9 +46,9 @@ describe('UserActionButtons', () => {
isAccessRequest: false, isAccessRequest: false,
isInvite: false, isInvite: false,
icon: 'remove', icon: 'remove',
oncallSchedules: { userDeletionObstacles: {
name: member.user.name, name: member.user.name,
schedules: member.user.oncallSchedules, obstacles: parseUserDeletionObstacles(member.user),
}, },
}); });
}); });
......
...@@ -6,7 +6,8 @@ import { nextTick } from 'vue'; ...@@ -6,7 +6,8 @@ import { nextTick } from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import LeaveModal from '~/members/components/modals/leave_modal.vue'; import LeaveModal from '~/members/components/modals/leave_modal.vue';
import { LEAVE_MODAL_ID, MEMBER_TYPES } from '~/members/constants'; import { LEAVE_MODAL_ID, MEMBER_TYPES } from '~/members/constants';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import { member } from '../../mock_data'; import { member } from '../../mock_data';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
...@@ -51,7 +52,7 @@ describe('LeaveModal', () => { ...@@ -51,7 +52,7 @@ describe('LeaveModal', () => {
const findModal = () => wrapper.findComponent(GlModal); const findModal = () => wrapper.findComponent(GlModal);
const findForm = () => findModal().findComponent(GlForm); const findForm = () => findModal().findComponent(GlForm);
const findOncallSchedulesList = () => findModal().findComponent(OncallSchedulesList); const findUserDeletionObstaclesList = () => findModal().findComponent(UserDeletionObstaclesList);
const getByText = (text, options) => const getByText = (text, options) =>
createWrapper(within(findModal().element).getByText(text, options)); createWrapper(within(findModal().element).getByText(text, options));
...@@ -89,25 +90,27 @@ describe('LeaveModal', () => { ...@@ -89,25 +90,27 @@ describe('LeaveModal', () => {
); );
}); });
describe('On-call schedules list', () => { describe('User deletion obstacles list', () => {
it("displays oncall schedules list when member's user is part of on-call schedules ", () => { it("displays obstacles list when member's user is part of on-call management", () => {
const schedulesList = findOncallSchedulesList(); const obstaclesList = findUserDeletionObstaclesList();
expect(schedulesList.exists()).toBe(true); expect(obstaclesList.exists()).toBe(true);
expect(schedulesList.props()).toMatchObject({ expect(obstaclesList.props()).toMatchObject({
isCurrentUser: true, isCurrentUser: true,
schedules: member.user.oncallSchedules, obstacles: parseUserDeletionObstacles(member.user),
}); });
}); });
it("does NOT display oncall schedules list when member's user is NOT a part of on-call schedules ", async () => { it("does NOT display obstacles list when member's user is NOT a part of on-call management", async () => {
wrapper.destroy(); wrapper.destroy();
const memberWithoutOncallSchedules = cloneDeep(member); const memberWithoutOncall = cloneDeep(member);
delete memberWithoutOncallSchedules.user.oncallSchedules; delete memberWithoutOncall.user.oncallSchedules;
createComponent({ member: memberWithoutOncallSchedules }); delete memberWithoutOncall.user.escalationPolicies;
createComponent({ member: memberWithoutOncall });
await nextTick(); await nextTick();
expect(findOncallSchedulesList().exists()).toBe(false); expect(findUserDeletionObstaclesList().exists()).toBe(false);
}); });
}); });
......
...@@ -4,15 +4,19 @@ import Vue from 'vue'; ...@@ -4,15 +4,19 @@ import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import RemoveMemberModal from '~/members/components/modals/remove_member_modal.vue'; import RemoveMemberModal from '~/members/components/modals/remove_member_modal.vue';
import { MEMBER_TYPES } from '~/members/constants'; import { MEMBER_TYPES } from '~/members/constants';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
Vue.use(Vuex); Vue.use(Vuex);
describe('RemoveMemberModal', () => { describe('RemoveMemberModal', () => {
const memberPath = '/gitlab-org/gitlab-test/-/project_members/90'; const memberPath = '/gitlab-org/gitlab-test/-/project_members/90';
const mockSchedules = { const mockObstacles = {
name: 'User1', name: 'User1',
schedules: [{ id: 1, name: 'Schedule 1' }], obstacles: [
{ name: 'Schedule 1', type: OBSTACLE_TYPES.oncallSchedules },
{ name: 'Policy 1', type: OBSTACLE_TYPES.escalationPolicies },
],
}; };
let wrapper; let wrapper;
...@@ -44,18 +48,18 @@ describe('RemoveMemberModal', () => { ...@@ -44,18 +48,18 @@ describe('RemoveMemberModal', () => {
const findForm = () => wrapper.find({ ref: 'form' }); const findForm = () => wrapper.find({ ref: 'form' });
const findGlModal = () => wrapper.findComponent(GlModal); const findGlModal = () => wrapper.findComponent(GlModal);
const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList); const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
describe.each` describe.each`
state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | userDeletionObstacles | isPartOfOncall
${'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 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?'} | ${{}} | ${false}
${'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} ${'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?'} | ${mockObstacles} | ${true}
${'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?"} | ${{}} ${'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?"} | ${{}} | ${false}
${'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} ${'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?'} | ${mockObstacles} | ${false}
`( `(
'when $state', 'when $state',
({ ({
...@@ -66,7 +70,8 @@ describe('RemoveMemberModal', () => { ...@@ -66,7 +70,8 @@ describe('RemoveMemberModal', () => {
message, message,
removeSubMembershipsCheckboxExpected, removeSubMembershipsCheckboxExpected,
unassignIssuablesCheckboxExpected, unassignIssuablesCheckboxExpected,
onCallSchedules, userDeletionObstacles,
isPartOfOncall,
}) => { }) => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
...@@ -75,12 +80,10 @@ describe('RemoveMemberModal', () => { ...@@ -75,12 +80,10 @@ describe('RemoveMemberModal', () => {
message, message,
memberPath, memberPath,
memberType, memberType,
onCallSchedules, userDeletionObstacles,
}); });
}); });
const isPartOfOncallSchedules = Boolean(isAccessRequest && onCallSchedules.schedules?.length);
it(`has the title ${actionText}`, () => { it(`has the title ${actionText}`, () => {
expect(findGlModal().attributes('title')).toBe(actionText); expect(findGlModal().attributes('title')).toBe(actionText);
}); });
...@@ -109,8 +112,8 @@ describe('RemoveMemberModal', () => { ...@@ -109,8 +112,8 @@ describe('RemoveMemberModal', () => {
); );
}); });
it(`shows ${isPartOfOncallSchedules ? 'all' : 'no'} related on-call schedules`, () => { it(`shows ${isPartOfOncall ? 'all' : 'no'} related on-call schedules or policies`, () => {
expect(findOnCallSchedulesList().exists()).toBe(isPartOfOncallSchedules); expect(findUserDeletionObstaclesList().exists()).toBe(isPartOfOncall);
}); });
it('submits the form when the modal is submitted', () => { it('submits the form when the modal is submitted', () => {
......
...@@ -23,6 +23,7 @@ export const member = { ...@@ -23,6 +23,7 @@ export const member = {
blocked: false, blocked: false,
twoFactorEnabled: false, twoFactorEnabled: false,
oncallSchedules: [{ name: 'schedule 1' }], oncallSchedules: [{ name: 'schedule 1' }],
escalationPolicies: [{ name: 'policy 1' }],
}, },
id: 238, id: 238,
createdAt: '2020-07-17T16:22:46.923Z', createdAt: '2020-07-17T16:22:46.923Z',
...@@ -63,7 +64,7 @@ export const modalData = { ...@@ -63,7 +64,7 @@ export const modalData = {
memberPath: '/groups/foo-bar/-/group_members/1', memberPath: '/groups/foo-bar/-/group_members/1',
memberType: 'GroupMember', memberType: 'GroupMember',
message: 'Are you sure you want to remove John Smith?', message: 'Are you sure you want to remove John Smith?',
oncallSchedules: { name: 'user', schedules: [] }, userDeletionObstacles: { name: 'user', obstacles: [] },
}; };
const { user, ...memberNoUser } = member; const { user, ...memberNoUser } = member;
......
import { GlLink, GlSprintf } from '@gitlab/ui'; import { GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
const mockSchedules = [ const mockSchedules = [
{ {
type: OBSTACLE_TYPES.oncallSchedules,
name: 'Schedule 1', name: 'Schedule 1',
scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/-/oncall_schedules', url: 'http://gitlab.com/gitlab-org/gitlab-shell/-/oncall_schedules',
projectName: 'Shell', projectName: 'Shell',
projectUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/', projectUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/',
}, },
{ {
type: OBSTACLE_TYPES.oncallSchedules,
name: 'Schedule 2', name: 'Schedule 2',
scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/-/oncall_schedules', url: 'http://gitlab.com/gitlab-org/gitlab-ui/-/oncall_schedules',
projectName: 'UI', projectName: 'UI',
projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/', projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/',
}, },
]; ];
const mockPolicies = [
{
type: OBSTACLE_TYPES.escalationPolicies,
name: 'Policy 1',
url: 'http://gitlab.com/gitlab-org/gitlab-ui/-/escalation-policies',
projectName: 'UI',
projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/',
},
];
const mockObstacles = mockSchedules.concat(mockPolicies);
const userName = "O'User"; const userName = "O'User";
describe('On-call schedules list', () => { describe('User deletion obstacles list', () => {
let wrapper; let wrapper;
function createComponent(props) { function createComponent(props) {
wrapper = extendedWrapper( wrapper = extendedWrapper(
shallowMount(OncallSchedulesList, { shallowMount(UserDeletionObstaclesList, {
propsData: { propsData: {
schedules: mockSchedules, obstacles: mockObstacles,
userName, userName,
...props, ...props,
}, },
...@@ -45,14 +58,14 @@ describe('On-call schedules list', () => { ...@@ -45,14 +58,14 @@ describe('On-call schedules list', () => {
const findLinks = () => wrapper.findAllComponents(GlLink); const findLinks = () => wrapper.findAllComponents(GlLink);
const findTitle = () => wrapper.findByTestId('title'); const findTitle = () => wrapper.findByTestId('title');
const findFooter = () => wrapper.findByTestId('footer'); const findFooter = () => wrapper.findByTestId('footer');
const findSchedules = () => wrapper.findByTestId('schedules-list'); const findObstacles = () => wrapper.findByTestId('obstacles-list');
describe.each` describe.each`
isCurrentUser | titleText | footerText isCurrentUser | titleText | footerText
${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'} ${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.'} ${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 }) => { `('when current user', ({ isCurrentUser, titleText, footerText }) => {
it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call schedule`, async () => { it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call management`, async () => {
createComponent({ createComponent({
isCurrentUser, isCurrentUser,
}); });
...@@ -62,25 +75,41 @@ describe('On-call schedules list', () => { ...@@ -62,25 +75,41 @@ describe('On-call schedules list', () => {
}); });
}); });
describe.each(mockSchedules)( describe.each(mockObstacles)(
'renders each on-call schedule data', 'renders all obstacles',
({ name, scheduleUrl, projectName, projectUrl }) => { ({ type, name, url, projectName, projectUrl }) => {
beforeEach(() => { it(`includes the project name and link for ${name}`, () => {
createComponent({ schedules: [{ name, scheduleUrl, projectName, projectUrl }] }); createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] });
const msg = findObstacles().text();
expect(msg).toContain(`in Project ${projectName}`);
expect(findLinks().at(1).attributes('href')).toBe(projectUrl);
}); });
},
);
it(`renders schedule ${name}'s name and link`, () => { describe.each(mockSchedules)(
const msg = findSchedules().text(); 'renders on-call schedules',
({ type, name, url, projectName, projectUrl }) => {
it(`includes the schedule name and link for ${name}`, () => {
createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] });
const msg = findObstacles().text();
expect(msg).toContain(`On-call schedule ${name}`); expect(msg).toContain(`On-call schedule ${name}`);
expect(findLinks().at(0).attributes('href')).toBe(scheduleUrl); expect(findLinks().at(0).attributes('href')).toBe(url);
}); });
},
);
it(`renders project ${projectName}'s name and link`, () => { describe.each(mockPolicies)(
const msg = findSchedules().text(); 'renders escalation policies',
({ type, name, url, projectName, projectUrl }) => {
it(`includes the policy name and link for ${name}`, () => {
createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] });
const msg = findObstacles().text();
expect(msg).toContain(`in Project ${projectName}`); expect(msg).toContain(`Escalation policy ${name}`);
expect(findLinks().at(1).attributes('href')).toBe(projectUrl); expect(findLinks().at(0).attributes('href')).toBe(url);
}); });
}, },
); );
......
import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
describe('parseUserDeletionObstacles', () => {
const mockObstacles = [{ name: 'Obstacle' }];
const expectedSchedule = { name: 'Obstacle', type: OBSTACLE_TYPES.oncallSchedules };
const expectedPolicy = { name: 'Obstacle', type: OBSTACLE_TYPES.escalationPolicies };
it('is undefined when user is not available', () => {
expect(parseUserDeletionObstacles()).toHaveLength(0);
});
it('is empty when obstacles are not available for user', () => {
expect(parseUserDeletionObstacles({})).toHaveLength(0);
});
it('is empty when user has no obstacles to deletion', () => {
const input = { oncallSchedules: [], escalationPolicies: [] };
expect(parseUserDeletionObstacles(input)).toHaveLength(0);
});
it('returns obstacles with type when user is part of on-call schedules', () => {
const input = { oncallSchedules: mockObstacles, escalationPolicies: [] };
const expectedOutput = [expectedSchedule];
expect(parseUserDeletionObstacles(input)).toEqual(expectedOutput);
});
it('returns obstacles with type when user is part of escalation policies', () => {
const input = { oncallSchedules: [], escalationPolicies: mockObstacles };
const expectedOutput = [expectedPolicy];
expect(parseUserDeletionObstacles(input)).toEqual(expectedOutput);
});
it('returns obstacles with type when user have every obstacle type', () => {
const input = { oncallSchedules: mockObstacles, escalationPolicies: mockObstacles };
const expectedOutput = [expectedSchedule, expectedPolicy];
expect(parseUserDeletionObstacles(input)).toEqual(expectedOutput);
});
});
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