Commit 724cffdf authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '262848-schedule-create-success' into 'master'

Handle schedule create success (cache and alert)

See merge request gitlab-org/gitlab!49158
parents 0860559f 92a8a848
......@@ -9,6 +9,7 @@ import {
GlSearchBoxByType,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { getFormattedTimezone } from '../utils/common_utils';
export const i18n = {
selectTimezone: s__('OnCallSchedules|Select timezone'),
......@@ -90,7 +91,7 @@ export default {
},
methods: {
getFormattedTimezone(tz) {
return __(`(UTC${tz.formatted_offset}) ${tz.abbr} ${tz.name}`);
return getFormattedTimezone(tz);
},
isTimezoneSelected(tz) {
return isEqual(tz, this.form.timezone);
......
......@@ -2,8 +2,10 @@
import { isEmpty } from 'lodash';
import { GlModal, GlAlert } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import getOncallSchedulesQuery from '../graphql/queries/get_oncall_schedules.query.graphql';
import createOncallScheduleMutation from '../graphql/mutations/create_oncall_schedule.mutation.graphql';
import AddEditScheduleForm from './add_edit_schedule_form.vue';
import { updateStoreOnScheduleCreate } from '../utils/cache_updates';
export const i18n = {
cancel: __('Cancel'),
......@@ -65,23 +67,35 @@ export default {
methods: {
createSchedule() {
this.loading = true;
const { projectPath } = this;
this.$apollo
.mutate({
mutation: createOncallScheduleMutation,
variables: {
oncallScheduleCreateInput: {
projectPath: this.projectPath,
projectPath,
...this.form,
timezone: this.form.timezone.identifier,
},
},
update(
store,
{
data: { oncallScheduleCreate },
},
) {
updateStoreOnScheduleCreate(store, getOncallSchedulesQuery, oncallScheduleCreate, {
projectPath,
});
},
})
.then(({ data: { oncallScheduleCreate: { errors: [error] } } }) => {
if (error) {
throw error;
}
this.$refs.createScheduleModal.hide();
this.$emit('scheduleCreated');
})
.catch(error => {
this.error = error;
......
......@@ -6,10 +6,9 @@ import DeleteScheduleModal from './delete_schedule_modal.vue';
import EditScheduleModal from './edit_schedule_modal.vue';
import { getTimeframeForWeeksView } from './schedule/utils';
import { PRESET_TYPES } from './schedule/constants';
import { getFormattedTimezone } from '../utils';
import { getFormattedTimezone } from '../utils/common_utils';
export const i18n = {
title: s__('OnCallSchedules|On-call schedule'),
scheduleForTz: s__('OnCallSchedules|On-call schedule for the %{tzShort}'),
updateScheduleLabel: s__('OnCallSchedules|Edit schedule'),
destroyScheduleLabel: s__('OnCallSchedules|Delete schedule'),
......@@ -51,7 +50,6 @@ export default {
<template>
<div>
<h2>{{ $options.i18n.title }}</h2>
<gl-card>
<template #header>
<div class="gl-display-flex gl-justify-content-space-between gl-m-0">
......
<script>
import { GlEmptyState, GlButton, GlLoadingIcon, GlModalDirective } from '@gitlab/ui';
import { GlAlert, GlButton, GlEmptyState, GlLoadingIcon, GlModalDirective } from '@gitlab/ui';
import * as Sentry from '~/sentry/wrapper';
import AddScheduleModal from './add_schedule_modal.vue';
import OncallSchedule from './oncall_schedule.vue';
......@@ -10,11 +10,18 @@ import { fetchPolicies } from '~/lib/graphql';
const addScheduleModalId = 'addScheduleModal';
export const i18n = {
title: s__('OnCallSchedules|On-call schedule'),
emptyState: {
title: s__('OnCallSchedules|Create on-call schedules in GitLab'),
description: s__('OnCallSchedules|Route alerts directly to specific members of your team'),
button: s__('OnCallSchedules|Add a schedule'),
},
successNotification: {
title: s__('OnCallSchedules|Try adding a rotation'),
description: s__(
'OnCallSchedules|Your schedule has been successfully created and all alerts from this project will now be routed to this schedule. Currently, only one schedule can be created per project. More coming soon! To add individual users to this schedule, use the add a rotation button.',
),
},
};
export default {
......@@ -22,8 +29,9 @@ export default {
addScheduleModalId,
inject: ['emptyOncallSchedulesSvgPath', 'projectPath'],
components: {
GlEmptyState,
GlAlert,
GlButton,
GlEmptyState,
GlLoadingIcon,
AddScheduleModal,
OncallSchedule,
......@@ -34,6 +42,7 @@ export default {
data() {
return {
schedule: {},
showSuccessNotification: false,
};
},
apollo: {
......@@ -46,7 +55,8 @@ export default {
};
},
update(data) {
return data?.project?.incidentManagementOncallSchedules?.nodes?.[0] ?? null;
const nodes = data.project?.incidentManagementOncallSchedules?.nodes ?? [];
return nodes.length ? nodes[nodes.length - 1] : null;
},
error(error) {
Sentry.captureException(error);
......@@ -64,7 +74,21 @@ export default {
<template>
<div>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" />
<oncall-schedule v-else-if="schedule" :schedule="schedule" />
<template v-else-if="schedule">
<h2>{{ $options.i18n.title }}</h2>
<gl-alert
v-if="showSuccessNotification"
variant="tip"
:title="$options.i18n.successNotification.title"
class="gl-my-3"
@dismiss="showSuccessNotification = false"
>
{{ $options.i18n.successNotification.description }}
</gl-alert>
<oncall-schedule :schedule="schedule" />
</template>
<gl-empty-state
v-else
:title="$options.i18n.emptyState.title"
......@@ -77,6 +101,9 @@ export default {
</gl-button>
</template>
</gl-empty-state>
<add-schedule-modal :modal-id="$options.addScheduleModalId" />
<add-schedule-modal
:modal-id="$options.addScheduleModalId"
@scheduleCreated="showSuccessNotification = true"
/>
</div>
</template>
......@@ -3,6 +3,27 @@ import createFlash from '~/flash';
import { DELETE_SCHEDULE_ERROR, UPDATE_SCHEDULE_ERROR } from './error_messages';
const addScheduleToStore = (store, query, { oncallSchedule: schedule }, variables) => {
if (!schedule) {
return;
}
const sourceData = store.readQuery({
query,
variables,
});
const data = produce(sourceData, draftData => {
draftData.project.incidentManagementOncallSchedules.nodes.push(schedule);
});
store.writeQuery({
query,
variables,
data,
});
};
const deleteScheduleFromStore = (store, query, { oncallScheduleDestroy }, variables) => {
const schedule = oncallScheduleDestroy?.oncallSchedule;
if (!schedule) {
......@@ -61,6 +82,12 @@ const onError = (data, message) => {
export const hasErrors = ({ errors = [] }) => errors?.length;
export const updateStoreOnScheduleCreate = (store, query, data, variables) => {
if (!hasErrors(data)) {
addScheduleToStore(store, query, data, variables);
}
};
export const updateStoreAfterScheduleDelete = (store, query, data, variables) => {
if (hasErrors(data)) {
onError(data, DELETE_SCHEDULE_ERROR);
......
......@@ -11,7 +11,7 @@ import { sprintf, __ } from '~/locale';
* @returns {String}
*/
export const getFormattedTimezone = tz => {
return sprintf(__('(UTC%{offset}) %{timezone}'), {
return sprintf(__('(UTC %{offset}) %{timezone}'), {
offset: tz.formatted_offset,
timezone: `${tz.abbr} ${tz.name}`,
});
......
......@@ -42,7 +42,7 @@ exports[`AddEditScheduleForm renders modal layout 1`] = `
headertext="Select timezone"
id="schedule-timezone"
size="medium"
text="(UTC-12:00) -12 International Date Line West"
text="(UTC -12:00) -12 International Date Line West"
variant="default"
>
<gl-search-box-by-type-stub
......@@ -63,7 +63,7 @@ exports[`AddEditScheduleForm renders modal layout 1`] = `
<span
class="gl-white-space-nowrap"
>
(UTC-12:00) -12 International Date Line West
(UTC -12:00) -12 International Date Line West
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
......@@ -78,7 +78,7 @@ exports[`AddEditScheduleForm renders modal layout 1`] = `
<span
class="gl-white-space-nowrap"
>
(UTC-11:00) SST American Samoa
(UTC -11:00) SST American Samoa
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
......@@ -93,7 +93,7 @@ exports[`AddEditScheduleForm renders modal layout 1`] = `
<span
class="gl-white-space-nowrap"
>
(UTC-11:00) SST Midway Island
(UTC -11:00) SST Midway Island
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
......@@ -108,7 +108,7 @@ exports[`AddEditScheduleForm renders modal layout 1`] = `
<span
class="gl-white-space-nowrap"
>
(UTC-10:00) HST Hawaii
(UTC -10:00) HST Hawaii
</span>
</gl-dropdown-item-stub>
......
......@@ -67,7 +67,7 @@ describe('AddEditScheduleForm', () => {
it('formats each option', () => {
findDropdownOptions().wrappers.forEach((option, index) => {
const tz = mockTimezones[index];
const expectedValue = `(UTC${tz.formatted_offset}) ${tz.abbr} ${tz.name}`;
const expectedValue = `(UTC ${tz.formatted_offset}) ${tz.abbr} ${tz.name}`;
expect(option.text()).toBe(expectedValue);
});
});
......
......@@ -10,13 +10,14 @@ describe('AddScheduleModal', () => {
const projectPath = 'group/project';
const mutate = jest.fn();
const mockHideModal = jest.fn();
const formData =
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
const createComponent = ({ data = {}, props = {} } = {}) => {
wrapper = shallowMount(AddScheduleModal, {
data() {
return {
form:
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0],
form: formData,
...data,
};
},
......@@ -60,7 +61,14 @@ describe('AddScheduleModal', () => {
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
expect(mutate).toHaveBeenCalledWith({
mutation: expect.any(Object),
variables: { oncallScheduleCreateInput: expect.objectContaining({ projectPath }) },
update: expect.any(Function),
variables: {
oncallScheduleCreateInput: {
projectPath,
...formData,
timezone: formData.timezone.identifier,
},
},
});
});
......
import { getFormattedTimezone } from 'ee/oncall_schedules/utils';
import { getFormattedTimezone } from 'ee/oncall_schedules/utils/common_utils';
import mockTimezones from './mocks/mockTimezones.json';
describe('getFormattedTimezone', () => {
it('formats the timezone', () => {
const tz = mockTimezones[0];
const expectedValue = `(UTC${tz.formatted_offset}) ${tz.abbr} ${tz.name}`;
const expectedValue = `(UTC ${tz.formatted_offset}) ${tz.abbr} ${tz.name}`;
expect(getFormattedTimezone(tz)).toBe(expectedValue);
});
});
......@@ -25,7 +25,9 @@ export const getOncallSchedulesQueryResponse = {
iid: '37',
name: 'Test schedule',
description: 'Description 1 lives here',
timezone: 'Pacific/Honolulu',
timezone: {
identifier: 'Pacific/Honolulu',
},
},
],
},
......@@ -81,3 +83,17 @@ export const updateScheduleResponse = {
},
},
};
export const preExistingSchedule = {
description: 'description',
iid: '1',
name: 'Monitor rotations',
timezone: 'Pacific/Honolulu',
};
export const newlyCreatedSchedule = {
description: 'description',
iid: '2',
name: 'S-Monitor rotations',
timezone: 'Kyiv/EST',
};
......@@ -3,7 +3,7 @@ import { GlCard, GlSprintf } from '@gitlab/ui';
import OnCallSchedule, { i18n } from 'ee/oncall_schedules/components/oncall_schedule.vue';
import ScheduleTimelineSection from 'ee/oncall_schedules/components/schedule/components/schedule_timeline_section.vue';
import * as utils from 'ee/oncall_schedules/components/schedule/utils';
import * as commonUtils from 'ee/oncall_schedules/utils';
import * as commonUtils from 'ee/oncall_schedules/utils/common_utils';
import { PRESET_TYPES } from 'ee/oncall_schedules/components/schedule/constants';
import mockTimezones from './mocks/mockTimezones.json';
......
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlEmptyState, GlLoadingIcon, GlAlert } from '@gitlab/ui';
import OnCallScheduleWrapper, {
i18n,
} from 'ee/oncall_schedules/components/oncall_schedules_wrapper.vue';
import OnCallSchedule from 'ee/oncall_schedules/components/oncall_schedule.vue';
import AddScheduleModal from 'ee/oncall_schedules/components/add_schedule_modal.vue';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import getOncallSchedulesQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql';
import VueApollo from 'vue-apollo';
import { preExistingSchedule, newlyCreatedSchedule } from './mocks/apollo_mock';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('On-call schedule wrapper', () => {
let wrapper;
......@@ -33,14 +41,38 @@ describe('On-call schedule wrapper', () => {
});
}
let getOncallSchedulesQuerySpy;
function mountComponentWithApollo() {
const fakeApollo = createMockApollo([[getOncallSchedulesQuery, getOncallSchedulesQuerySpy]]);
wrapper = shallowMount(OnCallScheduleWrapper, {
localVue,
apolloProvider: fakeApollo,
data() {
return {
schedule: {},
};
},
provide: {
emptyOncallSchedulesSvgPath,
projectPath,
},
});
}
afterEach(() => {
wrapper.destroy();
wrapper = null;
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
const findLoader = () => wrapper.find(GlLoadingIcon);
const findEmptyState = () => wrapper.find(GlEmptyState);
const findSchedule = () => wrapper.find(OnCallSchedule);
const findAlert = () => wrapper.find(GlAlert);
const findModal = () => wrapper.find(AddScheduleModal);
it('shows a loader while data is requested', () => {
mountComponent({ loading: true });
......@@ -59,11 +91,49 @@ describe('On-call schedule wrapper', () => {
});
});
it('renders On-call schedule when data received ', () => {
mountComponent({ loading: false, schedule: { name: 'monitor rotation' } });
const schedule = findSchedule();
expect(findLoader().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(false);
expect(schedule.exists()).toBe(true);
describe('Schedule created', () => {
beforeEach(() => {
mountComponent({ loading: false, schedule: { name: 'monitor rotation' } });
});
it('renders the schedule when data received ', () => {
expect(findLoader().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(false);
expect(findSchedule().exists()).toBe(true);
});
it('shows success alert', async () => {
await findModal().vm.$emit('scheduleCreated');
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.props('title')).toBe(i18n.successNotification.title);
expect(alert.text()).toBe(i18n.successNotification.description);
});
it('renders a newly created schedule', async () => {
await findModal().vm.$emit('scheduleCreated');
expect(findSchedule().exists()).toBe(true);
});
});
describe('Apollo', () => {
beforeEach(() => {
getOncallSchedulesQuerySpy = jest.fn().mockResolvedValue({
data: {
project: {
incidentManagementOncallSchedules: {
nodes: [preExistingSchedule, newlyCreatedSchedule],
},
},
},
});
});
it('should render newly create schedule', async () => {
mountComponentWithApollo();
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(findSchedule().props('schedule')).toEqual(newlyCreatedSchedule);
});
});
});
......@@ -947,7 +947,7 @@ msgstr ""
msgid "(No changes)"
msgstr ""
msgid "(UTC%{offset}) %{timezone}"
msgid "(UTC %{offset}) %{timezone}"
msgstr ""
msgid "(check progress)"
......@@ -19187,6 +19187,12 @@ msgstr ""
msgid "OnCallSchedules|The schedule could not be updated. Please try again."
msgstr ""
msgid "OnCallSchedules|Try adding a rotation"
msgstr ""
msgid "OnCallSchedules|Your schedule has been successfully created and all alerts from this project will now be routed to this schedule. Currently, only one schedule can be created per project. More coming soon! To add individual users to this schedule, use the add a rotation button."
msgstr ""
msgid "OnDemandScans|Could not fetch scanner profiles. Please refresh the page, or try again later."
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