Commit e450fa08 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska Committed by David O'Regan

Delete escalation policy

parent f6140400
<script>
import { GlSprintf, GlModal, GlAlert } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { updateStoreAfterEscalationPolicyDelete } from '../graphql/cache_updates';
import destroyEscalationPolicyMutation from '../graphql/mutations/destroy_escalatiion_policy.mutation.graphql';
import getEscalationPoliciesQuery from '../graphql/queries/get_escalation_policies.query.graphql';
export const i18n = {
deleteEscalationPolicy: s__('EscalationPolicies|Delete escalation policy'),
deleteEscalationPolicyMessage: s__(
'EscalationPolicies|Are you sure you want to delete the "%{escalationPolicy}" escalation policy? This action cannot be undone.',
),
};
export default {
i18n,
components: {
GlSprintf,
GlModal,
GlAlert,
},
inject: ['projectPath'],
props: {
escalationPolicy: {
type: Object,
required: true,
},
modalId: {
type: String,
required: true,
},
},
data() {
return {
loading: false,
error: null,
};
},
computed: {
primaryProps() {
return {
text: this.$options.i18n.deleteEscalationPolicy,
attributes: [{ category: 'primary' }, { variant: 'danger' }, { loading: this.loading }],
};
},
cancelProps() {
return {
text: __('Cancel'),
};
},
},
methods: {
deleteEscalationPolicy() {
const {
escalationPolicy: { id },
projectPath,
} = this;
this.loading = true;
this.$apollo
.mutate({
mutation: destroyEscalationPolicyMutation,
variables: {
input: {
id,
},
},
update(store, { data }) {
updateStoreAfterEscalationPolicyDelete(store, getEscalationPoliciesQuery, data, {
projectPath,
});
},
})
.then(({ data: { escalationPolicyDestroy } = {} } = {}) => {
const error = escalationPolicyDestroy.errors[0];
if (error) {
throw error;
}
this.$refs.deleteEscalationPolicyModal.hide();
})
.catch((error) => {
this.error = error;
})
.finally(() => {
this.loading = false;
});
},
hideErrorAlert() {
this.error = null;
},
},
};
</script>
<template>
<gl-modal
ref="deleteEscalationPolicyModal"
:modal-id="modalId"
size="sm"
:title="$options.i18n.deleteEscalationPolicy"
:action-primary="primaryProps"
:action-cancel="cancelProps"
@primary.prevent="deleteEscalationPolicy"
>
<gl-alert v-if="error" variant="danger" class="gl-mt-n3 gl-mb-3" @dismiss="hideErrorAlert">
{{ error || $options.i18n.errorMsg }}
</gl-alert>
<gl-sprintf :message="$options.i18n.deleteEscalationPolicyMessage">
<template #escalationPolicy>{{ escalationPolicy.name }}</template>
</gl-sprintf>
</gl-modal>
</template>
......@@ -10,7 +10,13 @@ import {
GlCollapse,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { ACTIONS, ALERT_STATUSES, DEFAULT_ACTION } from '../constants';
import {
ACTIONS,
ALERT_STATUSES,
DEFAULT_ACTION,
deleteEscalationPolicyModalId,
} from '../constants';
import DeleteEscalationPolicyModal from './delete_escalation_policy_modal.vue';
export const i18n = {
editPolicy: s__('EscalationPolicies|Edit escalation policy'),
......@@ -38,6 +44,7 @@ export default {
GlSprintf,
GlIcon,
GlCollapse,
DeleteEscalationPolicyModal,
},
directives: {
GlModal: GlModalDirective,
......@@ -68,86 +75,93 @@ export default {
policyVisibleAngleIconLabel() {
return this.isPolicyVisible ? __('Collapse') : __('Expand');
},
deletePolicyModalId() {
return `${deleteEscalationPolicyModalId}-${this.policy.id}`;
},
},
};
</script>
<template>
<gl-card
class="gl-mt-5"
:class="{ 'gl-border-bottom-0': !isPolicyVisible }"
:body-class="{ 'gl-p-0': !isPolicyVisible }"
:header-class="{ 'gl-py-3': true, 'gl-rounded-base': !isPolicyVisible }"
>
<template #header>
<div class="gl-display-flex gl-align-items-center">
<gl-button
v-gl-tooltip
class="gl-mr-2 gl-p-0!"
:title="policyVisibleAngleIconLabel"
:aria-label="policyVisibleAngleIconLabel"
category="tertiary"
@click="isPolicyVisible = !isPolicyVisible"
>
<gl-icon :size="12" :name="policyVisibleAngleIcon" />
</gl-button>
<h3 class="gl-font-weight-bold gl-font-lg gl-m-0">{{ policy.name }}</h3>
<gl-button-group class="gl-ml-auto">
<div>
<gl-card
class="gl-mt-5"
:class="{ 'gl-border-bottom-0': !isPolicyVisible }"
:body-class="{ 'gl-p-0': !isPolicyVisible }"
:header-class="{ 'gl-py-3': true, 'gl-rounded-base': !isPolicyVisible }"
>
<template #header>
<div class="gl-display-flex gl-align-items-center">
<gl-button
v-gl-tooltip
:title="$options.i18n.editPolicy"
icon="pencil"
:aria-label="$options.i18n.editPolicy"
disabled
/>
<gl-button
v-gl-tooltip
:title="$options.i18n.deletePolicy"
icon="remove"
:aria-label="$options.i18n.deletePolicy"
disabled
/>
</gl-button-group>
</div>
</template>
<gl-collapse :visible="isPolicyVisible">
<p v-if="policy.description" class="gl-text-gray-500 gl-mb-5">
{{ policy.description }}
</p>
<div class="gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base gl-p-5">
<div
v-for="(rule, ruleIndex) in policy.rules"
:key="rule.id"
:class="{ 'gl-mb-5': ruleIndex !== policy.rules.length - 1 }"
>
<gl-icon name="clock" class="gl-mr-3" />
<gl-sprintf :message="$options.i18n.escalationRule">
<template #alertStatus>
{{ $options.ALERT_STATUSES[rule.status].toLowerCase() }}
</template>
<template #minutes>
<span class="gl-font-weight-bold">
{{ rule.elapsedTimeSeconds }} {{ $options.i18n.minutes }}
</span>
</template>
<template #then>
<span class="right-arrow">
<i class="right-arrow-head"></i>
</span>
<gl-icon name="notifications" class="gl-mr-3" />
</template>
<template #doAction>
{{ $options.ACTIONS[$options.DEFAULT_ACTION].toLowerCase() }}
</template>
<template #schedule>
<span class="gl-font-weight-bold">
{{ rule.oncallSchedule.name }}
</span>
</template>
</gl-sprintf>
class="gl-mr-2 gl-p-0!"
:title="policyVisibleAngleIconLabel"
:aria-label="policyVisibleAngleIconLabel"
category="tertiary"
@click="isPolicyVisible = !isPolicyVisible"
>
<gl-icon :size="12" :name="policyVisibleAngleIcon" />
</gl-button>
<h3 class="gl-font-weight-bold gl-font-lg gl-m-0">{{ policy.name }}</h3>
<gl-button-group class="gl-ml-auto">
<gl-button
v-gl-tooltip
:title="$options.i18n.editPolicy"
icon="pencil"
:aria-label="$options.i18n.editPolicy"
disabled
/>
<gl-button
v-gl-modal="deletePolicyModalId"
v-gl-tooltip
:title="$options.i18n.deletePolicy"
:aria-label="$options.i18n.deletePolicy"
icon="remove"
/>
</gl-button-group>
</div>
</div>
</gl-collapse>
</gl-card>
</template>
<gl-collapse :visible="isPolicyVisible">
<p v-if="policy.description" class="gl-text-gray-500 gl-mb-5">
{{ policy.description }}
</p>
<div class="gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base gl-p-5">
<div
v-for="(rule, ruleIndex) in policy.rules"
:key="rule.id"
:class="{ 'gl-mb-5': ruleIndex !== policy.rules.length - 1 }"
>
<gl-icon name="clock" class="gl-mr-3" />
<gl-sprintf :message="$options.i18n.escalationRule">
<template #alertStatus>
{{ $options.ALERT_STATUSES[rule.status].toLowerCase() }}
</template>
<template #minutes>
<span class="gl-font-weight-bold">
{{ rule.elapsedTimeSeconds }} {{ $options.i18n.minutes }}
</span>
</template>
<template #then>
<span class="right-arrow">
<i class="right-arrow-head"></i>
</span>
<gl-icon name="notifications" class="gl-mr-3" />
</template>
<template #doAction>
{{ $options.ACTIONS[$options.DEFAULT_ACTION].toLowerCase() }}
</template>
<template #schedule>
<span class="gl-font-weight-bold">
{{ rule.oncallSchedule.name }}
</span>
</template>
</gl-sprintf>
</div>
</div>
</gl-collapse>
</gl-card>
<delete-escalation-policy-modal :escalation-policy="policy" :modal-id="deletePolicyModalId" />
</div>
</template>
......@@ -20,3 +20,4 @@ export const DEFAULT_ESCALATION_RULE = {
export const addEscalationPolicyModalId = 'addEscalationPolicyModal';
export const editEscalationPolicyModalId = 'editEscalationPolicyModal';
export const deleteEscalationPolicyModalId = 'deleteEscalationPolicyModal';
import produce from 'immer';
import createFlash from '~/flash';
import { s__ } from '~/locale';
export const DELETE_ESCALATION_POLICY_ERROR = s__(
'EscalationPolicies|The escalation policy could not be deleted. Please try again.',
);
const addEscalationPolicyToStore = (store, query, { escalationPolicyCreate }, variables) => {
const policy = escalationPolicyCreate?.escalationPolicy;
......@@ -22,10 +29,47 @@ const addEscalationPolicyToStore = (store, query, { escalationPolicyCreate }, va
});
};
const deleteEscalationPolicFromStore = (store, query, { escalationPolicyDestroy }, variables) => {
const escalationPolicy = escalationPolicyDestroy?.escalationPolicy;
if (!escalationPolicy) {
return;
}
const sourceData = store.readQuery({
query,
variables,
});
const data = produce(sourceData, (draftData) => {
draftData.project.incidentManagementEscalationPolicies.nodes = draftData.project.incidentManagementEscalationPolicies.nodes.filter(
({ id }) => id !== escalationPolicy.id,
);
});
store.writeQuery({
query,
variables,
data,
});
};
export const hasErrors = ({ errors = [] }) => errors?.length;
const onError = (data, message) => {
createFlash({ message });
throw new Error(data.errors);
};
export const updateStoreOnEscalationPolicyCreate = (store, query, data, variables) => {
if (!hasErrors(data)) {
addEscalationPolicyToStore(store, query, data, variables);
}
};
export const updateStoreAfterEscalationPolicyDelete = (store, query, data, variables) => {
if (hasErrors(data)) {
onError(data, DELETE_ESCALATION_POLICY_ERROR);
} else {
deleteEscalationPolicFromStore(store, query, data, variables);
}
};
#import "../fragments/escalation_policy.fragment.graphql"
mutation DestroyEscalationPolicy($input: EscalationPolicyDestroyInput!) {
escalationPolicyDestroy(input: $input) {
escalationPolicy {
...EscalationPolicy
}
errors
}
}
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EscalationPolicy renders policy with rules 1`] = `
<gl-card-stub
bodyclass="[object Object]"
class="gl-mt-5"
footerclass=""
headerclass="[object Object]"
>
<gl-collapse-stub
visible="true"
<div>
<gl-card-stub
bodyclass="[object Object]"
class="gl-mt-5"
footerclass=""
headerclass="[object Object]"
>
<p
class="gl-text-gray-500 gl-mb-5"
>
Description 1 lives here
</p>
<div
class="gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base gl-p-5"
<gl-collapse-stub
visible="true"
>
<p
class="gl-text-gray-500 gl-mb-5"
>
Description 1 lives here
</p>
<div
class="gl-mb-5"
class="gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base gl-p-5"
>
<gl-icon-stub
class="gl-mr-3"
name="clock"
size="16"
/>
IF alert is not
acknowledged
in
<span
class="gl-font-weight-bold"
<div
class="gl-mb-5"
>
10 mins
</span>
<span
class="right-arrow"
>
<i
class="right-arrow-head"
<gl-icon-stub
class="gl-mr-3"
name="clock"
size="16"
/>
</span>
<gl-icon-stub
class="gl-mr-3"
name="notifications"
size="16"
/>
THEN
email on-call user in schedule
<span
class="gl-font-weight-bold"
>
Schedule to fill in
IF alert is not
acknowledged
in
<span
class="gl-font-weight-bold"
>
</span>
</div>
<div
class=""
>
<gl-icon-stub
class="gl-mr-3"
name="clock"
size="16"
/>
IF alert is not
resolved
in
<span
class="gl-font-weight-bold"
>
20 mins
10 mins
</span>
<span
class="right-arrow"
>
<i
class="right-arrow-head"
/>
</span>
<gl-icon-stub
class="gl-mr-3"
name="notifications"
size="16"
/>
THEN
email on-call user in schedule
<span
class="gl-font-weight-bold"
>
</span>
<span
class="right-arrow"
Schedule to fill in
</span>
</div>
<div
class=""
>
<i
class="right-arrow-head"
<gl-icon-stub
class="gl-mr-3"
name="clock"
size="16"
/>
</span>
<gl-icon-stub
class="gl-mr-3"
name="notifications"
size="16"
/>
THEN
email on-call user in schedule
IF alert is not
resolved
in
<span
class="gl-font-weight-bold"
>
20 mins
</span>
<span
class="gl-font-weight-bold"
>
Monitor schedule
<span
class="right-arrow"
>
<i
class="right-arrow-head"
/>
</span>
<gl-icon-stub
class="gl-mr-3"
name="notifications"
size="16"
/>
THEN
email on-call user in schedule
<span
class="gl-font-weight-bold"
>
</span>
Monitor schedule
</span>
</div>
</div>
</div>
</gl-collapse-stub>
</gl-card-stub>
</gl-collapse-stub>
</gl-card-stub>
<delete-escalation-policy-modal-stub
escalationpolicy="[object Object]"
modalid="deleteEscalationPolicyModal-37"
/>
</div>
`;
import { GlModal, GlAlert, GlSprintf } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import VueApollo from 'vue-apollo';
import DeleteEscalationPolicyModal, {
i18n,
} from 'ee/escalation_policies/components/delete_escalation_policy_modal.vue';
import { deleteEscalationPolicyModalId } from 'ee/escalation_policies/constants';
import destroyEscalationPolicyMutation from 'ee/escalation_policies/graphql/mutations/destroy_escalatiion_policy.mutation.graphql';
import getEscalationPoliciesQuery from 'ee/escalation_policies/graphql/queries/get_escalation_policies.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
destroyPolicyResponse,
destroyPolicyResponseWithErrors,
getEscalationPoliciesQueryResponse,
} from './mocks/apollo_mock';
import mockPolicies from './mocks/mockPolicies.json';
const projectPath = 'group/project';
const mutate = jest.fn();
const mockHideModal = jest.fn();
const localVue = createLocalVue();
describe('DeleteEscalationPolicyModal', () => {
let wrapper;
let fakeApollo;
let destroyPolicyHandler;
const escalationPolicy = cloneDeep(mockPolicies[0]);
const cachedPolicy =
getEscalationPoliciesQueryResponse.data.project.incidentManagementEscalationPolicies.nodes[0];
const createComponent = ({ data = {}, props = {} } = {}) => {
wrapper = shallowMount(DeleteEscalationPolicyModal, {
data() {
return {
...data,
};
},
propsData: {
modalId: deleteEscalationPolicyModalId,
escalationPolicy,
...props,
},
provide: {
projectPath,
},
mocks: {
$apollo: {
mutate,
},
},
stubs: { GlSprintf },
});
wrapper.vm.$refs.deleteEscalationPolicyModal.hide = mockHideModal;
};
function createComponentWithApollo({
destroyHandler = jest.fn().mockResolvedValue(destroyPolicyResponse),
} = {}) {
localVue.use(VueApollo);
destroyPolicyHandler = destroyHandler;
const requestHandlers = [
[getEscalationPoliciesQuery, jest.fn().mockResolvedValue(getEscalationPoliciesQueryResponse)],
[destroyEscalationPolicyMutation, destroyPolicyHandler],
];
fakeApollo = createMockApollo(requestHandlers);
fakeApollo.clients.defaultClient.cache.writeQuery({
query: getEscalationPoliciesQuery,
variables: {
projectPath: 'group/project',
},
data: getEscalationPoliciesQueryResponse.data,
});
wrapper = shallowMount(DeleteEscalationPolicyModal, {
localVue,
apolloProvider: fakeApollo,
propsData: {
escalationPolicy: cachedPolicy,
modalId: deleteEscalationPolicyModalId,
},
provide: {
projectPath,
},
stubs: {
GlSprintf,
},
});
}
const findModal = () => wrapper.findComponent(GlModal);
const findAlert = () => wrapper.findComponent(GlAlert);
async function awaitApolloDomMock() {
await wrapper.vm.$nextTick(); // kick off the DOM update
await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises)
await wrapper.vm.$nextTick(); // kick off the DOM update
}
async function deleteEscalationPolicy(localWrapper) {
localWrapper.findComponent(GlModal).vm.$emit('primary', { preventDefault: jest.fn() });
}
afterEach(() => {
wrapper.destroy();
});
describe('layout', () => {
beforeEach(() => {
createComponent();
});
it('sets correct `modalId`', () => {
expect(findModal().props('modalId')).toBe(deleteEscalationPolicyModalId);
});
it('renders the confirmation message with provided policy name', () => {
expect(wrapper.text()).toBe(
i18n.deleteEscalationPolicyMessage.replace('%{escalationPolicy}', escalationPolicy.name),
);
});
});
describe('actions', () => {
beforeEach(() => {
createComponent();
});
it('makes a request to delete an escalation policy on delete confirmation', () => {
mutate.mockResolvedValueOnce({});
deleteEscalationPolicy(wrapper);
expect(mutate).toHaveBeenCalledWith({
mutation: destroyEscalationPolicyMutation,
update: expect.any(Function),
variables: { input: { id: escalationPolicy.id } },
});
});
it('hides the modal on successful escalation policy deletion', async () => {
mutate.mockResolvedValueOnce({ data: { escalationPolicyDestroy: { errors: [] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
expect(mockHideModal).toHaveBeenCalled();
});
it("doesn't hide the modal and shows an error alert on deletion fail", async () => {
const error = 'some error';
mutate.mockResolvedValueOnce({ data: { escalationPolicyDestroy: { errors: [error] } } });
deleteEscalationPolicy(wrapper);
await waitForPromises();
const alert = findAlert();
expect(mockHideModal).not.toHaveBeenCalled();
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain(error);
});
});
describe('with mocked Apollo client', () => {
it('has the name of the escalation policy to delete based on `getEscalationPoliciesQuery` response', async () => {
createComponentWithApollo();
await jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(findModal().text()).toContain(cachedPolicy.name);
});
it('calls a mutation with correct parameters to a policy', async () => {
createComponentWithApollo();
await deleteEscalationPolicy(wrapper);
expect(destroyPolicyHandler).toHaveBeenCalledWith({
input: { id: cachedPolicy.id },
});
});
it('displays alert if mutation had a recoverable error', async () => {
createComponentWithApollo({
destroyHandler: jest.fn().mockResolvedValue(destroyPolicyResponseWithErrors),
});
await deleteEscalationPolicy(wrapper);
await awaitApolloDomMock();
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain(
destroyPolicyResponseWithErrors.data.escalationPolicyDestroy.errors[0],
);
});
});
});
export const getEscalationPoliciesQueryResponse = {
data: {
project: {
incidentManagementEscalationPolicies: {
nodes: [
{
__typename: 'EscalationPolicyType',
id: 'gid://gitlab/IncidentManagement::EscalationPolicy/25',
name: 'Policy',
description: 'Monitor policy description',
rules: [
{
id: 'gid://gitlab/IncidentManagement::EscalationRule/35',
status: 'ACKNOWLEDGED',
elapsedTimeSeconds: 60,
oncallSchedule: {
iid: '1',
name: 'Schedule',
__typename: 'IncidentManagementOncallSchedule',
},
__typename: 'EscalationRuleType',
},
],
},
],
},
},
},
};
export const destroyPolicyResponse = {
data: {
escalationPolicyDestroy: {
escalationPolicy: {
__typename: 'EscalationPolicyType',
id: 'gid://gitlab/IncidentManagement::EscalationPolicy/25',
name: 'Policy',
description: 'Monitor policy description',
rules: [],
},
errors: [],
__typename: 'EscalationPolicyDestroyPayload',
},
},
};
export const destroyPolicyResponseWithErrors = {
data: {
escalationPolicyDestroy: {
escalationPolicy: {
__typename: 'EscalationPolicyType',
id: 'gid://gitlab/IncidentManagement::EscalationPolicy/25',
name: 'Policy',
description: 'Monitor policy description',
rules: [],
},
errors: ['Ooh, somethigb went wrong!'],
__typename: 'EscalationPolicyDestroyPayload',
},
},
};
......@@ -13045,6 +13045,9 @@ msgstr ""
msgid "EscalationPolicies|Add policy"
msgstr ""
msgid "EscalationPolicies|Are you sure you want to delete the \"%{escalationPolicy}\" escalation policy? This action cannot be undone."
msgstr ""
msgid "EscalationPolicies|Create an escalation policy in GitLab"
msgstr ""
......@@ -13087,6 +13090,9 @@ msgstr ""
msgid "EscalationPolicies|THEN %{doAction} %{schedule}"
msgstr ""
msgid "EscalationPolicies|The escalation policy could not be deleted. Please try again."
msgstr ""
msgid "EscalationPolicies|mins"
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