Commit 37405ed1 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska Committed by Simon Knox

Add escalation policy modal layout

parent 7316c9a7
.escalation-policy-modal {
width: 640px;
}
.escalation-policy-rules {
.rule-control {
width: 240px;
}
.rule-elapsed-minutes {
width: 56px;
}
}
...@@ -214,6 +214,7 @@ module Gitlab ...@@ -214,6 +214,7 @@ module Gitlab
config.assets.precompile << "page_bundles/milestone.css" config.assets.precompile << "page_bundles/milestone.css"
config.assets.precompile << "page_bundles/new_namespace.css" config.assets.precompile << "page_bundles/new_namespace.css"
config.assets.precompile << "page_bundles/oncall_schedules.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.css"
config.assets.precompile << "page_bundles/pipeline_schedules.css" config.assets.precompile << "page_bundles/pipeline_schedules.css"
config.assets.precompile << "page_bundles/pipelines.css" config.assets.precompile << "page_bundles/pipelines.css"
......
<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> <script>
import { GlEmptyState, GlButton } from '@gitlab/ui'; import { GlEmptyState, GlButton, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { addEscalationPolicyModalId } from '../constants';
import AddEscalationPolicyModal from './add_edit_escalation_policy_modal.vue';
export const i18n = { export const i18n = {
emptyState: { emptyState: {
...@@ -14,29 +16,32 @@ export const i18n = { ...@@ -14,29 +16,32 @@ export const i18n = {
export default { export default {
i18n, i18n,
addEscalationPolicyModalId,
components: { components: {
GlEmptyState, GlEmptyState,
GlButton, GlButton,
AddEscalationPolicyModal,
}, },
inject: ['emptyEscalationPoliciesSvgPath'], directives: {
methods: { GlModal: GlModalDirective,
addEscalationPolicy() {
// TODO: Add method as part of https://gitlab.com/gitlab-org/gitlab/-/issues/268356
},
}, },
inject: ['emptyEscalationPoliciesSvgPath'],
}; };
</script> </script>
<template> <template>
<gl-empty-state <div>
:title="$options.i18n.emptyState.title" <gl-empty-state
:description="$options.i18n.emptyState.description" :title="$options.i18n.emptyState.title"
:svg-path="emptyEscalationPoliciesSvgPath" :description="$options.i18n.emptyState.description"
> :svg-path="emptyEscalationPoliciesSvgPath"
<template #actions> >
<gl-button variant="info" @click="addEscalationPolicy">{{ <template #actions>
$options.i18n.emptyState.button <gl-button v-gl-modal="$options.addEscalationPolicyModalId" variant="confirm">
}}</gl-button> {{ $options.i18n.emptyState.button }}
</template> </gl-button>
</gl-empty-state> </template>
</gl-empty-state>
<add-escalation-policy-modal />
</div>
</template> </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';
...@@ -6,11 +6,12 @@ export default () => { ...@@ -6,11 +6,12 @@ export default () => {
if (!el) return null; if (!el) return null;
const { emptyEscalationPoliciesSvgPath } = el.dataset; const { emptyEscalationPoliciesSvgPath, projectPath = '' } = el.dataset;
return new Vue({ return new Vue({
el, el,
provide: { provide: {
projectPath,
emptyEscalationPoliciesSvgPath, emptyEscalationPoliciesSvgPath,
}, },
render(createElement) { render(createElement) {
......
/**
* Returns `true` for non-empty string, otherwise returns `false`
* @param {String} name
*
* @returns {Boolean}
*/
export const isNameFieldValid = (name) => {
return Boolean(name?.length);
};
...@@ -62,11 +62,6 @@ export default { ...@@ -62,11 +62,6 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
schedule: {
type: Object,
required: false,
default: () => ({}),
},
}, },
data() { data() {
return { return {
...@@ -112,7 +107,7 @@ export default { ...@@ -112,7 +107,7 @@ export default {
label-size="sm" label-size="sm"
label-for="schedule-name" label-for="schedule-name"
:state="validationState.name" :state="validationState.name"
requried required
> >
<gl-form-input <gl-form-input
id="schedule-name" id="schedule-name"
...@@ -140,7 +135,7 @@ export default { ...@@ -140,7 +135,7 @@ export default {
:description="$options.i18n.fields.timezone.description" :description="$options.i18n.fields.timezone.description"
:state="validationState.timezone" :state="validationState.timezone"
:invalid-feedback="$options.i18n.fields.timezone.validation.empty" :invalid-feedback="$options.i18n.fields.timezone.validation.empty"
requried required
> >
<gl-dropdown <gl-dropdown
id="schedule-timezone" id="schedule-timezone"
......
...@@ -215,7 +215,6 @@ export default { ...@@ -215,7 +215,6 @@ export default {
<add-edit-schedule-form <add-edit-schedule-form
:validation-state="validationState" :validation-state="validationState"
:form="form" :form="form"
:schedule="schedule"
@update-schedule-form="updateScheduleForm" @update-schedule-form="updateScheduleForm"
/> />
</gl-modal> </gl-modal>
......
- page_title _('Escalation policies') - 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 }
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 { GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import OnCallScheduleWrapper, { import EscalationPoliciesWrapper, {
i18n, i18n,
} from 'ee/escalation_policies/components/escalation_policies_wrapper.vue'; } from 'ee/escalation_policies/components/escalation_policies_wrapper.vue';
...@@ -9,7 +9,7 @@ describe('AlertManagementEmptyState', () => { ...@@ -9,7 +9,7 @@ describe('AlertManagementEmptyState', () => {
const emptyEscalationPoliciesSvgPath = 'illustration/path.svg'; const emptyEscalationPoliciesSvgPath = 'illustration/path.svg';
function mountComponent() { function mountComponent() {
wrapper = shallowMount(OnCallScheduleWrapper, { wrapper = shallowMount(EscalationPoliciesWrapper, {
provide: { provide: {
emptyEscalationPoliciesSvgPath, emptyEscalationPoliciesSvgPath,
}, },
......
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 // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddEditScheduleForm renders modal layout 1`] = ` exports[`AddEditScheduleForm renders form layout 1`] = `
<gl-form-stub <gl-form-stub>
modalid="modalId"
>
<gl-form-group-stub <gl-form-group-stub
invalid-feedback="Can't be empty" invalid-feedback="Can't be empty"
label="Name" label="Name"
label-for="schedule-name" label-for="schedule-name"
label-size="sm" label-size="sm"
requried="" required=""
state="true" state="true"
> >
<gl-form-input-stub <gl-form-input-stub
...@@ -35,7 +33,7 @@ exports[`AddEditScheduleForm renders modal layout 1`] = ` ...@@ -35,7 +33,7 @@ exports[`AddEditScheduleForm renders modal layout 1`] = `
label="Timezone" label="Timezone"
label-for="schedule-timezone" label-for="schedule-timezone"
label-size="sm" label-size="sm"
requried="" required=""
state="true" state="true"
> >
<gl-dropdown-stub <gl-dropdown-stub
......
...@@ -16,7 +16,6 @@ describe('AddEditScheduleForm', () => { ...@@ -16,7 +16,6 @@ describe('AddEditScheduleForm', () => {
const createComponent = ({ props = {} } = {}) => { const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(AddEditScheduleForm, { wrapper = shallowMount(AddEditScheduleForm, {
propsData: { propsData: {
modalId: 'modalId',
form: { form: {
name: mockSchedule.name, name: mockSchedule.name,
description: mockSchedule.description, description: mockSchedule.description,
...@@ -26,7 +25,6 @@ describe('AddEditScheduleForm', () => { ...@@ -26,7 +25,6 @@ describe('AddEditScheduleForm', () => {
name: true, name: true,
timezone: true, timezone: true,
}, },
schedule: mockSchedule,
...props, ...props,
}, },
provide: { provide: {
...@@ -54,7 +52,7 @@ describe('AddEditScheduleForm', () => { ...@@ -54,7 +52,7 @@ describe('AddEditScheduleForm', () => {
const findTimezoneSearchBox = () => wrapper.find(GlSearchBoxByType); const findTimezoneSearchBox = () => wrapper.find(GlSearchBoxByType);
const findScheduleName = () => wrapper.find(GlFormGroup); const findScheduleName = () => wrapper.find(GlFormGroup);
it('renders modal layout', () => { it('renders form layout', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
......
...@@ -13090,15 +13090,39 @@ msgstr "" ...@@ -13090,15 +13090,39 @@ msgstr ""
msgid "Escalation policies" msgid "Escalation policies"
msgstr "" msgstr ""
msgid "EscalationPolicies|+ Add an additional rule"
msgstr ""
msgid "EscalationPolicies|Add an escalation policy" msgid "EscalationPolicies|Add an escalation policy"
msgstr "" msgstr ""
msgid "EscalationPolicies|Add escalation policy"
msgstr ""
msgid "EscalationPolicies|Create an escalation policy in GitLab" msgid "EscalationPolicies|Create an escalation policy in GitLab"
msgstr "" 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." msgid "EscalationPolicies|Set up escalation policies to define who is paged, and when, in the event the first users paged don't respond."
msgstr "" msgstr ""
msgid "EscalationPolicies|THEN %{doAction} %{schedule}"
msgstr ""
msgid "Estimate" msgid "Estimate"
msgstr "" 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