Commit 06c48805 authored by David O'Regan's avatar David O'Regan

Merge branch '262863-delete-rotation' into 'master'

Resolve "Delete Rotation"

See merge request gitlab-org/gitlab!50828
parents cbcc0b6e e5e8441a
......@@ -2,3 +2,4 @@
filenames:
- ee/app/assets/javascripts/on_demand_scans/graphql/dast_scan_create.mutation.graphql
- ee/app/assets/javascripts/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql
- ee/app/assets/javascripts/oncall_schedules/graphql/mutations/destroy_oncall_rotation.mutation.graphql
<script>
import { isEmpty } from 'lodash';
import { GlSprintf, GlModal, GlAlert } from '@gitlab/ui';
import destroyOncallRotationMutation from 'ee/oncall_schedules/graphql/mutations/destroy_oncall_rotation.mutation.graphql';
import getOncallSchedulesQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql';
import { updateStoreAfterRotationDelete } from 'ee/oncall_schedules/utils/cache_updates';
import { s__, __ } from '~/locale';
export const i18n = {
deleteRotation: s__('OnCallSchedules|Delete rotation'),
deleteRotationMessage: s__(
'OnCallSchedules|Are you sure you want to delete the "%{deleteRotation}" rotation? This action cannot be undone.',
),
cancel: __('Cancel'),
};
export default {
i18n,
components: {
GlSprintf,
GlModal,
GlAlert,
},
inject: ['projectPath'],
props: {
rotation: {
type: Object,
required: true,
validator: (rotation) =>
isEmpty(rotation) || [rotation.id, rotation.name, rotation.startsAt].every(Boolean),
},
modalId: {
type: String,
required: true,
},
},
data() {
return {
loading: false,
error: '',
};
},
computed: {
primaryProps() {
return {
text: this.$options.i18n.deleteRotation,
attributes: [{ category: 'primary' }, { variant: 'danger' }, { loading: this.loading }],
};
},
cancelProps() {
return {
text: this.$options.i18n.cancel,
};
},
rotationDeleteModalTestId() {
return `delete-rotation-modal-${this.rotation.id}`;
},
},
methods: {
deleteRotation() {
const {
projectPath,
rotation: { id },
} = this;
this.loading = true;
this.$apollo
.mutate({
mutation: destroyOncallRotationMutation,
variables: {
iid: id,
projectPath,
},
update(store, { data }) {
updateStoreAfterRotationDelete(store, getOncallSchedulesQuery, data, { projectPath });
},
})
.then(({ data: { oncallRotationDestroy } = {} } = {}) => {
const error = oncallRotationDestroy.errors[0];
if (error) {
throw error;
}
this.$refs.deleteRotationModal.hide();
})
.catch((error) => {
this.error = error;
})
.finally(() => {
this.loading = false;
});
},
hideErrorAlert() {
this.error = '';
},
},
};
</script>
<template>
<gl-modal
ref="deleteRotationModal"
:modal-id="modalId"
size="sm"
:data-testid="rotationDeleteModalTestId"
:title="$options.i18n.deleteRotation"
:action-primary="primaryProps"
:action-cancel="cancelProps"
@primary.prevent="deleteRotation"
@cancel="$emit('set-rotation-to-update', {})"
>
<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.deleteRotationMessage">
<template #deleteRotation>{{ rotation.name }}</template>
</gl-sprintf>
</gl-modal>
</template>
......@@ -3,7 +3,8 @@ import { GlButtonGroup, GlButton, GlTooltipDirective, GlModalDirective } from '@
import { s__ } from '~/locale';
import CurrentDayIndicator from './current_day_indicator.vue';
import RotationAssignee from '../../rotations/components/rotation_assignee.vue';
import { editRotationModalId } from '../../../constants';
import DeleteRotationModal from '../../rotations/components/delete_rotation_modal.vue';
import { editRotationModalId, deleteRotationModalId } from '../../../constants';
export const i18n = {
editRotationLabel: s__('OnCallSchedules|Edit rotation'),
......@@ -13,11 +14,13 @@ export const i18n = {
export default {
i18n,
editRotationModalId,
deleteRotationModalId,
components: {
GlButtonGroup,
GlButton,
CurrentDayIndicator,
RotationAssignee,
DeleteRotationModal,
},
directives: {
GlModal: GlModalDirective,
......@@ -37,6 +40,16 @@ export default {
required: true,
},
},
data() {
return {
rotationToUpdate: {},
};
},
methods: {
setRotationToUpdate(rotation) {
this.rotationToUpdate = rotation;
},
},
};
</script>
......@@ -61,12 +74,13 @@ export default {
:aria-label="$options.i18n.editRotationLabel"
/>
<gl-button
v-gl-modal="$options.editRotationModalId"
v-gl-modal="$options.deleteRotationModalId"
v-gl-tooltip
category="tertiary"
:title="$options.i18n.deleteRotationLabel"
icon="remove"
:aria-label="$options.i18n.deleteRotationLabel"
@click="setRotationToUpdate(rotation)"
/>
</gl-button-group>
</span>
......@@ -85,5 +99,10 @@ export default {
/>
</span>
</div>
<delete-rotation-modal
:rotation="rotationToUpdate"
:modal-id="$options.deleteRotationModalId"
@set-rotation-to-update="setRotationToUpdate"
/>
</div>
</template>
......@@ -23,3 +23,4 @@ export const PRESET_DEFAULTS = {
export const addRotationModalId = 'addRotationModal';
export const editRotationModalId = 'editRotationModal';
export const deleteRotationModalId = 'deleteRotationModal';
#import "../fragments/oncall_schedule_rotation.fragment.graphql"
mutation oncallRotationDestroy($iid: String!, $projectPath: ID!) {
oncallRotationDestroy(input: { iid: $iid, projectPath: $projectPath }) {
errors
oncallRotation {
...OnCallRotation
}
}
}
......@@ -5,6 +5,7 @@ import {
DELETE_SCHEDULE_ERROR,
UPDATE_SCHEDULE_ERROR,
UPDATE_ROTATION_ERROR,
DELETE_ROTATION_ERROR,
} from './error_messages';
const addScheduleToStore = (store, query, { oncallSchedule: schedule }, variables) => {
......@@ -138,6 +139,32 @@ const updateRotationFromStore = (store, query, { oncallRotationUpdate }, schedul
});
};
const deleteRotationFromStore = (store, query, { oncallRotationDestroy }, variables) => {
const rotation = oncallRotationDestroy?.oncallRotation;
if (!rotation) {
return;
}
const sourceData = store.readQuery({
query,
variables,
});
// TODO: This needs the rotation backend to be fully integrated to work, for the moment we will place-hold it. https://gitlab.com/gitlab-org/gitlab/-/issues/262863
const data = produce(sourceData, (draftData) => {
// eslint-disable-next-line no-param-reassign
draftData.project.incidentManagementOncallSchedules.nodes[0].rotations = [rotation].filter(
({ id }) => id !== rotation.id,
);
});
store.writeQuery({
query,
variables,
data,
});
};
const onError = (data, message) => {
createFlash({ message });
throw new Error(data.errors);
......@@ -180,3 +207,11 @@ export const updateStoreAfterRotationEdit = (store, query, data, scheduleId, var
updateRotationFromStore(store, query, data, scheduleId, variables);
}
};
export const updateStoreAfterRotationDelete = (store, query, data, variables) => {
if (hasErrors(data)) {
onError(data, DELETE_ROTATION_ERROR);
} else {
deleteRotationFromStore(store, query, data, variables);
}
};
......@@ -11,3 +11,7 @@ export const UPDATE_SCHEDULE_ERROR = s__(
export const UPDATE_ROTATION_ERROR = s__(
'OnCallSchedules|The rotation could not be updated. Please try again.',
);
export const DELETE_ROTATION_ERROR = s__(
'OnCallSchedules|The rotation could not be deleted. Please try again.',
);
......@@ -47,7 +47,6 @@ describe('AddEditScheduleForm', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findTimezoneDropdown = () => wrapper.find(GlDropdown);
......
......@@ -22,8 +22,6 @@ const mockHideModal = jest.fn();
const schedule =
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
localVue.use(VueApollo);
describe('DeleteScheduleModal', () => {
let wrapper;
let fakeApollo;
......@@ -40,9 +38,6 @@ describe('DeleteScheduleModal', () => {
}
async function destroySchedule(localWrapper) {
await jest.runOnlyPendingTimers();
await localWrapper.vm.$nextTick();
localWrapper.find(GlModal).vm.$emit('primary', { preventDefault: jest.fn() });
}
......@@ -111,7 +106,6 @@ describe('DeleteScheduleModal', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders delete schedule modal layout', () => {
......
import mockRotations from './mock_rotation.json';
export const participants = [
{
id: '1',
......@@ -165,3 +167,27 @@ export const createRotationResponseWithErrors = {
},
},
};
export const destroyRotationResponse = {
data: {
oncallRotationDestroy: {
errors: [],
oncallRotation: {
__typename: 'IncidentManagementOncallRotation',
...mockRotations[0],
},
},
},
};
export const destroyRotationResponseWithErrors = {
data: {
oncallRotationDestroy: {
errors: ['Houston, we have a problem'],
oncallRotation: {
__typename: 'IncidentManagementOncallRotation',
...mockRotations[0],
},
},
},
};
......@@ -22,7 +22,7 @@ describe('On-call schedule', () => {
const mockWeeksTimeFrame = ['31 Dec 2020', '7 Jan 2021', '14 Jan 2021'];
const formattedTimezone = '(UTC-09:00) AKST Alaska';
function mountComponent({ schedule } = {}) {
function createComponent({ schedule } = {}) {
wrapper = extendedWrapper(
shallowMount(OnCallSchedule, {
propsData: {
......@@ -42,12 +42,11 @@ describe('On-call schedule', () => {
beforeEach(() => {
jest.spyOn(utils, 'getTimeframeForWeeksView').mockReturnValue(mockWeeksTimeFrame);
jest.spyOn(commonUtils, 'getFormattedTimezone').mockReturnValue(formattedTimezone);
mountComponent({ schedule: mockSchedule });
createComponent({ schedule: mockSchedule });
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findScheduleHeader = () => wrapper.findByTestId('scheduleHeader');
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DeleteRotationModal renders delete rotation modal layout 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
data-testid="delete-rotation-modal-gid://gitlab/IncidentManagement::OncallRotation/2"
modalclass=""
modalid="deleteRotationModal"
size="sm"
title="Delete rotation"
titletag="h4"
>
<!---->
<gl-sprintf-stub
message="Are you sure you want to delete the \\"%{deleteRotation}\\" rotation? This action cannot be undone."
/>
</gl-modal-stub>
`;
......@@ -123,7 +123,6 @@ describe('AddEditRotationModal', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findModal = () => wrapper.find(GlModal);
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import { GlModal, GlAlert, GlSprintf } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises';
import getOncallSchedulesQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql';
import destroyOncallRotationMutation from 'ee/oncall_schedules/graphql/mutations/destroy_oncall_rotation.mutation.graphql';
import DeleteRotationModal, {
i18n,
} from 'ee/oncall_schedules/components/rotations/components/delete_rotation_modal.vue';
import { deleteRotationModalId } from 'ee/oncall_schedules/constants';
import {
getOncallSchedulesQueryResponse,
destroyRotationResponse,
destroyRotationResponseWithErrors,
} from '../../mocks/apollo_mock';
import mockRotations from '../../mocks/mock_rotation.json';
const localVue = createLocalVue();
const projectPath = 'group/project';
const mutate = jest.fn();
const mockHideModal = jest.fn();
const rotation = mockRotations[0];
describe('DeleteRotationModal', () => {
let wrapper;
let fakeApollo;
let destroyRotationHandler;
const findModal = () => wrapper.find(GlModal);
const findModalText = () => wrapper.find(GlSprintf);
const findAlert = () => wrapper.find(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 destroyRotation(localWrapper) {
localWrapper.find(GlModal).vm.$emit('primary', { preventDefault: jest.fn() });
}
const createComponent = ({ data = {}, props = {} } = {}) => {
wrapper = shallowMount(DeleteRotationModal, {
data() {
return {
...data,
};
},
propsData: {
modalId: deleteRotationModalId,
rotation,
...props,
},
provide: {
projectPath,
},
mocks: {
$apollo: {
mutate,
},
},
stubs: { GlSprintf: false },
});
wrapper.vm.$refs.deleteRotationModal.hide = mockHideModal;
};
function createComponentWithApollo({
destroyHandler = jest.fn().mockResolvedValue(destroyRotationResponse),
} = {}) {
localVue.use(VueApollo);
destroyRotationHandler = destroyHandler;
const requestHandlers = [
[getOncallSchedulesQuery, jest.fn().mockResolvedValue(getOncallSchedulesQueryResponse)],
[destroyOncallRotationMutation, destroyRotationHandler],
];
fakeApollo = createMockApollo(requestHandlers);
fakeApollo.clients.defaultClient.cache.writeQuery({
query: getOncallSchedulesQuery,
variables: {
projectPath: 'group/project',
},
data: getOncallSchedulesQueryResponse.data,
});
wrapper = shallowMount(DeleteRotationModal, {
localVue,
apolloProvider: fakeApollo,
propsData: {
rotation,
modalId: deleteRotationModalId,
},
provide: {
projectPath,
},
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders delete rotation modal layout', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('renders delete modal with the correct rotation information', () => {
it('renders name of rotation to destroy', () => {
expect(findModalText().attributes('message')).toBe(i18n.deleteRotationMessage);
});
});
describe('Rotation destroy apollo API call', () => {
it('makes a request with `oncallRotationDestroy` to delete a rotation', () => {
mutate.mockResolvedValueOnce({});
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
expect(mutate).toHaveBeenCalledWith({
mutation: expect.any(Object),
update: expect.anything(),
variables: { iid: rotation.id, projectPath },
});
});
it('hides the modal on successful rotation deletion', async () => {
mutate.mockResolvedValueOnce({ data: { oncallRotationDestroy: { errors: [] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
expect(mockHideModal).toHaveBeenCalled();
});
it('does not hide the modal on deletion fail and shows the error alert', async () => {
const error = 'some error';
mutate.mockResolvedValueOnce({ data: { oncallRotationDestroy: { 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);
});
});
describe('with mocked Apollo client', () => {
it('has the name of the rotation to delete based on getOncallSchedulesQuery', async () => {
createComponentWithApollo();
await jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(findModal().attributes('data-testid')).toBe(`delete-rotation-modal-${rotation.id}`);
});
it('calls a mutation with correct parameters and destroys a rotation', async () => {
createComponentWithApollo();
await destroyRotation(wrapper);
expect(destroyRotationHandler).toHaveBeenCalled();
});
it('displays alert if mutation had a recoverable error', async () => {
createComponentWithApollo({
destroyHandler: jest.fn().mockResolvedValue(destroyRotationResponseWithErrors),
});
await destroyRotation(wrapper);
await awaitApolloDomMock();
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain('Houston, we have a problem');
});
});
});
......@@ -14,7 +14,7 @@ describe('RotationAssignee', () => {
const findStartsAt = () => wrapper.findByTestId('rotation-assignee-starts-at');
const findEndsAt = () => wrapper.findByTestId('rotation-assignee-ends-at');
function mountComponent() {
function createComponent() {
wrapper = extendedWrapper(
shallowMount(RotationAssignee, {
propsData: {
......@@ -28,12 +28,11 @@ describe('RotationAssignee', () => {
}
beforeEach(() => {
mountComponent();
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('rotation assignee token', () => {
......
......@@ -78,5 +78,10 @@ exports[`RotationsListSectionComponent renders component layout 1`] = `
/>
</span>
</div>
<delete-rotation-modal-stub
modalid="deleteRotationModal"
rotation="[object Object]"
/>
</div>
`;
......@@ -12,7 +12,7 @@ describe('CurrentDayIndicator', () => {
// current indicator will be rendered
const mockTimeframeInitialDate = new Date(2018, 0, 1);
function mountComponent() {
function createComponent() {
wrapper = shallowMount(CurrentDayIndicator, {
propsData: {
presetType: PRESET_TYPES.WEEKS,
......@@ -22,13 +22,12 @@ describe('CurrentDayIndicator', () => {
}
beforeEach(() => {
mountComponent();
createComponent();
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
......
......@@ -12,7 +12,7 @@ describe('RotationsListSectionComponent', () => {
const mockTimeframeInitialDate = new Date(2018, 0, 1);
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
function mountComponent({
function createComponent({
presetType = PRESET_TYPES.WEEKS,
timeframe = mockTimeframeWeeks,
} = {}) {
......@@ -29,13 +29,12 @@ describe('RotationsListSectionComponent', () => {
}
beforeEach(() => {
mountComponent();
createComponent();
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
......
......@@ -12,7 +12,7 @@ describe('TimelineSectionComponent', () => {
const schedule =
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
function mountComponent({
function createComponent({
presetType = PRESET_TYPES.WEEKS,
timeframe = mockTimeframeWeeks,
} = {}) {
......@@ -26,12 +26,11 @@ describe('TimelineSectionComponent', () => {
}
beforeEach(() => {
mountComponent({});
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders component container element with class `timeline-section`', () => {
......
......@@ -19526,6 +19526,9 @@ msgstr ""
msgid "OnCallSchedules|Add schedule"
msgstr ""
msgid "OnCallSchedules|Are you sure you want to delete the \"%{deleteRotation}\" rotation? This action cannot be undone."
msgstr ""
msgid "OnCallSchedules|Are you sure you want to delete the \"%{deleteSchedule}\" schedule? This action cannot be undone."
msgstr ""
......@@ -19592,6 +19595,9 @@ msgstr ""
msgid "OnCallSchedules|Successfully edited your rotation"
msgstr ""
msgid "OnCallSchedules|The rotation could not be deleted. Please try again."
msgstr ""
msgid "OnCallSchedules|The rotation could not be updated. Please try again."
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