Commit a3645c1c authored by David O'Regan's avatar David O'Regan

Merge branch '268361-delete-escalation-policy' into 'master'

Delete escalation policy

See merge request gitlab-org/gitlab!63774
parents d88e5224 e450fa08
<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 { ...@@ -10,7 +10,13 @@ import {
GlCollapse, GlCollapse,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__, __ } from '~/locale'; 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 = { export const i18n = {
editPolicy: s__('EscalationPolicies|Edit escalation policy'), editPolicy: s__('EscalationPolicies|Edit escalation policy'),
...@@ -38,6 +44,7 @@ export default { ...@@ -38,6 +44,7 @@ export default {
GlSprintf, GlSprintf,
GlIcon, GlIcon,
GlCollapse, GlCollapse,
DeleteEscalationPolicyModal,
}, },
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
...@@ -68,86 +75,93 @@ export default { ...@@ -68,86 +75,93 @@ export default {
policyVisibleAngleIconLabel() { policyVisibleAngleIconLabel() {
return this.isPolicyVisible ? __('Collapse') : __('Expand'); return this.isPolicyVisible ? __('Collapse') : __('Expand');
}, },
deletePolicyModalId() {
return `${deleteEscalationPolicyModalId}-${this.policy.id}`;
},
}, },
}; };
</script> </script>
<template> <template>
<gl-card <div>
class="gl-mt-5" <gl-card
:class="{ 'gl-border-bottom-0': !isPolicyVisible }" class="gl-mt-5"
:body-class="{ 'gl-p-0': !isPolicyVisible }" :class="{ 'gl-border-bottom-0': !isPolicyVisible }"
:header-class="{ 'gl-py-3': true, 'gl-rounded-base': !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"> <template #header>
<gl-button <div class="gl-display-flex gl-align-items-center">
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">
<gl-button <gl-button
v-gl-tooltip v-gl-tooltip
:title="$options.i18n.editPolicy" class="gl-mr-2 gl-p-0!"
icon="pencil" :title="policyVisibleAngleIconLabel"
:aria-label="$options.i18n.editPolicy" :aria-label="policyVisibleAngleIconLabel"
disabled category="tertiary"
/> @click="isPolicyVisible = !isPolicyVisible"
<gl-button >
v-gl-tooltip <gl-icon :size="12" :name="policyVisibleAngleIcon" />
:title="$options.i18n.deletePolicy" </gl-button>
icon="remove"
:aria-label="$options.i18n.deletePolicy" <h3 class="gl-font-weight-bold gl-font-lg gl-m-0">{{ policy.name }}</h3>
disabled <gl-button-group class="gl-ml-auto">
/> <gl-button
</gl-button-group> v-gl-tooltip
</div> :title="$options.i18n.editPolicy"
</template> icon="pencil"
<gl-collapse :visible="isPolicyVisible"> :aria-label="$options.i18n.editPolicy"
<p v-if="policy.description" class="gl-text-gray-500 gl-mb-5"> disabled
{{ policy.description }} />
</p> <gl-button
<div class="gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base gl-p-5"> v-gl-modal="deletePolicyModalId"
<div v-gl-tooltip
v-for="(rule, ruleIndex) in policy.rules" :title="$options.i18n.deletePolicy"
:key="rule.id" :aria-label="$options.i18n.deletePolicy"
:class="{ 'gl-mb-5': ruleIndex !== policy.rules.length - 1 }" icon="remove"
> />
<gl-icon name="clock" class="gl-mr-3" /> </gl-button-group>
<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>
</div> </template>
</gl-collapse> <gl-collapse :visible="isPolicyVisible">
</gl-card> <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> </template>
...@@ -20,3 +20,4 @@ export const DEFAULT_ESCALATION_RULE = { ...@@ -20,3 +20,4 @@ export const DEFAULT_ESCALATION_RULE = {
export const addEscalationPolicyModalId = 'addEscalationPolicyModal'; export const addEscalationPolicyModalId = 'addEscalationPolicyModal';
export const editEscalationPolicyModalId = 'editEscalationPolicyModal'; export const editEscalationPolicyModalId = 'editEscalationPolicyModal';
export const deleteEscalationPolicyModalId = 'deleteEscalationPolicyModal';
import produce from 'immer'; 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 addEscalationPolicyToStore = (store, query, { escalationPolicyCreate }, variables) => {
const policy = escalationPolicyCreate?.escalationPolicy; const policy = escalationPolicyCreate?.escalationPolicy;
...@@ -22,10 +29,47 @@ const addEscalationPolicyToStore = (store, query, { escalationPolicyCreate }, va ...@@ -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; export const hasErrors = ({ errors = [] }) => errors?.length;
const onError = (data, message) => {
createFlash({ message });
throw new Error(data.errors);
};
export const updateStoreOnEscalationPolicyCreate = (store, query, data, variables) => { export const updateStoreOnEscalationPolicyCreate = (store, query, data, variables) => {
if (!hasErrors(data)) { if (!hasErrors(data)) {
addEscalationPolicyToStore(store, query, data, variables); 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 // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EscalationPolicy renders policy with rules 1`] = ` exports[`EscalationPolicy renders policy with rules 1`] = `
<gl-card-stub <div>
bodyclass="[object Object]" <gl-card-stub
class="gl-mt-5" bodyclass="[object Object]"
footerclass="" class="gl-mt-5"
headerclass="[object Object]" footerclass=""
> headerclass="[object Object]"
<gl-collapse-stub
visible="true"
> >
<p
class="gl-text-gray-500 gl-mb-5"
>
Description 1 lives here
</p>
<div <gl-collapse-stub
class="gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base gl-p-5" visible="true"
> >
<p
class="gl-text-gray-500 gl-mb-5"
>
Description 1 lives here
</p>
<div <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 <div
class="gl-mr-3" class="gl-mb-5"
name="clock"
size="16"
/>
IF alert is not
acknowledged
in
<span
class="gl-font-weight-bold"
> >
<gl-icon-stub
10 mins class="gl-mr-3"
name="clock"
</span> size="16"
<span
class="right-arrow"
>
<i
class="right-arrow-head"
/> />
</span> IF alert is not
acknowledged
<gl-icon-stub in
class="gl-mr-3" <span
name="notifications" class="gl-font-weight-bold"
size="16" >
/>
THEN
email on-call user in schedule
<span
class="gl-font-weight-bold"
>
Schedule to fill in
</span> 10 mins
</div>
<div </span>
class=""
> <span
<gl-icon-stub class="right-arrow"
class="gl-mr-3" >
name="clock" <i
size="16" class="right-arrow-head"
/> />
IF alert is not </span>
resolved
in <gl-icon-stub
<span class="gl-mr-3"
class="gl-font-weight-bold" name="notifications"
> size="16"
/>
20 mins THEN
email on-call user in schedule
<span
class="gl-font-weight-bold"
>
</span> Schedule to fill in
<span </span>
class="right-arrow" </div>
<div
class=""
> >
<i <gl-icon-stub
class="right-arrow-head" class="gl-mr-3"
name="clock"
size="16"
/> />
</span> IF alert is not
resolved
<gl-icon-stub in
class="gl-mr-3" <span
name="notifications" class="gl-font-weight-bold"
size="16" >
/>
THEN 20 mins
email on-call user in schedule
</span>
<span <span
class="gl-font-weight-bold" class="right-arrow"
> >
<i
Monitor schedule 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>
</div> </gl-collapse-stub>
</gl-collapse-stub> </gl-card-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 "" ...@@ -13045,6 +13045,9 @@ msgstr ""
msgid "EscalationPolicies|Add policy" msgid "EscalationPolicies|Add policy"
msgstr "" 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" msgid "EscalationPolicies|Create an escalation policy in GitLab"
msgstr "" msgstr ""
...@@ -13087,6 +13090,9 @@ msgstr "" ...@@ -13087,6 +13090,9 @@ msgstr ""
msgid "EscalationPolicies|THEN %{doAction} %{schedule}" msgid "EscalationPolicies|THEN %{doAction} %{schedule}"
msgstr "" msgstr ""
msgid "EscalationPolicies|The escalation policy could not be deleted. Please try again."
msgstr ""
msgid "EscalationPolicies|mins" msgid "EscalationPolicies|mins"
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