Commit 99a8861f authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '268356-add-escalation-pollicies-modal-integration' into 'master'

Add escalation policy BE integration

See merge request gitlab-org/gitlab!61815
parents 5e6b0b37 01673739
......@@ -2,13 +2,14 @@
width: 640px;
}
.escalation-policy-rules {
.rule-control {
.rule-control {
width: 240px;
}
}
.rule-elapsed-minutes {
.rule-elapsed-minutes {
width: 56px;
}
}
.rule-close-icon {
right: 1rem;
}
<script>
import { GlLink, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import createFlash from '~/flash';
import { s__, __ } from '~/locale';
import { defaultEscalationRule } from '../constants';
import getOncallSchedulesQuery from '../graphql/queries/get_oncall_schedules.query.graphql';
import EscalationRule from './escalation_rule.vue';
export const i18n = {
......@@ -19,6 +21,7 @@ export const i18n = {
},
},
addRule: s__('EscalationPolicies|+ Add an additional rule'),
failedLoadingSchedules: s__('EscalationPolicies|Failed to load oncall-schedules'),
};
export default {
......@@ -30,6 +33,7 @@ export default {
GlFormInput,
EscalationRule,
},
inject: ['projectPath'],
props: {
form: {
type: Object,
......@@ -42,12 +46,50 @@ export default {
},
data() {
return {
rules: [cloneDeep(defaultEscalationRule)],
schedules: [],
rules: [],
uid: 0,
};
},
apollo: {
schedules: {
query: getOncallSchedulesQuery,
variables() {
return {
projectPath: this.projectPath,
};
},
update(data) {
const nodes = data.project?.incidentManagementOncallSchedules?.nodes ?? [];
return nodes;
},
error(error) {
createFlash({ message: i18n.failedLoadingSchedules, captureError: true, error });
},
},
},
mounted() {
this.rules.push({ ...cloneDeep(defaultEscalationRule), key: this.getUid() });
},
methods: {
addRule() {
this.rules.push(cloneDeep(defaultEscalationRule));
this.rules.push({ ...cloneDeep(defaultEscalationRule), key: this.getUid() });
this.emitUpdate();
},
updateEscalationRules(index, rule) {
this.rules[index] = rule;
this.emitUpdate();
},
removeEscalationRule(index) {
this.rules.splice(index, 1);
this.emitUpdate();
},
emitUpdate() {
this.$emit('update-escalation-policy-form', { field: 'rules', value: this.rules });
},
getUid() {
this.uid += 1;
return this.uid;
},
},
};
......@@ -92,13 +134,17 @@ export default {
</gl-form-group>
</div>
<gl-form-group
class="escalation-policy-rules"
:label="$options.i18n.fields.rules.title"
label-size="sm"
:state="validationState.rules"
>
<escalation-rule v-for="(rule, index) in rules" :key="index" :rule="rule" />
<gl-form-group class="gl-mb-3" :label="$options.i18n.fields.rules.title" label-size="sm">
<escalation-rule
v-for="(rule, index) in rules"
:key="rule.key"
:rule="rule"
:index="index"
:schedules="schedules"
:is-valid="validationState.rules[index]"
@update-escalation-rule="updateEscalationRules"
@remove-escalation-rule="removeEscalationRule"
/>
</gl-form-group>
<gl-link @click="addRule">
<span>{{ $options.i18n.addRule }}</span>
......
<script>
import { GlModal } from '@gitlab/ui';
import { GlModal, GlAlert } from '@gitlab/ui';
import { set } from 'lodash';
import { s__, __ } from '~/locale';
import { addEscalationPolicyModalId } from '../constants';
import { isNameFieldValid } from '../utils';
import createEscalationPolicyMutation from '../graphql/mutations/create_escalation_policy.mutation.graphql';
import { isNameFieldValid, getRulesValidationState } from '../utils';
import AddEditEscalationPolicyForm from './add_edit_escalation_policy_form.vue';
export const i18n = {
......@@ -17,8 +18,10 @@ export default {
addEscalationPolicyModalId,
components: {
GlModal,
GlAlert,
AddEditEscalationPolicyForm,
},
inject: ['projectPath'],
props: {
escalationPolicy: {
type: Object,
......@@ -32,11 +35,13 @@ export default {
form: {
name: this.escalationPolicy.name,
description: this.escalationPolicy.description,
rules: [],
},
validationState: {
name: true,
rules: true,
rules: [],
},
error: null,
};
},
computed: {
......@@ -56,7 +61,15 @@ export default {
};
},
isFormValid() {
return Object.values(this.validationState).every(Boolean);
return this.validationState.name && this.validationState.rules.every(Boolean);
},
serializedData() {
const rules = this.form.rules.map(({ status, elapsedTimeSeconds, oncallScheduleIid }) => ({
status,
elapsedTimeSeconds,
oncallScheduleIid,
}));
return { ...this.form, rules };
},
},
methods: {
......@@ -64,10 +77,59 @@ export default {
set(this.form, field, value);
this.validateForm(field);
},
createEscalationPolicy() {
this.loading = true;
const { projectPath } = this;
this.$apollo
.mutate({
mutation: createEscalationPolicyMutation,
variables: {
input: {
projectPath,
...this.serializedData,
},
},
})
.then(
({
data: {
escalationPolicyCreate: {
errors: [error],
},
},
}) => {
if (error) {
throw error;
}
this.$refs.addUpdateEscalationPolicyModal.hide();
this.$emit('policyCreated');
this.clearForm();
},
)
.catch((error) => {
this.error = error;
})
.finally(() => {
this.loading = false;
});
},
validateForm(field) {
if (field === 'name') {
this.validationState.name = isNameFieldValid(this.form.name);
}
if (field === 'rules') {
this.validationState.rules = getRulesValidationState(this.form.rules);
}
},
hideErrorAlert() {
this.error = null;
},
clearForm() {
this.form = {
name: '',
description: '',
rules: [],
};
},
},
};
......@@ -75,12 +137,18 @@ export default {
<template>
<gl-modal
ref="addUpdateEscalationPolicyModal"
class="escalation-policy-modal"
:modal-id="$options.addEscalationPolicyModalId"
:title="$options.i18n.addEscalationPolicy"
:action-primary="actionsProps.primary"
:action-cancel="actionsProps.cancel"
@primary.prevent="createEscalationPolicy"
@cancel="clearForm"
>
<gl-alert v-if="error" variant="danger" class="gl-mt-n3 gl-mb-3" @dismiss="hideErrorAlert">
{{ error }}
</gl-alert>
<add-edit-escalation-policy-form
:validation-state="validationState"
:form="form"
......
<script>
import { GlFormInput, GlDropdown, GlDropdownItem, GlCard, GlSprintf } from '@gitlab/ui';
import {
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlCard,
GlIcon,
GlSprintf,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { ACTIONS, ALERT_STATUSES } from '../constants';
......@@ -9,6 +17,9 @@ export const i18n = {
condition: s__('EscalationPolicies|IF alert is not %{alertStatus} in %{minutes} minutes'),
action: s__('EscalationPolicies|THEN %{doAction} %{schedule}'),
selectSchedule: s__('EscalationPolicies|Select schedule'),
validationMsg: s__(
'EscalationPolicies|A schedule is required for adding an escalation policy.',
),
},
},
};
......@@ -18,10 +29,12 @@ export default {
ALERT_STATUSES,
ACTIONS,
components: {
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlCard,
GlIcon,
GlSprintf,
},
props: {
......@@ -34,32 +47,93 @@ export default {
required: false,
default: () => [],
},
index: {
type: Number,
required: true,
},
isValid: {
type: Boolean,
required: false,
default: true,
},
},
data() {
const { status, elapsedTimeSeconds, action, oncallScheduleIid } = this.rule;
return {
status,
elapsedTimeSeconds,
action,
oncallScheduleIid,
};
},
computed: {
scheduleDropdownTitle() {
return this.oncallScheduleIid
? this.schedules.find(({ iid }) => iid === this.oncallScheduleIid)?.name
: i18n.fields.rules.selectSchedule;
},
},
methods: {
setOncallSchedule({ iid }) {
this.oncallScheduleIid = this.oncallScheduleIid === iid ? null : iid;
this.emitUpdate();
},
setStatus(status) {
this.status = status;
this.emitUpdate();
},
emitUpdate() {
this.$emit('update-escalation-rule', this.index, {
oncallScheduleIid: parseInt(this.oncallScheduleIid, 10),
action: this.action,
status: this.status,
elapsedTimeSeconds: parseInt(this.elapsedTimeSeconds, 10),
});
},
},
};
</script>
<template>
<gl-card class="gl-border-gray-400 gl-bg-gray-10 gl-mb-3">
<gl-card class="gl-border-gray-400 gl-bg-gray-10 gl-mb-3 gl-relative">
<gl-icon
v-if="index !== 0"
name="close"
class="gl-absolute rule-close-icon"
@click="$emit('remove-escalation-rule', index)"
/>
<gl-form-group
:invalid-feedback="$options.i18n.fields.rules.validationMsg"
:state="isValid"
class="gl-mb-0"
>
<div class="gl-display-flex gl-align-items-center">
<gl-sprintf :message="$options.i18n.fields.rules.condition">
<template #alertStatus>
<gl-dropdown
class="rule-control gl-mx-3"
:text="$options.ALERT_STATUSES[rule.status]"
:text="$options.ALERT_STATUSES[status]"
data-testid="alert-status-dropdown"
>
<gl-dropdown-item
v-for="(label, status) in $options.ALERT_STATUSES"
:key="status"
:is-checked="rule.status === status"
v-for="(label, alertStatus) in $options.ALERT_STATUSES"
:key="alertStatus"
:is-checked="status === alertStatus"
is-check-item
@click="setStatus(alertStatus)"
>
{{ label }}
</gl-dropdown-item>
</gl-dropdown>
</template>
<template #minutes>
<gl-form-input class="gl-mx-3 rule-elapsed-minutes" :value="0" />
<gl-form-input
v-model="elapsedTimeSeconds"
class="gl-mx-3 gl-inset-border-1-gray-200! rule-elapsed-minutes"
type="number"
min="0"
@change="emitUpdate"
/>
</template>
</gl-sprintf>
</div>
......@@ -72,9 +146,9 @@ export default {
data-testid="action-dropdown"
>
<gl-dropdown-item
v-for="(label, action) in $options.ACTIONS"
:key="action"
:is-checked="rule.action === action"
v-for="(label, ruleAction) in $options.ACTIONS"
:key="ruleAction"
:is-checked="rule.action === ruleAction"
is-check-item
>
{{ label }}
......@@ -83,16 +157,28 @@ export default {
</template>
<template #schedule>
<gl-dropdown
class="rule-control gl-mx-3"
:text="$options.i18n.fields.rules.selectSchedule"
class="rule-control"
:text="scheduleDropdownTitle"
data-testid="schedules-dropdown"
>
<gl-dropdown-item v-for="schedule in schedules" :key="schedule.id" is-check-item>
<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>
</template>
</gl-sprintf>
</div>
</gl-form-group>
</gl-card>
</template>
......@@ -13,10 +13,7 @@ export const defaultEscalationRule = {
status: 'ACKNOWLEDGED',
elapsedTimeSeconds: 0,
action: 'EMAIL_ONCALL_SCHEDULE_USER',
oncallSchedule: {
iid: null,
name: null,
},
oncallScheduleIid: null,
};
export const addEscalationPolicyModalId = 'addEscalationPolicyModal';
mutation escalationPolicyCreate($input: EscalationPolicyCreateInput!) {
escalationPolicyCreate(input: $input) {
escalationPolicy {
id
name
description
rules {
status
elapsedTimeSeconds
oncallSchedule {
iid
name
}
}
}
errors
}
}
query getOncallSchedules($projectPath: ID!) {
project(fullPath: $projectPath) {
incidentManagementOncallSchedules {
nodes {
iid
name
}
}
}
}
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import EscalationPoliciesWrapper from './components/escalation_policies_wrapper.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
cacheConfig: {
dataIdFromObject: (object) => {
// eslint-disable-next-line no-underscore-dangle
if (object.__typename === 'IncidentManagementOncallSchedule') {
return object.iid;
}
return defaultDataIdFromObject(object);
},
},
},
),
});
export default () => {
const el = document.querySelector('.js-escalation-policies');
......@@ -10,6 +32,7 @@ export default () => {
return new Vue({
el,
apolloProvider,
provide: {
projectPath,
emptyEscalationPoliciesSvgPath,
......
......@@ -7,3 +7,13 @@
export const isNameFieldValid = (name) => {
return Boolean(name?.length);
};
/**
* Returns an array of booleans - validation state for each rule
* @param {Array} rules
*
* @returns {Array}
*/
export const getRulesValidationState = (rules) => {
return rules.map((rule) => Boolean(rule.oncallScheduleIid));
};
......@@ -2,8 +2,9 @@
module IncidentManagement
module EscalationPolicyHelper
def escalation_policy_data
def escalation_policy_data(project)
{
'project-path' => project.full_path,
'empty_escalation_policies_svg_path' => image_path('illustrations/empty-state/empty-escalation.svg')
}
end
......
- page_title _('Escalation policies')
- add_page_specific_style 'page_bundles/escalation_policies'
.js-escalation-policies{ data: escalation_policy_data }
.js-escalation-policies{ data: escalation_policy_data(@project) }
......@@ -38,7 +38,7 @@ module EE
end
::Sidebars::MenuItem.new(
title: _('Escalation policies'),
title: _('Escalation Policies'),
link: project_incident_management_escalation_policies_path(context.project),
active_routes: { controller: :escalation_policies },
item_id: :escalation_policies
......
......@@ -6,7 +6,6 @@ import AddEscalationPolicyForm, {
import EscalationRule from 'ee/escalation_policies/components/escalation_rule.vue';
import { defaultEscalationRule } from 'ee/escalation_policies/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import mockPolicy from './mocks/mockPolicy.json';
describe('AddEscalationPolicyForm', () => {
......@@ -23,6 +22,7 @@ describe('AddEscalationPolicyForm', () => {
},
validationState: {
name: true,
rules: [],
},
...props,
},
......@@ -46,13 +46,15 @@ describe('AddEscalationPolicyForm', () => {
const findAddRuleLink = () => wrapper.findComponent(GlLink);
describe('Escalation policy form validation', () => {
it('should show feedback for an invalid name input validation state', async () => {
it('should set correct validation state for validated controls', async () => {
createComponent({
props: {
validationState: { name: false },
validationState: { name: false, rules: [false] },
},
});
expect(findPolicyName().attributes('state')).toBeFalsy();
await wrapper.vm.$nextTick();
expect(findPolicyName().attributes('state')).toBeUndefined();
expect(findRules().at(0).attributes('is-valid')).toBeUndefined();
});
});
......@@ -72,7 +74,40 @@ describe('AddEscalationPolicyForm', () => {
await wrapper.vm.$nextTick();
const rules = findRules();
expect(rules.length).toBe(2);
expect(rules.at(1).props('rule')).toEqual(defaultEscalationRule);
expect(rules.at(1).props('rule')).toMatchObject(defaultEscalationRule);
});
it('should emit updates when rule is added', async () => {
findAddRuleLink().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(wrapper.emitted('update-escalation-policy-form')[0]).toMatchObject([
{
field: 'rules',
value: [
expect.objectContaining(defaultEscalationRule),
expect.objectContaining(defaultEscalationRule),
],
},
]);
});
it('on rule update emitted should update rules array and emit updates up', () => {
const updatedRule = {
status: 'TRIGGERED',
elapsedTimeSeconds: 30,
oncallScheduleIid: 2,
};
findRules().at(0).vm.$emit('update-escalation-rule', 0, updatedRule);
expect(wrapper.emitted('update-escalation-policy-form')[0]).toEqual([
{ field: 'rules', value: [updatedRule] },
]);
});
it('on rule removal emitted should update rules array and emit updates up', () => {
findRules().at(0).vm.$emit('remove-escalation-rule', 0);
expect(wrapper.emitted('update-escalation-policy-form')[0]).toEqual([
{ field: 'rules', value: [] },
]);
});
});
});
import { GlModal } from '@gitlab/ui';
import { GlModal, GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import AddEscalationPolicyForm from 'ee/escalation_policies/components/add_edit_escalation_policy_form.vue';
import AddEscalationPolicyModal, {
i18n,
} from 'ee/escalation_policies/components/add_edit_escalation_policy_modal.vue';
import waitForPromises from 'helpers/wait_for_promises';
import mockPolicy from './mocks/mockPolicy.json';
describe('AddEscalationPolicyModal', () => {
let wrapper;
const projectPath = 'group/project';
const mockHideModal = jest.fn();
const mutate = jest.fn();
const createComponent = ({ escalationPolicy, data } = {}) => {
wrapper = shallowMount(AddEscalationPolicyModal, {
data() {
return {
form: mockPolicy,
...data,
};
},
......@@ -22,7 +27,14 @@ describe('AddEscalationPolicyModal', () => {
provide: {
projectPath,
},
mocks: {
$apollo: {
mutate,
},
},
});
wrapper.vm.$refs.addUpdateEscalationPolicyModal.hide = mockHideModal;
};
beforeEach(() => {
createComponent();
......@@ -34,6 +46,7 @@ describe('AddEscalationPolicyModal', () => {
const findModal = () => wrapper.findComponent(GlModal);
const findEscalationPolicyForm = () => wrapper.findComponent(AddEscalationPolicyForm);
const findAlert = () => wrapper.findComponent(GlAlert);
describe('renders create modal with the correct information', () => {
it('renders modal title', () => {
......@@ -43,6 +56,49 @@ describe('AddEscalationPolicyModal', () => {
it('renders the form inside the modal', () => {
expect(findEscalationPolicyForm().exists()).toBe(true);
});
it('makes a request with form data to create an escalation policy', () => {
mutate.mockResolvedValueOnce({});
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
input: {
projectPath,
...mockPolicy,
},
},
}),
);
});
it('hides the modal on successful policy creation', async () => {
mutate.mockResolvedValueOnce({ data: { escalationPolicyCreate: { errors: [] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
expect(mockHideModal).toHaveBeenCalled();
});
it("doesn't hide a modal and shows error alert on creation failure", async () => {
const error = 'some error';
mutate.mockResolvedValueOnce({ data: { escalationPolicyCreate: { errors: [error] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
const alert = findAlert();
expect(mockHideModal).not.toHaveBeenCalled();
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain(error);
});
it('clears the form on modal close', () => {
expect(wrapper.vm.form).toEqual(mockPolicy);
findModal().vm.$emit('cancel', { preventDefault: jest.fn() });
expect(wrapper.vm.form).toEqual({
name: '',
description: '',
rules: [],
});
});
});
describe('modal buttons', () => {
......
import { GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { GlDropdownItem, GlFormGroup, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import EscalationRule from 'ee/escalation_policies/components/escalation_rule.vue';
......@@ -19,6 +19,8 @@ describe('EscalationRule', () => {
propsData: {
rule: cloneDeep(defaultEscalationRule),
schedules: mockSchedules,
index: 0,
isValid: false,
...props,
},
stubs: {
......@@ -45,6 +47,8 @@ describe('EscalationRule', () => {
const findSchedulesDropdown = () => wrapper.findByTestId('schedules-dropdown');
const findSchedulesDropdownOptions = () => findSchedulesDropdown().findAll(GlDropdownItem);
const findFormGroup = () => wrapper.findComponent(GlFormGroup);
describe('Status dropdown', () => {
it('should have correct alert status options', () => {
expect(findStatusDropdownOptions().wrappers.map((w) => w.text())).toStrictEqual(
......@@ -76,4 +80,19 @@ describe('EscalationRule', () => {
);
});
});
describe('Validation', () => {
it.each`
isValid | state
${true} | ${'true'}
${false} | ${undefined}
`('when $isValid sets from group state to $state', ({ isValid, state }) => {
createComponent({
props: {
isValid,
},
});
expect(findFormGroup().attributes('state')).toBe(state);
});
});
});
......@@ -3,11 +3,14 @@
require 'spec_helper'
RSpec.describe IncidentManagement::EscalationPolicyHelper do
let_it_be(:project) { create(:project) }
describe '#escalation_policy_data' do
subject(:data) { helper.escalation_policy_data }
subject(:data) { helper.escalation_policy_data(project) }
it 'returns scalation policies data' do
is_expected.to eq(
'project-path' => project.full_path,
'empty_escalation_policies_svg_path' => helper.image_path('illustrations/empty-state/empty-escalation.svg')
)
end
......
......@@ -26,7 +26,7 @@ RSpec.describe Sidebars::Projects::Menus::MonitorMenu do
end
end
describe 'Escalation policies' do
describe 'Escalation Policies' do
let(:item_id) { :escalation_policies }
before do
......
......@@ -275,7 +275,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
end
end
describe 'Escalation policies' do
describe 'Escalation Policies' do
before do
allow(view).to receive(:current_user).and_return(user)
stub_licensed_features(oncall_schedules: true, escalation_policies: true)
......@@ -284,7 +284,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
it 'has a link to the escalation policies page' do
render
expect(rendered).to have_link('Escalation policies', href: project_incident_management_escalation_policies_path(project))
expect(rendered).to have_link('Escalation Policies', href: project_incident_management_escalation_policies_path(project))
end
describe 'when the user does not have access' do
......@@ -293,7 +293,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
it 'does not have a link to the escalation policies page' do
render
expect(rendered).not_to have_link('Escalation policies')
expect(rendered).not_to have_link('Escalation Policies')
end
end
end
......
......@@ -13072,6 +13072,9 @@ msgstr ""
msgid "Errors:"
msgstr ""
msgid "Escalation Policies"
msgstr ""
msgid "Escalation policies"
msgstr ""
......@@ -13081,6 +13084,9 @@ msgstr ""
msgid "EscalationPolicies|+ Add an additional rule"
msgstr ""
msgid "EscalationPolicies|A schedule is required for adding an escalation policy."
msgstr ""
msgid "EscalationPolicies|Add an escalation policy"
msgstr ""
......@@ -13099,6 +13105,9 @@ msgstr ""
msgid "EscalationPolicies|Escalation rules"
msgstr ""
msgid "EscalationPolicies|Failed to load oncall-schedules"
msgstr ""
msgid "EscalationPolicies|IF alert is not %{alertStatus} in %{minutes} minutes"
msgstr ""
......
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