Skip to content
Snippets Groups Projects
Commit 37405ed1 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska Committed by Simon Knox
Browse files

Add escalation policy modal layout

parent 7316c9a7
No related branches found
No related tags found
No related merge requests found
Showing
with 629 additions and 37 deletions
.escalation-policy-modal {
width: 640px;
}
.escalation-policy-rules {
.rule-control {
width: 240px;
}
.rule-elapsed-minutes {
width: 56px;
}
}
Loading
Loading
@@ -214,6 +214,7 @@ class Application < Rails::Application
config.assets.precompile << "page_bundles/milestone.css"
config.assets.precompile << "page_bundles/new_namespace.css"
config.assets.precompile << "page_bundles/oncall_schedules.css"
config.assets.precompile << "page_bundles/escalation_policies.css"
config.assets.precompile << "page_bundles/pipeline.css"
config.assets.precompile << "page_bundles/pipeline_schedules.css"
config.assets.precompile << "page_bundles/pipelines.css"
Loading
Loading
<script>
import { GlLink, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { s__, __ } from '~/locale';
import { defaultEscalationRule } from '../constants';
import EscalationRule from './escalation_rule.vue';
export const i18n = {
fields: {
name: {
title: __('Name'),
validation: {
empty: __("Can't be empty"),
},
},
description: { title: __('Description (optional)') },
rules: {
title: s__('EscalationPolicies|Escalation rules'),
},
},
addRule: s__('EscalationPolicies|+ Add an additional rule'),
};
export default {
i18n,
components: {
GlLink,
GlForm,
GlFormGroup,
GlFormInput,
EscalationRule,
},
props: {
form: {
type: Object,
required: true,
},
validationState: {
type: Object,
required: true,
},
},
data() {
return {
rules: [cloneDeep(defaultEscalationRule)],
};
},
methods: {
addRule() {
this.rules.push(cloneDeep(defaultEscalationRule));
},
},
};
</script>
<template>
<gl-form>
<div class="w-75 gl-xs-w-full!">
<gl-form-group
data-testid="escalation-policy-name"
:label="$options.i18n.fields.name.title"
:invalid-feedback="$options.i18n.fields.name.validation.empty"
label-size="sm"
label-for="escalation-policy-name"
:state="validationState.name"
required
>
<gl-form-input
id="escalation-policy-name"
:value="form.name"
@blur="
$emit('update-escalation-policy-form', { field: 'name', value: $event.target.value })
"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.description.title"
label-size="sm"
label-for="escalation-policy-description"
>
<gl-form-input
id="escalation-policy-description"
:value="form.description"
@blur="
$emit('update-escalation-policy-form', {
field: 'description',
value: $event.target.value,
})
"
/>
</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>
<gl-link @click="addRule">
<span>{{ $options.i18n.addRule }}</span>
</gl-link>
</gl-form>
</template>
<script>
import { GlModal } from '@gitlab/ui';
import { set } from 'lodash';
import { s__, __ } from '~/locale';
import { addEscalationPolicyModalId } from '../constants';
import { isNameFieldValid } from '../utils';
import AddEditEscalationPolicyForm from './add_edit_escalation_policy_form.vue';
export const i18n = {
cancel: __('Cancel'),
addEscalationPolicy: s__('EscalationPolicies|Add escalation policy'),
editEscalationPolicy: s__('EscalationPolicies|Edit escalation policy'),
};
export default {
i18n,
addEscalationPolicyModalId,
components: {
GlModal,
AddEditEscalationPolicyForm,
},
props: {
escalationPolicy: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
loading: false,
form: {
name: this.escalationPolicy.name,
description: this.escalationPolicy.description,
},
validationState: {
name: true,
rules: true,
},
};
},
computed: {
actionsProps() {
return {
primary: {
text: i18n.addEscalationPolicy,
attributes: [
{ variant: 'info' },
{ loading: this.loading },
{ disabled: !this.isFormValid },
],
},
cancel: {
text: i18n.cancel,
},
};
},
isFormValid() {
return Object.values(this.validationState).every(Boolean);
},
},
methods: {
updateForm({ field, value }) {
set(this.form, field, value);
this.validateForm(field);
},
validateForm(field) {
if (field === 'name') {
this.validationState.name = isNameFieldValid(this.form.name);
}
},
},
};
</script>
<template>
<gl-modal
class="escalation-policy-modal"
:modal-id="$options.addEscalationPolicyModalId"
:title="$options.i18n.addEscalationPolicy"
:action-primary="actionsProps.primary"
:action-cancel="actionsProps.cancel"
>
<add-edit-escalation-policy-form
:validation-state="validationState"
:form="form"
@update-escalation-policy-form="updateForm"
/>
</gl-modal>
</template>
<script>
import { GlEmptyState, GlButton } from '@gitlab/ui';
import { GlEmptyState, GlButton, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import { addEscalationPolicyModalId } from '../constants';
import AddEscalationPolicyModal from './add_edit_escalation_policy_modal.vue';
 
export const i18n = {
emptyState: {
Loading
Loading
@@ -14,29 +16,32 @@ export const i18n = {
 
export default {
i18n,
addEscalationPolicyModalId,
components: {
GlEmptyState,
GlButton,
AddEscalationPolicyModal,
},
inject: ['emptyEscalationPoliciesSvgPath'],
methods: {
addEscalationPolicy() {
// TODO: Add method as part of https://gitlab.com/gitlab-org/gitlab/-/issues/268356
},
directives: {
GlModal: GlModalDirective,
},
inject: ['emptyEscalationPoliciesSvgPath'],
};
</script>
 
<template>
<gl-empty-state
:title="$options.i18n.emptyState.title"
:description="$options.i18n.emptyState.description"
:svg-path="emptyEscalationPoliciesSvgPath"
>
<template #actions>
<gl-button variant="info" @click="addEscalationPolicy">{{
$options.i18n.emptyState.button
}}</gl-button>
</template>
</gl-empty-state>
<div>
<gl-empty-state
:title="$options.i18n.emptyState.title"
:description="$options.i18n.emptyState.description"
:svg-path="emptyEscalationPoliciesSvgPath"
>
<template #actions>
<gl-button v-gl-modal="$options.addEscalationPolicyModalId" variant="confirm">
{{ $options.i18n.emptyState.button }}
</gl-button>
</template>
</gl-empty-state>
<add-escalation-policy-modal />
</div>
</template>
<script>
import { GlFormInput, GlDropdown, GlDropdownItem, GlCard, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import { ACTIONS, ALERT_STATUSES } from '../constants';
export const i18n = {
fields: {
rules: {
condition: s__('EscalationPolicies|IF alert is not %{alertStatus} in %{minutes} minutes'),
action: s__('EscalationPolicies|THEN %{doAction} %{schedule}'),
selectSchedule: s__('EscalationPolicies|Select schedule'),
},
},
};
export default {
i18n,
ALERT_STATUSES,
ACTIONS,
components: {
GlFormInput,
GlDropdown,
GlDropdownItem,
GlCard,
GlSprintf,
},
props: {
rule: {
type: Object,
required: true,
},
schedules: {
type: Array,
required: false,
default: () => [],
},
},
};
</script>
<template>
<gl-card class="gl-border-gray-400 gl-bg-gray-10 gl-mb-3">
<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]"
data-testid="alert-status-dropdown"
>
<gl-dropdown-item
v-for="(label, status) in $options.ALERT_STATUSES"
:key="status"
:is-checked="rule.status === status"
is-check-item
>
{{ label }}
</gl-dropdown-item>
</gl-dropdown>
</template>
<template #minutes>
<gl-form-input class="gl-mx-3 rule-elapsed-minutes" :value="0" />
</template>
</gl-sprintf>
</div>
<div class="gl-display-flex gl-align-items-center gl-mt-3">
<gl-sprintf :message="$options.i18n.fields.rules.action">
<template #doAction>
<gl-dropdown
class="rule-control gl-mx-3"
:text="$options.ACTIONS[rule.action]"
data-testid="action-dropdown"
>
<gl-dropdown-item
v-for="(label, action) in $options.ACTIONS"
:key="action"
:is-checked="rule.action === action"
is-check-item
>
{{ label }}
</gl-dropdown-item>
</gl-dropdown>
</template>
<template #schedule>
<gl-dropdown
class="rule-control gl-mx-3"
:text="$options.i18n.fields.rules.selectSchedule"
data-testid="schedules-dropdown"
>
<gl-dropdown-item v-for="schedule in schedules" :key="schedule.id" is-check-item>
{{ schedule.name }}
</gl-dropdown-item>
</gl-dropdown>
</template>
</gl-sprintf>
</div>
</gl-card>
</template>
import { s__ } from '~/locale';
export const ALERT_STATUSES = {
ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
RESOLVED: s__('AlertManagement|Resolved'),
};
export const ACTIONS = {
EMAIL_ONCALL_SCHEDULE_USER: s__('EscalationPolicies|Email on-call user in schedule'),
};
export const defaultEscalationRule = {
status: 'ACKNOWLEDGED',
elapsedTimeSeconds: 0,
action: 'EMAIL_ONCALL_SCHEDULE_USER',
oncallSchedule: {
iid: null,
name: null,
},
};
export const addEscalationPolicyModalId = 'addEscalationPolicyModal';
Loading
Loading
@@ -6,11 +6,12 @@ export default () => {
 
if (!el) return null;
 
const { emptyEscalationPoliciesSvgPath } = el.dataset;
const { emptyEscalationPoliciesSvgPath, projectPath = '' } = el.dataset;
 
return new Vue({
el,
provide: {
projectPath,
emptyEscalationPoliciesSvgPath,
},
render(createElement) {
Loading
Loading
/**
* Returns `true` for non-empty string, otherwise returns `false`
* @param {String} name
*
* @returns {Boolean}
*/
export const isNameFieldValid = (name) => {
return Boolean(name?.length);
};
Loading
Loading
@@ -62,11 +62,6 @@ export default {
type: Object,
required: true,
},
schedule: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
Loading
Loading
@@ -112,7 +107,7 @@ export default {
label-size="sm"
label-for="schedule-name"
:state="validationState.name"
requried
required
>
<gl-form-input
id="schedule-name"
Loading
Loading
@@ -140,7 +135,7 @@ export default {
:description="$options.i18n.fields.timezone.description"
:state="validationState.timezone"
:invalid-feedback="$options.i18n.fields.timezone.validation.empty"
requried
required
>
<gl-dropdown
id="schedule-timezone"
Loading
Loading
Loading
Loading
@@ -215,7 +215,6 @@ export default {
<add-edit-schedule-form
:validation-state="validationState"
:form="form"
:schedule="schedule"
@update-schedule-form="updateScheduleForm"
/>
</gl-modal>
Loading
Loading
- page_title _('Escalation policies')
- add_page_specific_style 'page_bundles/escalation_policies'
 
.js-escalation-policies{ data: escalation_policy_data }
import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import AddEscalationPolicyForm, {
i18n,
} from 'ee/escalation_policies/components/add_edit_escalation_policy_form.vue';
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', () => {
let wrapper;
const projectPath = 'group/project';
const createComponent = ({ props = {} } = {}) => {
wrapper = extendedWrapper(
shallowMount(AddEscalationPolicyForm, {
propsData: {
form: {
name: mockPolicy.name,
description: mockPolicy.description,
},
validationState: {
name: true,
},
...props,
},
provide: {
projectPath,
},
}),
);
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
const findPolicyName = () => wrapper.findByTestId('escalation-policy-name');
const findRules = () => wrapper.findAllComponents(EscalationRule);
const findAddRuleLink = () => wrapper.findComponent(GlLink);
describe('Escalation policy form validation', () => {
it('should show feedback for an invalid name input validation state', async () => {
createComponent({
props: {
validationState: { name: false },
},
});
expect(findPolicyName().attributes('state')).toBeFalsy();
});
});
describe('Escalation rules', () => {
it('should render one default rule', () => {
expect(findRules().length).toBe(1);
});
it('should contain a link to add escalation rules', () => {
const link = findAddRuleLink();
expect(link.exists()).toBe(true);
expect(link.text()).toMatchInterpolatedText(i18n.addRule);
});
it('should add an empty rule to the rules list on click', async () => {
findAddRuleLink().vm.$emit('click');
await wrapper.vm.$nextTick();
const rules = findRules();
expect(rules.length).toBe(2);
expect(rules.at(1).props('rule')).toEqual(defaultEscalationRule);
});
});
});
import { GlModal } 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';
describe('AddEscalationPolicyModal', () => {
let wrapper;
const projectPath = 'group/project';
const createComponent = ({ escalationPolicy, data } = {}) => {
wrapper = shallowMount(AddEscalationPolicyModal, {
data() {
return {
...data,
};
},
propsData: {
escalationPolicy,
},
provide: {
projectPath,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
const findModal = () => wrapper.findComponent(GlModal);
const findEscalationPolicyForm = () => wrapper.findComponent(AddEscalationPolicyForm);
describe('renders create modal with the correct information', () => {
it('renders modal title', () => {
expect(findModal().attributes('title')).toBe(i18n.addEscalationPolicy);
});
it('renders the form inside the modal', () => {
expect(findEscalationPolicyForm().exists()).toBe(true);
});
});
describe('modal buttons', () => {
it('should disable primary button when form is invalid', async () => {
findEscalationPolicyForm().vm.$emit('update-escalation-policy-form', {
field: 'name',
value: '',
});
await wrapper.vm.$nextTick();
expect(findModal().props('actionPrimary').attributes).toContainEqual({ disabled: true });
});
it('should enable primary button when form is valid', async () => {
findEscalationPolicyForm().vm.$emit('update-escalation-policy-form', {
field: 'name',
value: 'Some policy name',
});
await wrapper.vm.$nextTick();
expect(findModal().props('actionPrimary').attributes).toContainEqual({ disabled: false });
});
});
});
import { GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import OnCallScheduleWrapper, {
import EscalationPoliciesWrapper, {
i18n,
} from 'ee/escalation_policies/components/escalation_policies_wrapper.vue';
 
Loading
Loading
@@ -9,7 +9,7 @@ describe('AlertManagementEmptyState', () => {
const emptyEscalationPoliciesSvgPath = 'illustration/path.svg';
 
function mountComponent() {
wrapper = shallowMount(OnCallScheduleWrapper, {
wrapper = shallowMount(EscalationPoliciesWrapper, {
provide: {
emptyEscalationPoliciesSvgPath,
},
Loading
Loading
import { GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import EscalationRule from 'ee/escalation_policies/components/escalation_rule.vue';
import { defaultEscalationRule, ACTIONS, ALERT_STATUSES } from 'ee/escalation_policies/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
const mockSchedules = [
{ id: 1, name: 'schedule1' },
{ id: 2, name: 'schedule2' },
{ id: 3, name: 'schedule3' },
];
describe('EscalationRule', () => {
let wrapper;
const createComponent = ({ props = {} } = {}) => {
wrapper = extendedWrapper(
shallowMount(EscalationRule, {
propsData: {
rule: cloneDeep(defaultEscalationRule),
schedules: mockSchedules,
...props,
},
stubs: {
GlSprintf,
},
}),
);
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
const findStatusDropdown = () => wrapper.findByTestId('alert-status-dropdown');
const findStatusDropdownOptions = () => findStatusDropdown().findAll(GlDropdownItem);
const findActionDropdown = () => wrapper.findByTestId('action-dropdown');
const findActionDropdownOptions = () => findActionDropdown().findAll(GlDropdownItem);
const findSchedulesDropdown = () => wrapper.findByTestId('schedules-dropdown');
const findSchedulesDropdownOptions = () => findSchedulesDropdown().findAll(GlDropdownItem);
describe('Status dropdown', () => {
it('should have correct alert status options', () => {
expect(findStatusDropdownOptions().wrappers.map((w) => w.text())).toStrictEqual(
Object.values(ALERT_STATUSES),
);
});
it('should have default status selected', async () => {
expect(findStatusDropdownOptions().at(0).props('isChecked')).toBe(true);
});
});
describe('Actions dropdown', () => {
it('should have correct action options', () => {
expect(findActionDropdownOptions().wrappers.map((w) => w.text())).toStrictEqual(
Object.values(ACTIONS),
);
});
it('should have default action selected', async () => {
expect(findActionDropdownOptions().at(0).props('isChecked')).toBe(true);
});
});
describe('Schedules dropdown', () => {
it('should have correct schedules options', () => {
expect(findSchedulesDropdownOptions().wrappers.map((w) => w.text())).toStrictEqual(
mockSchedules.map(({ name }) => name),
);
});
});
});
{
"iid": "37",
"name": "Test ecsaltion policy",
"description": "Description 1 lives here",
"rules": []
}
// Jest Snapshot v1, https://goo.gl/fbAQLP
 
exports[`AddEditScheduleForm renders modal layout 1`] = `
<gl-form-stub
modalid="modalId"
>
exports[`AddEditScheduleForm renders form layout 1`] = `
<gl-form-stub>
<gl-form-group-stub
invalid-feedback="Can't be empty"
label="Name"
label-for="schedule-name"
label-size="sm"
requried=""
required=""
state="true"
>
<gl-form-input-stub
Loading
Loading
@@ -35,7 +33,7 @@ exports[`AddEditScheduleForm renders modal layout 1`] = `
label="Timezone"
label-for="schedule-timezone"
label-size="sm"
requried=""
required=""
state="true"
>
<gl-dropdown-stub
Loading
Loading
Loading
Loading
@@ -16,7 +16,6 @@ describe('AddEditScheduleForm', () => {
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(AddEditScheduleForm, {
propsData: {
modalId: 'modalId',
form: {
name: mockSchedule.name,
description: mockSchedule.description,
Loading
Loading
@@ -26,7 +25,6 @@ describe('AddEditScheduleForm', () => {
name: true,
timezone: true,
},
schedule: mockSchedule,
...props,
},
provide: {
Loading
Loading
@@ -54,7 +52,7 @@ describe('AddEditScheduleForm', () => {
const findTimezoneSearchBox = () => wrapper.find(GlSearchBoxByType);
const findScheduleName = () => wrapper.find(GlFormGroup);
 
it('renders modal layout', () => {
it('renders form layout', () => {
expect(wrapper.element).toMatchSnapshot();
});
 
Loading
Loading
Loading
Loading
@@ -13090,15 +13090,39 @@ msgstr ""
msgid "Escalation policies"
msgstr ""
 
msgid "EscalationPolicies|+ Add an additional rule"
msgstr ""
msgid "EscalationPolicies|Add an escalation policy"
msgstr ""
 
msgid "EscalationPolicies|Add escalation policy"
msgstr ""
msgid "EscalationPolicies|Create an escalation policy in GitLab"
msgstr ""
 
msgid "EscalationPolicies|Edit escalation policy"
msgstr ""
msgid "EscalationPolicies|Email on-call user in schedule"
msgstr ""
msgid "EscalationPolicies|Escalation rules"
msgstr ""
msgid "EscalationPolicies|IF alert is not %{alertStatus} in %{minutes} minutes"
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}"
msgstr ""
msgid "Estimate"
msgstr ""
 
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment