Commit a67734ba authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '330648-email-specific-user-in-escalation-policy' into 'master'

Add option to email user in escalation policy

See merge request gitlab-org/gitlab!64844
parents 2138c946 1354c674
......@@ -3,7 +3,12 @@ import { GlLink, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { cloneDeep, uniqueId } from 'lodash';
import createFlash from '~/flash';
import { s__, __ } from '~/locale';
import { DEFAULT_ACTION, DEFAULT_ESCALATION_RULE, MAX_RULES_LENGTH } from '../constants';
import {
EMAIL_ONCALL_SCHEDULE_USER,
DEFAULT_ESCALATION_RULE,
EMAIL_USER,
MAX_RULES_LENGTH,
} from '../constants';
import getOncallSchedulesQuery from '../graphql/queries/get_oncall_schedules.query.graphql';
import EscalationRule from './escalation_rule.vue';
......@@ -78,17 +83,14 @@ export default {
},
mounted() {
this.rules = this.form.rules.map((rule) => {
const {
status,
elapsedTimeMinutes,
oncallSchedule: { iid: oncallScheduleIid },
} = rule;
const { status, elapsedTimeMinutes, oncallSchedule, user } = rule;
return {
status,
elapsedTimeMinutes,
action: DEFAULT_ACTION,
oncallScheduleIid,
action: user ? EMAIL_USER : EMAIL_ONCALL_SCHEDULE_USER,
oncallScheduleIid: oncallSchedule?.iid,
username: user?.username,
key: uniqueId(),
};
});
......@@ -102,7 +104,8 @@ export default {
this.rules.push({ ...cloneDeep(DEFAULT_ESCALATION_RULE), key: uniqueId() });
},
updateEscalationRules({ rule, index }) {
this.rules[index] = { ...this.rules[index], ...rule };
const { key } = this.rules[index];
this.rules[index] = { key, ...rule };
this.emitRulesUpdate();
},
removeEscalationRule(index) {
......
......@@ -9,7 +9,7 @@ import {
import createEscalationPolicyMutation from '../graphql/mutations/create_escalation_policy.mutation.graphql';
import updateEscalationPolicyMutation from '../graphql/mutations/update_escalation_policy.mutation.graphql';
import getEscalationPoliciesQuery from '../graphql/queries/get_escalation_policies.query.graphql';
import { isNameFieldValid, getRulesValidationState, serializeRule } from '../utils';
import { isNameFieldValid, getRulesValidationState, serializeRule, getRules } from '../utils';
import AddEditEscalationPolicyForm from './add_edit_escalation_policy_form.vue';
export const i18n = {
......@@ -82,7 +82,8 @@ export default {
this.validationState.name &&
(this.isEditMode ? true : this.validationState.rules.length) &&
this.validationState.rules.every(
({ isTimeValid, isScheduleValid }) => isTimeValid && isScheduleValid,
({ isTimeValid, isScheduleValid, isUserValid }) =>
isTimeValid && isScheduleValid && isUserValid,
)
);
},
......@@ -90,12 +91,12 @@ export default {
return (
this.form.name !== this.initialState.name ||
this.form.description !== this.initialState.description ||
!isEqual(this.getRules(this.form.rules), this.getRules(this.initialState.rules))
!isEqual(getRules(this.form.rules), getRules(this.initialState.rules))
);
},
requestParams() {
const id = this.isEditMode ? { id: this.escalationPolicy.id } : {};
return { ...this.form, ...id, rules: this.getRules(this.form.rules).map(serializeRule) };
return { ...this.form, ...id, rules: getRules(this.form.rules).map(serializeRule) };
},
},
methods: {
......@@ -188,15 +189,6 @@ export default {
this.loading = false;
});
},
getRules(rules) {
return rules.map(
({ status, elapsedTimeMinutes, oncallScheduleIid, oncallSchedule: { iid } = {} }) => ({
status,
elapsedTimeMinutes,
oncallScheduleIid: oncallScheduleIid || iid,
}),
);
},
validateForm(field) {
if (field === 'name') {
this.validationState.name = isNameFieldValid(this.form.name);
......
......@@ -8,14 +8,17 @@ import {
GlSprintf,
GlIcon,
GlCollapse,
GlToken,
GlAvatar,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import {
ACTIONS,
ALERT_STATUSES,
DEFAULT_ACTION,
EMAIL_ONCALL_SCHEDULE_USER,
deleteEscalationPolicyModalId,
editEscalationPolicyModalId,
EMAIL_USER,
} from '../constants';
import EditEscalationPolicyModal from './add_edit_escalation_policy_modal.vue';
import DeleteEscalationPolicyModal from './delete_escalation_policy_modal.vue';
......@@ -24,22 +27,22 @@ export const i18n = {
editPolicy: s__('EscalationPolicies|Edit escalation policy'),
deletePolicy: s__('EscalationPolicies|Delete escalation policy'),
escalationRule: s__(
'EscalationPolicies|IF alert is not %{alertStatus} in %{minutes} %{then} THEN %{doAction} %{schedule}',
'EscalationPolicies|IF alert is not %{alertStatus} in %{minutes} %{then} THEN %{doAction} %{scheduleOrUser}',
),
minutes: s__('EscalationPolicies|mins'),
noRules: s__('EscalationPolicies|This policy has no escalation rules.'),
};
const isRuleValid = ({ status, elapsedTimeMinutes, oncallSchedule: { name } }) =>
const isRuleValid = ({ status, elapsedTimeMinutes, oncallSchedule, user }) =>
Object.keys(ALERT_STATUSES).includes(status) &&
typeof elapsedTimeMinutes === 'number' &&
typeof name === 'string';
(typeof oncallSchedule?.name === 'string' || typeof user?.username === 'string');
export default {
i18n,
ACTIONS,
ALERT_STATUSES,
DEFAULT_ACTION,
EMAIL_ONCALL_SCHEDULE_USER,
components: {
GlButton,
GlButtonGroup,
......@@ -47,6 +50,8 @@ export default {
GlSprintf,
GlIcon,
GlCollapse,
GlToken,
GlAvatar,
DeleteEscalationPolicyModal,
EditEscalationPolicyModal,
},
......@@ -87,6 +92,20 @@ export default {
return `${deleteEscalationPolicyModalId}-${this.policy.id}`;
},
},
methods: {
hasEscalationSchedule(rule) {
return rule.oncallSchedule?.iid;
},
hasEscalationUser(rule) {
return rule.user?.username;
},
getActionName(rule) {
return (this.hasEscalationSchedule(rule)
? ACTIONS[EMAIL_ONCALL_SCHEDULE_USER]
: ACTIONS[EMAIL_USER]
).toLowerCase();
},
},
};
</script>
......@@ -147,6 +166,7 @@ export default {
v-for="(rule, ruleIndex) in policy.rules"
:key="rule.id"
:class="{ 'gl-mb-5': ruleIndex !== policy.rules.length - 1 }"
class="gl-display-flex gl-align-items-center"
>
<gl-icon name="clock" class="gl-mr-3" />
<gl-sprintf :message="$options.i18n.escalationRule">
......@@ -155,7 +175,7 @@ export default {
</template>
<template #minutes>
<span class="gl-font-weight-bold">
{{ rule.elapsedTimeMinutes }} {{ $options.i18n.minutes }}
&nbsp;{{ rule.elapsedTimeMinutes }} {{ $options.i18n.minutes }}
</span>
</template>
<template #then>
......@@ -165,12 +185,17 @@ export default {
<gl-icon name="notifications" class="gl-mr-3" />
</template>
<template #doAction>
{{ $options.ACTIONS[$options.DEFAULT_ACTION].toLowerCase() }}
{{ getActionName(rule) }}
&nbsp;
</template>
<template #schedule>
<span class="gl-font-weight-bold">
<template #scheduleOrUser>
<span v-if="hasEscalationSchedule(rule)" class="gl-font-weight-bold">
{{ rule.oncallSchedule.name }}
</span>
<gl-token v-else-if="hasEscalationUser(rule)" view-only>
<gl-avatar :src="rule.user.avatarUrl" :size="16" />
{{ rule.user.name }}
</gl-token>
</template>
</gl-sprintf>
</div>
......
......@@ -11,13 +11,14 @@ import {
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { ACTIONS, ALERT_STATUSES } from '../constants';
import { ACTIONS, ALERT_STATUSES, EMAIL_ONCALL_SCHEDULE_USER, EMAIL_USER } from '../constants';
import UserSelect from './user_select.vue';
export const i18n = {
fields: {
rules: {
condition: s__('EscalationPolicies|IF alert is not %{alertStatus} in %{minutes} minutes'),
action: s__('EscalationPolicies|THEN %{doAction} %{schedule}'),
action: s__('EscalationPolicies|THEN %{doAction} %{scheduleOrUser}'),
selectSchedule: s__('EscalationPolicies|Select schedule'),
noSchedules: s__(
'EscalationPolicies|A schedule is required for adding an escalation policy. Please create an on-call schedule first.',
......@@ -27,6 +28,9 @@ export const i18n = {
'EscalationPolicies|A schedule is required for adding an escalation policy.',
),
invalidTimeValidationMsg: s__('EscalationPolicies|Minutes must be between 0 and 1440.'),
invalidUserValidationMsg: s__(
'EscalationPolicies|A user is required for adding an escalation policy.',
),
},
},
};
......@@ -35,6 +39,8 @@ export default {
i18n,
ALERT_STATUSES,
ACTIONS,
EMAIL_ONCALL_SCHEDULE_USER,
EMAIL_USER,
components: {
GlFormGroup,
GlFormInput,
......@@ -44,6 +50,7 @@ export default {
GlButton,
GlIcon,
GlSprintf,
UserSelect,
},
directives: {
GlTooltip,
......@@ -74,12 +81,15 @@ export default {
},
},
data() {
const { status, elapsedTimeMinutes, action, oncallScheduleIid } = this.rule;
const { status, elapsedTimeMinutes, oncallScheduleIid, username, action } = this.rule;
return {
status,
elapsedTimeMinutes,
action,
elapsedTimeMinutes,
oncallScheduleIid,
username,
hasFocus: true,
};
},
computed: {
......@@ -92,7 +102,7 @@ export default {
return !this.schedulesLoading && !this.schedules.length;
},
isValid() {
return this.isTimeValid && this.isScheduleValid;
return this.isTimeValid && this.isScheduleValid && this.isUserValid;
},
isTimeValid() {
return this.validationState?.isTimeValid;
......@@ -100,21 +110,71 @@ export default {
isScheduleValid() {
return this.validationState?.isScheduleValid;
},
isUserValid() {
return this.validationState?.isUserValid;
},
isEmailOncallScheduleUserActionSelected() {
return this.action === EMAIL_ONCALL_SCHEDULE_USER;
},
isEmailUserActionSelected() {
return this.action === EMAIL_USER;
},
actionBasedRequestParams() {
if (this.isEmailOncallScheduleUserActionSelected) {
return { oncallScheduleIid: parseInt(this.oncallScheduleIid, 10) };
}
return { username: this.username };
},
showEmptyScheduleValidationMsg() {
return this.isEmailOncallScheduleUserActionSelected && !this.isScheduleValid;
},
showNoUserValidationMsg() {
return this.isEmailUserActionSelected && !this.isUserValid;
},
},
mounted() {
this.ruleContainer = this.$refs.ruleContainer?.$el;
this.ruleContainer?.addEventListener('focusin', this.addFocus);
this.ruleContainer?.addEventListener('focusout', this.removeFocus);
},
beforeDestroy() {
this.ruleContainer?.removeEventListener('focusin', this.addFocus);
this.ruleContainer?.removeEventListener('focusout', this.removeFocus);
},
methods: {
addFocus() {
this.hasFocus = true;
},
removeFocus() {
this.hasFocus = false;
},
setOncallSchedule({ iid }) {
this.oncallScheduleIid = this.oncallScheduleIid === iid ? null : iid;
this.emitUpdate();
},
setAction(action) {
this.action = action;
if (this.isEmailOncallScheduleUserActionSelected) {
this.username = null;
} else if (this.isEmailUserActionSelected) {
this.oncallScheduleIid = null;
}
this.emitUpdate();
},
setStatus(status) {
this.status = status;
this.emitUpdate();
},
setSelectedUser(username) {
this.username = username;
this.emitUpdate();
},
emitUpdate() {
this.$emit('update-escalation-rule', {
index: this.index,
rule: {
oncallScheduleIid: parseInt(this.oncallScheduleIid, 10),
...this.actionBasedRequestParams,
action: this.action,
status: this.status,
elapsedTimeMinutes: this.elapsedTimeMinutes,
......@@ -126,7 +186,7 @@ export default {
</script>
<template>
<gl-card class="gl-border-gray-400 gl-bg-gray-10 gl-mb-3 gl-relative">
<gl-card ref="ruleContainer" class="gl-border-gray-400 gl-bg-gray-10 gl-mb-3 gl-relative">
<gl-button
v-if="index !== 0"
category="tertiary"
......@@ -138,10 +198,13 @@ export default {
/>
<gl-form-group :state="isValid" class="gl-mb-0">
<template #invalid-feedback>
<div v-if="!isScheduleValid">
<div v-if="!isScheduleValid && !hasFocus">
{{ $options.i18n.fields.rules.emptyScheduleValidationMsg }}
</div>
<div v-if="!isTimeValid" class="gl-display-inline-block gl-mt-2">
<div v-if="!isUserValid && !hasFocus" class="gl-display-inline-block gl-mt-2">
{{ $options.i18n.fields.rules.invalidUserValidationMsg }}
</div>
<div v-if="!isTimeValid && !hasFocus" class="gl-display-inline-block gl-mt-2">
{{ $options.i18n.fields.rules.invalidTimeValidationMsg }}
</div>
</template>
......@@ -181,49 +244,53 @@ export default {
<template #doAction>
<gl-dropdown
class="rule-control gl-mx-3"
:text="$options.ACTIONS[rule.action]"
:text="$options.ACTIONS[action]"
data-testid="action-dropdown"
>
<gl-dropdown-item
v-for="(label, ruleAction) in $options.ACTIONS"
:key="ruleAction"
:is-checked="rule.action === ruleAction"
:is-checked="action === ruleAction"
is-check-item
@click="setAction(ruleAction)"
>
{{ label }}
</gl-dropdown-item>
</gl-dropdown>
</template>
<template #schedule>
<gl-dropdown
:disabled="noSchedules"
class="rule-control"
:text="scheduleDropdownTitle"
data-testid="schedules-dropdown"
>
<template #button-text>
<span :class="{ 'gl-text-gray-400': !oncallScheduleIid }">
{{ scheduleDropdownTitle }}
</span>
</template>
<gl-dropdown-item
v-for="schedule in schedules"
:key="schedule.iid"
:is-checked="schedule.iid === oncallScheduleIid"
is-check-item
@click="setOncallSchedule(schedule)"
<template #scheduleOrUser>
<template v-if="isEmailOncallScheduleUserActionSelected">
<gl-dropdown
:disabled="noSchedules"
class="rule-control"
:text="scheduleDropdownTitle"
data-testid="schedules-dropdown"
>
{{ schedule.name }}
</gl-dropdown-item>
</gl-dropdown>
<gl-icon
v-if="noSchedules"
v-gl-tooltip
:title="$options.i18n.fields.rules.noSchedules"
name="information-o"
class="gl-text-gray-500 gl-ml-3"
data-testid="no-schedules-info-icon"
/>
<template #button-text>
<span :class="{ 'gl-text-gray-400': !oncallScheduleIid }">
{{ scheduleDropdownTitle }}
</span>
</template>
<gl-dropdown-item
v-for="schedule in schedules"
:key="schedule.iid"
:is-checked="schedule.iid === oncallScheduleIid"
is-check-item
@click="setOncallSchedule(schedule)"
>
{{ schedule.name }}
</gl-dropdown-item>
</gl-dropdown>
<gl-icon
v-if="noSchedules"
v-gl-tooltip
:title="$options.i18n.fields.rules.noSchedules"
name="information-o"
class="gl-text-gray-500 gl-ml-3"
data-testid="no-schedules-info-icon"
/>
</template>
<user-select v-else :selected-user-name="username" @select-user="setSelectedUser" />
</template>
</gl-sprintf>
</div>
......
<script>
import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlToken } from '@gitlab/ui';
import searchProjectMembersQuery from '~/graphql_shared/queries/project_user_members_search.query.graphql';
import { s__, __ } from '~/locale';
export default {
components: {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlToken,
},
inject: ['projectPath'],
i18n: {
placeholder: s__('EscalationPolicies|Search for user'),
noResults: __('No matching results'),
},
props: {
selectedUserName: {
type: String,
required: false,
default: null,
},
},
apollo: {
users: {
query: searchProjectMembersQuery,
variables() {
return {
fullPath: this.projectPath,
search: this.search,
};
},
update({ project: { projectMembers: { nodes = [] } = {} } = {} } = {}) {
return nodes.filter((x) => x?.user).map(({ user }) => ({ ...user }));
},
error(error) {
this.error = error;
},
result() {
this.setSelectedUser();
},
debounce: 250,
},
},
data() {
return {
users: [],
selectedUsers: [],
search: '',
};
},
computed: {
loading() {
return this.$apollo.queries.users.loading;
},
placeholderText() {
return this.selectedUsers.length ? '' : this.$options.i18n.placeholder;
},
user() {
return this.selectedUsers[0];
},
},
methods: {
filterUsers(searchTerm) {
this.search = searchTerm;
},
emitUserUpdate() {
this.$emit('select-user', this.user?.username);
},
clearSelectedUsers() {
this.selectedUsers = [];
this.emitUserUpdate();
},
setSelectedUser() {
const selectedUser = this.users.find(({ username }) => username === this.selectedUserName);
if (selectedUser) {
this.selectedUsers.push(selectedUser);
}
},
},
};
</script>
<template>
<div
v-if="selectedUsers.length"
class="gl-inset-border-1-gray-400 gl-px-3 gl-py-2 gl-rounded-base rule-control"
>
<gl-token @close="clearSelectedUsers">
<gl-avatar :src="user.avatarUrl" :size="16" />
{{ user.name }}
</gl-token>
</div>
<gl-token-selector
v-else
ref="tokenSelector"
v-model="selectedUsers"
:dropdown-items="users"
:loading="loading"
:placeholder="placeholderText"
container-class="rule-control"
@text-input="filterUsers"
@token-add="emitUserUpdate"
>
<template #dropdown-item-content="{ dropdownItem }">
<gl-avatar-labeled
:src="dropdownItem.avatarUrl"
:size="32"
:label="dropdownItem.name"
:sub-label="dropdownItem.username"
/>
</template>
</gl-token-selector>
</template>
......@@ -5,10 +5,12 @@ export const ALERT_STATUSES = {
RESOLVED: s__('AlertManagement|Resolved'),
};
export const DEFAULT_ACTION = 'EMAIL_ONCALL_SCHEDULE_USER';
export const EMAIL_ONCALL_SCHEDULE_USER = 'EMAIL_ONCALL_SCHEDULE_USER';
export const EMAIL_USER = 'EMAIL_USER';
export const ACTIONS = {
[DEFAULT_ACTION]: s__('EscalationPolicies|Email on-call user in schedule'),
[EMAIL_ONCALL_SCHEDULE_USER]: s__('EscalationPolicies|Email on-call user in schedule'),
[EMAIL_USER]: s__('EscalationPolicies|Email user'),
};
export const DEFAULT_ESCALATION_RULE = {
......
......@@ -10,5 +10,10 @@ fragment EscalationPolicy on EscalationPolicyType {
iid
name
}
user {
username
name
avatarUrl
}
}
}
import { pickBy, isNull, isNaN } from 'lodash';
import { EMAIL_ONCALL_SCHEDULE_USER, EMAIL_USER } from './constants';
/**
* Returns `true` for non-empty string, otherwise returns `false`
* @param {String} name
......@@ -15,11 +18,12 @@ export const isNameFieldValid = (name) => {
* @returns {Array}
*/
export const getRulesValidationState = (rules) => {
return rules.map((rule) => {
const minutes = parseInt(rule.elapsedTimeMinutes, 10);
return rules.map(({ elapsedTimeMinutes, oncallScheduleIid, username, action }) => {
const minutes = parseInt(elapsedTimeMinutes, 10);
return {
isTimeValid: minutes >= 0 && minutes <= 1440,
isScheduleValid: Boolean(rule.oncallScheduleIid),
isScheduleValid: action === EMAIL_ONCALL_SCHEDULE_USER ? Boolean(oncallScheduleIid) : true,
isUserValid: action === EMAIL_USER ? Boolean(username) : true,
};
});
};
......@@ -30,10 +34,14 @@ export const getRulesValidationState = (rules) => {
*
* @returns {Object} rule
*/
export const serializeRule = ({ elapsedTimeMinutes, ...ruleParams }) => ({
...ruleParams,
elapsedTimeSeconds: elapsedTimeMinutes * 60,
});
export const serializeRule = ({ elapsedTimeMinutes, ...ruleParams }) => {
const params = { ...ruleParams };
delete params.action;
return {
...params,
elapsedTimeSeconds: elapsedTimeMinutes * 60,
};
};
/**
* Parses a policy by converting elapsed seconds to minutes
......@@ -48,3 +56,29 @@ export const parsePolicy = (policy) => ({
elapsedTimeMinutes: elapsedTimeSeconds / 60,
})),
});
/**
* Parses a rule for the UI form usage or doe BE params serializing
* @param {Array} of transformed rules from BE
*
* @returns {Array} of rules
*/
export const getRules = (rules) => {
return rules.map(
({ status, elapsedTimeMinutes, oncallScheduleIid, oncallSchedule, user, username }) => {
const actionBasedProps = pickBy(
{
username: username ?? user?.username,
oncallScheduleIid: parseInt(oncallScheduleIid ?? oncallSchedule?.iid, 10),
},
(prop) => !(isNull(prop) || isNaN(prop)),
);
return {
status,
elapsedTimeMinutes,
...actionBasedProps,
};
},
);
};
......@@ -24,7 +24,7 @@ exports[`EscalationPolicy renders a policy with rules 1`] = `
class="gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base gl-p-5"
>
<div
class="gl-mb-5"
class="gl-display-flex gl-align-items-center gl-mb-5"
>
<gl-icon-stub
class="gl-mr-3"
......@@ -38,7 +38,7 @@ exports[`EscalationPolicy renders a policy with rules 1`] = `
class="gl-font-weight-bold"
>
1 mins
 1 mins
</span>
......@@ -57,6 +57,7 @@ exports[`EscalationPolicy renders a policy with rules 1`] = `
/>
THEN
email on-call user in schedule
 
<span
class="gl-font-weight-bold"
......@@ -67,7 +68,7 @@ exports[`EscalationPolicy renders a policy with rules 1`] = `
</span>
</div>
<div
class=""
class="gl-display-flex gl-align-items-center"
>
<gl-icon-stub
class="gl-mr-3"
......@@ -81,7 +82,7 @@ exports[`EscalationPolicy renders a policy with rules 1`] = `
class="gl-font-weight-bold"
>
2 mins
 2 mins
</span>
......@@ -99,15 +100,25 @@ exports[`EscalationPolicy renders a policy with rules 1`] = `
size="16"
/>
THEN
email on-call user in schedule
email user
 
<span
class="gl-font-weight-bold"
<gl-token-stub
variant="default"
viewonly="true"
>
<gl-avatar-stub
alt="avatar"
entityid="0"
entityname=""
shape="circle"
size="16"
src="avatar.com/lena.png"
/>
Monitor schedule
Lena
</span>
</gl-token-stub>
</div>
</div>
</gl-collapse-stub>
......
......@@ -96,16 +96,28 @@ describe('AddEscalationPolicyForm', () => {
expect(wrapper.emitted('update-escalation-policy-form')).toBeUndefined();
});
it('on rule update emitted should update rules array and emit updates up', () => {
it('on rule update emitted should update rules array and emit updates up', async () => {
const ruleBeforeUpdate = {
status: 'RESOLVED',
elapsedTimeMinutes: 3,
username: 'user',
};
createComponent({ props: { form: { rules: [ruleBeforeUpdate] } } });
await wrapper.vm.$nextTick();
const updatedRule = {
status: 'TRIGGERED',
elapsedTimeMinutes: 3,
oncallScheduleIid: 2,
};
findRules().at(0).vm.$emit('update-escalation-rule', { index: 0, rule: updatedRule });
expect(wrapper.emitted('update-escalation-policy-form')[0]).toEqual([
const emittedValue = wrapper.emitted('update-escalation-policy-form')[0];
expect(emittedValue).toEqual([
{ field: 'rules', value: [expect.objectContaining(updatedRule)] },
]);
expect(emittedValue).not.toEqual([
{ field: 'rules', value: [expect.objectContaining(ruleBeforeUpdate)] },
]);
});
it('on rule removal emitted should update rules array and emit updates up', () => {
......
......@@ -9,6 +9,7 @@ import AddEscalationPolicyModal, {
import {
addEscalationPolicyModalId,
editEscalationPolicyModalId,
EMAIL_ONCALL_SCHEDULE_USER,
} from 'ee/escalation_policies/constants';
import createEscalationPolicyMutation from 'ee/escalation_policies/graphql/mutations/create_escalation_policy.mutation.graphql';
import updateEscalationPolicyMutation from 'ee/escalation_policies/graphql/mutations/update_escalation_policy.mutation.graphql';
......@@ -267,7 +268,14 @@ describe('AddEditsEscalationPolicyModal', () => {
});
form.vm.$emit('update-escalation-policy-form', {
field: 'rules',
value: [{ status: 'RESOLVED', elapsedTimeMinutes: 1, oncallScheduleIid: 1 }],
value: [
{
status: 'RESOLVED',
elapsedTimeMinutes: 1,
action: EMAIL_ONCALL_SCHEDULE_USER,
oncallScheduleIid: 1,
},
],
});
await wrapper.vm.$nextTick();
expect(findModal().props('actionPrimary').attributes).toContainEqual({ disabled: false });
......
import { GlDropdownItem, GlFormGroup, GlSprintf } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import EscalationRule, { i18n } from 'ee/escalation_policies/components/escalation_rule.vue';
import { DEFAULT_ESCALATION_RULE, ACTIONS, ALERT_STATUSES } from 'ee/escalation_policies/constants';
import UserSelect from 'ee/escalation_policies/components/user_select.vue';
import {
DEFAULT_ESCALATION_RULE,
ACTIONS,
ALERT_STATUSES,
EMAIL_ONCALL_SCHEDULE_USER,
EMAIL_USER,
} from 'ee/escalation_policies/constants';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
const mockSchedules = [
......@@ -11,6 +18,7 @@ const mockSchedules = [
];
const emptyScheduleMsg = i18n.fields.rules.emptyScheduleValidationMsg;
const noUserSelecteddErrorMsg = i18n.fields.rules.invalidUserValidationMsg;
const invalidTimeMsg = i18n.fields.rules.invalidTimeValidationMsg;
describe('EscalationRule', () => {
......@@ -48,7 +56,7 @@ describe('EscalationRule', () => {
const findSchedulesDropdown = () => wrapper.findByTestId('schedules-dropdown');
const findSchedulesDropdownOptions = () => findSchedulesDropdown().findAll(GlDropdownItem);
const findUserSelect = () => wrapper.findComponent(UserSelect);
const findFormGroup = () => wrapper.findComponent(GlFormGroup);
const findNoSchedulesInfoIcon = () => wrapper.findByTestId('no-schedules-info-icon');
......@@ -94,25 +102,67 @@ describe('EscalationRule', () => {
expect(findSchedulesDropdown().attributes('disabled')).toBe('true');
expect(findNoSchedulesInfoIcon().exists()).toBe(true);
});
it('should not render UserSelect when action is EMAIL_ONCALL_SCHEDULE_USER', () => {
createComponent({
props: {
rule: {
...DEFAULT_ESCALATION_RULE,
action: EMAIL_ONCALL_SCHEDULE_USER,
},
},
});
expect(findUserSelect().exists()).toBe(false);
});
});
describe('User select', () => {
beforeEach(() => {
createComponent({
props: {
rule: {
...DEFAULT_ESCALATION_RULE,
action: EMAIL_USER,
},
},
});
});
it('should render UserSelect when action is EMAIL USER', () => {
expect(findUserSelect().exists()).toBe(true);
});
it('should NOT render schedule selection dropdown when action is EMAIL USER', () => {
expect(findSchedulesDropdown().exists()).toBe(false);
});
});
describe('Validation', () => {
describe.each`
validationState | formState
${{ isTimeValid: true, isScheduleValid: true }} | ${'true'}
${{ isTimeValid: false, isScheduleValid: true }} | ${undefined}
${{ isTimeValid: true, isScheduleValid: false }} | ${undefined}
${{ isTimeValid: false, isScheduleValid: false }} | ${undefined}
`(`when`, ({ validationState, formState }) => {
validationState | formState | action
${{ isTimeValid: true, isScheduleValid: true, isUserValid: true }} | ${'true'} | ${EMAIL_ONCALL_SCHEDULE_USER}
${{ isTimeValid: false, isScheduleValid: true, isUserValid: true }} | ${undefined} | ${EMAIL_ONCALL_SCHEDULE_USER}
${{ isTimeValid: true, isScheduleValid: false, isUserValid: true }} | ${undefined} | ${EMAIL_ONCALL_SCHEDULE_USER}
${{ isTimeValid: true, isScheduleValid: true, isUserValid: false }} | ${undefined} | ${EMAIL_USER}
${{ isTimeValid: false, isScheduleValid: false, isUserValid: true }} | ${undefined} | ${EMAIL_ONCALL_SCHEDULE_USER}
${{ isTimeValid: false, isScheduleValid: true, isUserValid: false }} | ${undefined} | ${EMAIL_USER}
`(`when`, ({ validationState, formState, action }) => {
describe(`elapsed minutes control is ${
validationState.isTimeValid ? 'valid' : 'invalid'
} and schedule control is ${validationState.isScheduleValid ? 'valid' : 'invalid'}`, () => {
} and schedule control is ${
validationState.isScheduleValid ? 'valid' : 'invalid'
} and user control is ${validationState.isUserValid ? 'valid' : 'invalid'}`, () => {
beforeEach(() => {
createComponent({
props: {
validationState,
rule: {
...DEFAULT_ESCALATION_RULE,
action,
},
},
});
wrapper.setData({ hasFocus: false });
});
it(`sets form group validation state to ${formState}`, () => {
......@@ -123,17 +173,26 @@ describe('EscalationRule', () => {
validationState.isTimeValid ? 'not show' : 'show'
} invalid time error message && does ${
validationState.isScheduleValid ? 'not show' : 'show'
} invalid schedule error message `, () => {
} no schedule error message && does ${
validationState.isUserValid ? 'not show' : 'show'
} no user error message `, () => {
if (validationState.isTimeValid) {
expect(findFormGroup().text()).not.toContain(invalidTimeMsg);
} else {
expect(findFormGroup().text()).toContain(invalidTimeMsg);
}
if (validationState.isScheduleValid) {
expect(findFormGroup().text()).not.toContain(emptyScheduleMsg);
} else {
expect(findFormGroup().text()).toContain(emptyScheduleMsg);
}
if (validationState.isUserValid) {
expect(findFormGroup().text()).not.toContain(noUserSelecteddErrorMsg);
} else {
expect(findFormGroup().text()).toContain(noUserSelecteddErrorMsg);
}
});
});
});
......
......@@ -18,6 +18,7 @@ export const getEscalationPoliciesQueryResponse = {
name: 'Schedule',
__typename: 'IncidentManagementOncallSchedule',
},
user: null,
__typename: 'EscalationRuleType',
},
],
......
......@@ -17,9 +17,10 @@
"id": "gid://gitlab/IncidentManagement::EscalationRule/23",
"status": "RESOLVED",
"elapsedTimeSeconds": 120,
"oncallSchedule": {
"iid": "4",
"name": "Monitor schedule"
"user": {
"username": "sharlatenok",
"name": "Lena",
"avatarUrl": "avatar.com/lena.png"
}
}
]
......
import { GlTokenSelector, GlAvatar, GlToken } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import UserSelect from 'ee/escalation_policies/components/user_select.vue';
const mockUsers = [
{ id: 1, name: 'User 1', avatarUrl: 'avatar.com/user1.png' },
{ id: 2, name: 'User2', avatarUrl: 'avatar.com/user1.png' },
];
describe('UserSelect', () => {
let wrapper;
const projectPath = 'group/project';
const createComponent = () => {
wrapper = shallowMount(UserSelect, {
data() {
return {
users: mockUsers,
};
},
mocks: {
$apollo: {
queries: {
users: { loading: false },
},
},
},
stubs: {
GlTokenSelector,
},
provide: {
projectPath,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
const findSelectedUserToken = () => wrapper.findComponent(GlToken);
const findAvatar = () => wrapper.findComponent(GlAvatar);
describe('When no user selected', () => {
it('renders token selector and provides it with correct params', () => {
const tokenSelector = findTokenSelector();
expect(tokenSelector.exists()).toBe(true);
expect(tokenSelector.props('dropdownItems')).toEqual(mockUsers);
expect(tokenSelector.props('loading')).toEqual(false);
});
it('does not render selected user token', () => {
expect(findSelectedUserToken().exists()).toBe(false);
});
});
describe('On user selected', () => {
it('hides token selector', async () => {
const tokenSelector = findTokenSelector();
expect(tokenSelector.exists()).toBe(true);
tokenSelector.vm.$emit('input', [mockUsers[0]]);
await wrapper.vm.$nextTick();
expect(tokenSelector.exists()).toBe(false);
});
it('shows selected user token with name and avatar', async () => {
const selectedUser = mockUsers[0];
findTokenSelector().vm.$emit('input', [selectedUser]);
await wrapper.vm.$nextTick();
const userToken = findSelectedUserToken();
expect(userToken.exists()).toBe(true);
expect(userToken.text()).toMatchInterpolatedText(selectedUser.name);
const avatar = findAvatar();
expect(avatar.exists()).toBe(true);
expect(avatar.props('src')).toBe(selectedUser.avatarUrl);
});
});
describe('On user deselected', () => {
it('hides selected user token and avatar, shows token selector', async () => {
// select user
findTokenSelector().vm.$emit('input', [mockUsers[0]]);
await wrapper.vm.$nextTick();
const userToken = findSelectedUserToken();
expect(userToken.exists()).toBe(true);
// deselect user
userToken.vm.$emit('close');
await wrapper.vm.$nextTick();
expect(userToken.exists()).toBe(false);
expect(findTokenSelector().exists()).toBe(true);
});
});
});
import { EMAIL_ONCALL_SCHEDULE_USER, EMAIL_USER } from 'ee/escalation_policies/constants';
import * as utils from 'ee/escalation_policies/utils';
describe('Escalation policies utility functions', () => {
describe('isNameFieldValid', () => {
it('should return `true` when name is valid', () => {
expect(utils.isNameFieldValid('policy name')).toBe(true);
});
it('should return `false` otherwise', () => {
expect(utils.isNameFieldValid('')).toBe(false);
expect(utils.isNameFieldValid(undefined)).toBe(false);
});
});
describe('getRulesValidationState', () => {
it.each`
rules | validationState
${[{ elapsedTimeMinutes: 10, oncallScheduleIid: 1, username: null, action: EMAIL_ONCALL_SCHEDULE_USER }]} | ${[{ isTimeValid: true, isScheduleValid: true, isUserValid: true }]}
${[{ elapsedTimeMinutes: 1500, oncallScheduleIid: 1, username: null, action: EMAIL_ONCALL_SCHEDULE_USER }]} | ${[{ isTimeValid: false, isScheduleValid: true, isUserValid: true }]}
${[{ elapsedTimeMinutes: -2, oncallScheduleIid: null, username: 'user', action: EMAIL_ONCALL_SCHEDULE_USER }]} | ${[{ isTimeValid: false, isScheduleValid: false, isUserValid: true }]}
${[{ elapsedTimeMinutes: 30, oncallScheduleIid: null, username: 'user', action: EMAIL_USER }]} | ${[{ isTimeValid: true, isScheduleValid: true, isUserValid: true }]}
${[{ elapsedTimeMinutes: 30, oncallScheduleIid: 1, username: null, action: EMAIL_USER }]} | ${[{ isTimeValid: true, isScheduleValid: true, isUserValid: false }]}
`('calculates rules validation state', ({ rules, validationState }) => {
expect(utils.getRulesValidationState(rules)).toEqual(validationState);
});
});
describe('parsePolicy', () => {
it('parses a policy by converting elapsed seconds to minutes for ecach rule', () => {
const policy = {
name: 'policy',
rules: [
{ elapsedTimeSeconds: 600, username: 'user' },
{ elapsedTimeSeconds: 0, oncallScheduleIid: 1 },
],
};
expect(utils.parsePolicy(policy)).toEqual({
name: 'policy',
rules: [
{ elapsedTimeMinutes: 10, username: 'user' },
{ elapsedTimeMinutes: 0, oncallScheduleIid: 1 },
],
});
});
});
describe('getRules', () => {
it.each`
rules | transformedRules
${[{ elapsedTimeMinutes: 10, status: 'Acknowledged', oncallScheduleIid: '1', username: null }]} | ${[{ elapsedTimeMinutes: 10, status: 'Acknowledged', oncallScheduleIid: 1 }]}
${[{ elapsedTimeMinutes: 20, status: 'Resolved', oncallSchedule: { iid: '2' }, username: null }]} | ${[{ elapsedTimeMinutes: 20, status: 'Resolved', oncallScheduleIid: 2 }]}
${[{ elapsedTimeMinutes: 0, status: 'Resolved', oncallScheduleId: null, username: 'user' }]} | ${[{ elapsedTimeMinutes: 0, status: 'Resolved', username: 'user' }]}
${[{ elapsedTimeMinutes: 40, status: 'Resolved', oncallScheduleId: null, user: { username: 'user2' } }]} | ${[{ elapsedTimeMinutes: 40, status: 'Resolved', username: 'user2' }]}
`('transforms the rules', ({ rules, transformedRules }) => {
expect(utils.getRules(rules)).toEqual(transformedRules);
});
});
});
......@@ -13145,6 +13145,9 @@ msgstr ""
msgid "EscalationPolicies|A schedule is required for adding an escalation policy. Please create an on-call schedule first."
msgstr ""
msgid "EscalationPolicies|A user is required for adding an escalation policy."
msgstr ""
msgid "EscalationPolicies|Add an escalation policy"
msgstr ""
......@@ -13169,6 +13172,9 @@ msgstr ""
msgid "EscalationPolicies|Email on-call user in schedule"
msgstr ""
msgid "EscalationPolicies|Email user"
msgstr ""
msgid "EscalationPolicies|Escalation policies"
msgstr ""
......@@ -13178,7 +13184,7 @@ msgstr ""
msgid "EscalationPolicies|Failed to load oncall-schedules"
msgstr ""
msgid "EscalationPolicies|IF alert is not %{alertStatus} in %{minutes} %{then} THEN %{doAction} %{schedule}"
msgid "EscalationPolicies|IF alert is not %{alertStatus} in %{minutes} %{then} THEN %{doAction} %{scheduleOrUser}"
msgstr ""
msgid "EscalationPolicies|IF alert is not %{alertStatus} in %{minutes} minutes"
......@@ -13193,13 +13199,16 @@ msgstr ""
msgid "EscalationPolicies|Remove escalation rule"
msgstr ""
msgid "EscalationPolicies|Search for user"
msgstr ""
msgid "EscalationPolicies|Select schedule"
msgstr ""
msgid "EscalationPolicies|Set up escalation policies to define who is paged, and when, in the event the first users paged don't respond."
msgstr ""
msgid "EscalationPolicies|THEN %{doAction} %{schedule}"
msgid "EscalationPolicies|THEN %{doAction} %{scheduleOrUser}"
msgstr ""
msgid "EscalationPolicies|The escalation policy could not be deleted. Please try again."
......
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