diff --git a/ee/app/assets/javascripts/escalation_policies/components/add_edit_escalation_policy_form.vue b/ee/app/assets/javascripts/escalation_policies/components/add_edit_escalation_policy_form.vue index d058554fbd2f3ffaa6d25ddf7bdc81cb3adf9d5b..5c8b1b677b368fee0086a1f1d7c9a6b0a17c183f 100644 --- a/ee/app/assets/javascripts/escalation_policies/components/add_edit_escalation_policy_form.vue +++ b/ee/app/assets/javascripts/escalation_policies/components/add_edit_escalation_policy_form.vue @@ -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) { diff --git a/ee/app/assets/javascripts/escalation_policies/components/add_edit_escalation_policy_modal.vue b/ee/app/assets/javascripts/escalation_policies/components/add_edit_escalation_policy_modal.vue index 2baa9fbf511c8cdb4261390b6f39380f36fb3db0..340736a3d78fa0f84d53b4e34be44794ebaba382 100644 --- a/ee/app/assets/javascripts/escalation_policies/components/add_edit_escalation_policy_modal.vue +++ b/ee/app/assets/javascripts/escalation_policies/components/add_edit_escalation_policy_modal.vue @@ -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); diff --git a/ee/app/assets/javascripts/escalation_policies/components/escalation_policy.vue b/ee/app/assets/javascripts/escalation_policies/components/escalation_policy.vue index 68b3e98a66fd6dab11d1e22d295590627da66147..f269937df8a1dbb8bdfb92912ae7444b016b8829 100644 --- a/ee/app/assets/javascripts/escalation_policies/components/escalation_policy.vue +++ b/ee/app/assets/javascripts/escalation_policies/components/escalation_policy.vue @@ -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 }} + {{ 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) }} + </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> diff --git a/ee/app/assets/javascripts/escalation_policies/components/escalation_rule.vue b/ee/app/assets/javascripts/escalation_policies/components/escalation_rule.vue index 75e30f734390957bfe11b2eafe17a1d5ba3a78bf..c1bffbabde56bbc31a8743e09d1a3513ae6b7b52 100644 --- a/ee/app/assets/javascripts/escalation_policies/components/escalation_rule.vue +++ b/ee/app/assets/javascripts/escalation_policies/components/escalation_rule.vue @@ -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> diff --git a/ee/app/assets/javascripts/escalation_policies/components/user_select.vue b/ee/app/assets/javascripts/escalation_policies/components/user_select.vue new file mode 100644 index 0000000000000000000000000000000000000000..ee16936c99110e6dba9d6bc3bd9ba5bef6c0822a --- /dev/null +++ b/ee/app/assets/javascripts/escalation_policies/components/user_select.vue @@ -0,0 +1,116 @@ +<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> diff --git a/ee/app/assets/javascripts/escalation_policies/constants.js b/ee/app/assets/javascripts/escalation_policies/constants.js index 3386dbb9b448f5e8560c817bddf70e8d0af00c42..2b44f4c4e4c70c11840e15174857c8e2b2a6329d 100644 --- a/ee/app/assets/javascripts/escalation_policies/constants.js +++ b/ee/app/assets/javascripts/escalation_policies/constants.js @@ -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 = { diff --git a/ee/app/assets/javascripts/escalation_policies/graphql/fragments/escalation_policy.fragment.graphql b/ee/app/assets/javascripts/escalation_policies/graphql/fragments/escalation_policy.fragment.graphql index 98f2edd8b5feb1fa3e90e1ebf0717199861145c4..52b9d60ab8ce16ec549cea684f56cbcd51e2cc15 100644 --- a/ee/app/assets/javascripts/escalation_policies/graphql/fragments/escalation_policy.fragment.graphql +++ b/ee/app/assets/javascripts/escalation_policies/graphql/fragments/escalation_policy.fragment.graphql @@ -10,5 +10,10 @@ fragment EscalationPolicy on EscalationPolicyType { iid name } + user { + username + name + avatarUrl + } } } diff --git a/ee/app/assets/javascripts/escalation_policies/utils.js b/ee/app/assets/javascripts/escalation_policies/utils.js index 625031e46584054e850f00c17e976aa3d30677e9..6ff36165ac2a2e592a20546175d875136eb42548 100644 --- a/ee/app/assets/javascripts/escalation_policies/utils.js +++ b/ee/app/assets/javascripts/escalation_policies/utils.js @@ -1,3 +1,6 @@ +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, + }; + }, + ); +}; diff --git a/ee/spec/frontend/escalation_policies/__snapshots__/escalation_policy_spec.js.snap b/ee/spec/frontend/escalation_policies/__snapshots__/escalation_policy_spec.js.snap index 82b6d25481e48c7573cd78b51a68a73159d61a4b..bd8f82f5e44118525f911d39a49b84c6a8f8d6d6 100644 --- a/ee/spec/frontend/escalation_policies/__snapshots__/escalation_policy_spec.js.snap +++ b/ee/spec/frontend/escalation_policies/__snapshots__/escalation_policy_spec.js.snap @@ -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> diff --git a/ee/spec/frontend/escalation_policies/add_edit_escalation_policy_form_spec.js b/ee/spec/frontend/escalation_policies/add_edit_escalation_policy_form_spec.js index 988d7c1b8628386da5ee512e0ea6fde48e6a4fc9..88bf2728574140804c5f9f056a45e7b3cc9a6a13 100644 --- a/ee/spec/frontend/escalation_policies/add_edit_escalation_policy_form_spec.js +++ b/ee/spec/frontend/escalation_policies/add_edit_escalation_policy_form_spec.js @@ -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', () => { diff --git a/ee/spec/frontend/escalation_policies/add_edit_escalation_policy_modal_spec.js b/ee/spec/frontend/escalation_policies/add_edit_escalation_policy_modal_spec.js index e0c9b75cbb5afacb9c3cc89f6ed2b1ddd1090535..727d45134bce8572169c8df436d3279aaf795f0b 100644 --- a/ee/spec/frontend/escalation_policies/add_edit_escalation_policy_modal_spec.js +++ b/ee/spec/frontend/escalation_policies/add_edit_escalation_policy_modal_spec.js @@ -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 }); diff --git a/ee/spec/frontend/escalation_policies/escalation_rule_spec.js b/ee/spec/frontend/escalation_policies/escalation_rule_spec.js index 460953c11ea4868cf73e87c17006b574925612ed..2f34aeefe101e874e18aeda5b805eea0b40588a8 100644 --- a/ee/spec/frontend/escalation_policies/escalation_rule_spec.js +++ b/ee/spec/frontend/escalation_policies/escalation_rule_spec.js @@ -1,7 +1,14 @@ 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); + } }); }); }); diff --git a/ee/spec/frontend/escalation_policies/mocks/apollo_mock.js b/ee/spec/frontend/escalation_policies/mocks/apollo_mock.js index d3c5ab93a0a59518c900b28fbe4f1321c24cfecd..0d4b2df91882e05fff0efdc03a4b40048b77d382 100644 --- a/ee/spec/frontend/escalation_policies/mocks/apollo_mock.js +++ b/ee/spec/frontend/escalation_policies/mocks/apollo_mock.js @@ -18,6 +18,7 @@ export const getEscalationPoliciesQueryResponse = { name: 'Schedule', __typename: 'IncidentManagementOncallSchedule', }, + user: null, __typename: 'EscalationRuleType', }, ], diff --git a/ee/spec/frontend/escalation_policies/mocks/mockPolicies.json b/ee/spec/frontend/escalation_policies/mocks/mockPolicies.json index 0f0859101aa031076767123dee64fa0b2942b3ab..abbcf83fd59098359bb7daa5052a179088a4749a 100644 --- a/ee/spec/frontend/escalation_policies/mocks/mockPolicies.json +++ b/ee/spec/frontend/escalation_policies/mocks/mockPolicies.json @@ -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" } } ] diff --git a/ee/spec/frontend/escalation_policies/user_select_spec.js b/ee/spec/frontend/escalation_policies/user_select_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cafba643856b996454e19d3c957062e32fb9bd2f --- /dev/null +++ b/ee/spec/frontend/escalation_policies/user_select_spec.js @@ -0,0 +1,97 @@ +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); + }); + }); +}); diff --git a/ee/spec/frontend/escalation_policies/utils_spec.js b/ee/spec/frontend/escalation_policies/utils_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..06ae2a26390b3e20b8feab22b4f3a292d478913c --- /dev/null +++ b/ee/spec/frontend/escalation_policies/utils_spec.js @@ -0,0 +1,59 @@ +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); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b0017b7bbfe05244dbab1f600155324724889b6d..8d69e1a35911259f92b7726d0a77051b1e7e0f24 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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."