Commit 5894ca6c authored by Brandon Labuschagne's avatar Brandon Labuschagne

Merge branch '333038-fix-alerts-on-oncalls-schedules-page' into 'master'

Fix alert positioning and content for oncall schedules page

See merge request gitlab-org/gitlab!66858
parents 718b30c2 4b228dce
...@@ -222,7 +222,11 @@ export default { ...@@ -222,7 +222,11 @@ export default {
break; break;
} }
}, },
fetchRotationShifts() { onRotationUpdate(message) {
this.$apollo.queries.rotations.refetch();
this.$emit('rotation-updated', message);
},
onRotationDelete() {
this.$apollo.queries.rotations.refetch(); this.$apollo.queries.rotations.refetch();
}, },
setRotationToUpdate(rotation) { setRotationToUpdate(rotation) {
...@@ -343,20 +347,20 @@ export default { ...@@ -343,20 +347,20 @@ export default {
<add-edit-rotation-modal <add-edit-rotation-modal
:schedule="schedule" :schedule="schedule"
:modal-id="addRotationModalId" :modal-id="addRotationModalId"
@fetch-rotation-shifts="fetchRotationShifts" @rotation-updated="onRotationUpdate"
/> />
<add-edit-rotation-modal <add-edit-rotation-modal
:schedule="schedule" :schedule="schedule"
:modal-id="editRotationModalId" :modal-id="editRotationModalId"
:rotation="rotationToUpdate" :rotation="rotationToUpdate"
is-edit-mode is-edit-mode
@fetch-rotation-shifts="fetchRotationShifts" @rotation-updated="onRotationUpdate"
/> />
<delete-rotation-modal <delete-rotation-modal
:rotation="rotationToUpdate" :rotation="rotationToUpdate"
:schedule="schedule" :schedule="schedule"
:modal-id="deleteRotationModalId" :modal-id="deleteRotationModalId"
@fetch-rotation-shifts="fetchRotationShifts" @rotation-deleted="onRotationDelete"
/> />
</div> </div>
</template> </template>
...@@ -11,7 +11,6 @@ import { ...@@ -11,7 +11,6 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { escalationPolicyUrl } from '../constants';
import getOncallSchedulesWithRotationsQuery from '../graphql/queries/get_oncall_schedules.query.graphql'; import getOncallSchedulesWithRotationsQuery from '../graphql/queries/get_oncall_schedules.query.graphql';
import AddScheduleModal from './add_edit_schedule_modal.vue'; import AddScheduleModal from './add_edit_schedule_modal.vue';
import OncallSchedule from './oncall_schedule.vue'; import OncallSchedule from './oncall_schedule.vue';
...@@ -31,7 +30,7 @@ export const i18n = { ...@@ -31,7 +30,7 @@ export const i18n = {
successNotification: { successNotification: {
title: s__('OnCallSchedules|Try adding a rotation'), title: s__('OnCallSchedules|Try adding a rotation'),
description: s__( description: s__(
'OnCallSchedules|Your schedule has been successfully created. To add individual users to this schedule, use the add a rotation button. To create an escalation policy that defines which schedule is used when, visit the %{linkStart}escalation policy%{linkEnd} page.', 'OnCallSchedules|Your schedule has been successfully created. To add individual users to this schedule, use the Add a rotation button. To enable notifications for this schedule, you must also create an %{linkStart}escalation policy%{linkEnd}.',
), ),
}, },
}; };
...@@ -39,7 +38,6 @@ export const i18n = { ...@@ -39,7 +38,6 @@ export const i18n = {
export default { export default {
i18n, i18n,
addScheduleModalId, addScheduleModalId,
escalationPolicyUrl,
components: { components: {
GlAlert, GlAlert,
GlButton, GlButton,
...@@ -54,11 +52,13 @@ export default { ...@@ -54,11 +52,13 @@ export default {
GlModal: GlModalDirective, GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
inject: ['emptyOncallSchedulesSvgPath', 'projectPath'], inject: ['emptyOncallSchedulesSvgPath', 'projectPath', 'escalationPoliciesPath'],
data() { data() {
return { return {
schedules: [], schedules: [],
showSuccessNotification: false, showScheduleCreatedNotification: false,
showRotationUpdatedNotification: false,
rotationUpdateMsg: null,
}; };
}, },
apollo: { apollo: {
...@@ -85,6 +85,13 @@ export default { ...@@ -85,6 +85,13 @@ export default {
return this.schedules.length; return this.schedules.length;
}, },
}, },
methods: {
onRotationUpdate(message) {
this.showScheduleCreatedNotification = false;
this.showRotationUpdatedNotification = true;
this.rotationUpdateMsg = message;
},
},
}; };
</script> </script>
...@@ -108,26 +115,38 @@ export default { ...@@ -108,26 +115,38 @@ export default {
{{ $options.i18n.add.button }} {{ $options.i18n.add.button }}
</gl-button> </gl-button>
</div> </div>
<gl-alert <gl-alert
v-if="showSuccessNotification" v-if="showScheduleCreatedNotification"
data-testid="tip-alert"
variant="tip" variant="tip"
:title="$options.i18n.successNotification.title" :title="$options.i18n.successNotification.title"
class="gl-my-3" class="gl-my-3"
@dismiss="showSuccessNotification = false" @dismiss="showScheduleCreatedNotification = false"
> >
<gl-sprintf :message="$options.i18n.successNotification.description"> <gl-sprintf :message="$options.i18n.successNotification.description">
<template #link="{ content }"> <template #link="{ content }">
<gl-link :href="$options.escalationPolicyUrl" target="_blank"> <gl-link :href="escalationPoliciesPath" target="_blank">{{ content }}</gl-link>
{{ content }}
</gl-link>
</template> </template>
</gl-sprintf> </gl-sprintf>
</gl-alert> </gl-alert>
<gl-alert
v-if="showRotationUpdatedNotification"
data-testid="success-alert"
variant="success"
class="gl-my-3"
@dismiss="showRotationUpdatedNotification = false"
>
{{ rotationUpdateMsg }}
</gl-alert>
<oncall-schedule <oncall-schedule
v-for="(schedule, scheduleIndex) in schedules" v-for="(schedule, scheduleIndex) in schedules"
:key="schedule.iid" :key="schedule.iid"
:schedule="schedule" :schedule="schedule"
:schedule-index="scheduleIndex" :schedule-index="scheduleIndex"
@rotation-updated="onRotationUpdate"
/> />
</template> </template>
...@@ -145,7 +164,7 @@ export default { ...@@ -145,7 +164,7 @@ export default {
</gl-empty-state> </gl-empty-state>
<add-schedule-modal <add-schedule-modal
:modal-id="$options.addScheduleModalId" :modal-id="$options.addScheduleModalId"
@scheduleCreated="showSuccessNotification = true" @scheduleCreated="showScheduleCreatedNotification = true"
/> />
</div> </div>
</template> </template>
...@@ -12,7 +12,6 @@ import { ...@@ -12,7 +12,6 @@ import {
parseHour, parseHour,
parseRotationDate, parseRotationDate,
} from 'ee/oncall_schedules/utils/common_utils'; } from 'ee/oncall_schedules/utils/common_utils';
import createFlash, { FLASH_TYPES } from '~/flash';
import searchProjectMembersQuery from '~/graphql_shared/queries/project_user_members_search.query.graphql'; import searchProjectMembersQuery from '~/graphql_shared/queries/project_user_members_search.query.graphql';
import { format24HourTimeStringFromInt, formatDate } from '~/lib/utils/datetime_utility'; import { format24HourTimeStringFromInt, formatDate } from '~/lib/utils/datetime_utility';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
...@@ -226,11 +225,7 @@ export default { ...@@ -226,11 +225,7 @@ export default {
} }
this.$refs.addEditScheduleRotationModal.hide(); this.$refs.addEditScheduleRotationModal.hide();
this.$emit('fetch-rotation-shifts'); this.$emit('rotation-updated', i18n.rotationCreated);
return createFlash({
message: this.$options.i18n.rotationCreated,
type: FLASH_TYPES.SUCCESS,
});
}, },
) )
.catch((error) => { .catch((error) => {
...@@ -275,11 +270,7 @@ export default { ...@@ -275,11 +270,7 @@ export default {
} }
this.$refs.addEditScheduleRotationModal.hide(); this.$refs.addEditScheduleRotationModal.hide();
this.$emit('fetch-rotation-shifts'); this.$emit('rotation-updated', i18n.editedRotation);
return createFlash({
message: this.$options.i18n.editedRotation,
type: FLASH_TYPES.SUCCESS,
});
}, },
) )
.catch((error) => { .catch((error) => {
......
...@@ -93,7 +93,7 @@ export default { ...@@ -93,7 +93,7 @@ export default {
if (error) { if (error) {
throw error; throw error;
} }
this.$emit('fetch-rotation-shifts'); this.$emit('rotation-deleted');
this.$refs.deleteRotationModal.hide(); this.$refs.deleteRotationModal.hide();
}) })
.catch((error) => { .catch((error) => {
......
...@@ -54,6 +54,3 @@ export const SHIFT_WIDTH_CALCULATION_DELAY = 250; ...@@ -54,6 +54,3 @@ export const SHIFT_WIDTH_CALCULATION_DELAY = 250;
export const oneHourOffsetDayView = 100 / HOURS_IN_DAY; export const oneHourOffsetDayView = 100 / HOURS_IN_DAY;
export const oneDayOffsetWeekView = 100 / DAYS_IN_WEEK; export const oneDayOffsetWeekView = 100 / DAYS_IN_WEEK;
export const oneHourOffsetWeekView = oneDayOffsetWeekView / HOURS_IN_DAY; export const oneHourOffsetWeekView = oneDayOffsetWeekView / HOURS_IN_DAY;
// TODO: Replace with href to documentation once https://gitlab.com/groups/gitlab-org/-/epics/4638 is completed
export const escalationPolicyUrl = 'https://gitlab.com/groups/gitlab-org/-/epics/4638';
...@@ -11,7 +11,12 @@ export default () => { ...@@ -11,7 +11,12 @@ export default () => {
if (!el) return null; if (!el) return null;
const { projectPath, emptyOncallSchedulesSvgPath, timezones } = el.dataset; const {
projectPath,
emptyOncallSchedulesSvgPath,
timezones,
escalationPoliciesPath,
} = el.dataset;
apolloProvider.clients.defaultClient.cache.writeQuery({ apolloProvider.clients.defaultClient.cache.writeQuery({
query: getTimelineWidthQuery, query: getTimelineWidthQuery,
...@@ -27,6 +32,7 @@ export default () => { ...@@ -27,6 +32,7 @@ export default () => {
projectPath, projectPath,
emptyOncallSchedulesSvgPath, emptyOncallSchedulesSvgPath,
timezones: JSON.parse(timezones), timezones: JSON.parse(timezones),
escalationPoliciesPath,
}, },
render(createElement) { render(createElement) {
return createElement(OnCallSchedulesWrapper); return createElement(OnCallSchedulesWrapper);
......
...@@ -6,7 +6,8 @@ module IncidentManagement ...@@ -6,7 +6,8 @@ module IncidentManagement
{ {
'project-path' => project.full_path, 'project-path' => project.full_path,
'empty-oncall-schedules-svg-path' => image_path('illustrations/empty-state/empty-on-call.svg'), 'empty-oncall-schedules-svg-path' => image_path('illustrations/empty-state/empty-on-call.svg'),
'timezones' => timezone_data(format: :full).to_json 'timezones' => timezone_data(format: :full).to_json,
'escalation-policies-path' => project_incident_management_escalation_policies_path(project)
} }
end end
end end
......
import { GlEmptyState, GlLoadingIcon, GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; import { GlEmptyState, GlLoadingIcon, GlSprintf, GlLink } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import AddScheduleModal from 'ee/oncall_schedules/components/add_edit_schedule_modal.vue'; import AddScheduleModal from 'ee/oncall_schedules/components/add_edit_schedule_modal.vue';
import OnCallSchedule from 'ee/oncall_schedules/components/oncall_schedule.vue'; import OnCallSchedule from 'ee/oncall_schedules/components/oncall_schedule.vue';
import OnCallScheduleWrapper, { import OnCallScheduleWrapper, {
i18n, i18n,
} from 'ee/oncall_schedules/components/oncall_schedules_wrapper.vue'; } from 'ee/oncall_schedules/components/oncall_schedules_wrapper.vue';
import { escalationPolicyUrl } from 'ee/oncall_schedules/constants';
import getOncallSchedulesWithRotationsQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql'; import getOncallSchedulesWithRotationsQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
...@@ -19,6 +19,7 @@ describe('On-call schedule wrapper', () => { ...@@ -19,6 +19,7 @@ describe('On-call schedule wrapper', () => {
let wrapper; let wrapper;
const emptyOncallSchedulesSvgPath = 'illustration/path.svg'; const emptyOncallSchedulesSvgPath = 'illustration/path.svg';
const projectPath = 'group/project'; const projectPath = 'group/project';
const escalationPoliciesPath = 'group/project/-/escalation_policies';
function mountComponent({ loading, schedules } = {}) { function mountComponent({ loading, schedules } = {}) {
const $apollo = { const $apollo = {
...@@ -39,6 +40,7 @@ describe('On-call schedule wrapper', () => { ...@@ -39,6 +40,7 @@ describe('On-call schedule wrapper', () => {
provide: { provide: {
emptyOncallSchedulesSvgPath, emptyOncallSchedulesSvgPath,
projectPath, projectPath,
escalationPoliciesPath,
}, },
directives: { directives: {
GlTooltip: createMockDirective(), GlTooltip: createMockDirective(),
...@@ -70,6 +72,7 @@ describe('On-call schedule wrapper', () => { ...@@ -70,6 +72,7 @@ describe('On-call schedule wrapper', () => {
provide: { provide: {
emptyOncallSchedulesSvgPath, emptyOncallSchedulesSvgPath,
projectPath, projectPath,
escalationPoliciesPath,
}, },
}); });
} }
...@@ -83,7 +86,8 @@ describe('On-call schedule wrapper', () => { ...@@ -83,7 +86,8 @@ describe('On-call schedule wrapper', () => {
const findLoader = () => wrapper.findComponent(GlLoadingIcon); const findLoader = () => wrapper.findComponent(GlLoadingIcon);
const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findSchedules = () => wrapper.findAllComponents(OnCallSchedule); const findSchedules = () => wrapper.findAllComponents(OnCallSchedule);
const findAlert = () => wrapper.findComponent(GlAlert); const findTipAlert = () => wrapper.findByTestId('tip-alert');
const findSuccessAlert = () => wrapper.findByTestId('success-alert');
const findAlertLink = () => wrapper.findComponent(GlLink); const findAlertLink = () => wrapper.findComponent(GlLink);
const findModal = () => wrapper.findComponent(AddScheduleModal); const findModal = () => wrapper.findComponent(AddScheduleModal);
const findAddAdditionalButton = () => wrapper.findByTestId('add-additional-schedules-button'); const findAddAdditionalButton = () => wrapper.findByTestId('add-additional-schedules-button');
...@@ -127,12 +131,25 @@ describe('On-call schedule wrapper', () => { ...@@ -127,12 +131,25 @@ describe('On-call schedule wrapper', () => {
expect(button.attributes('title')).toBe(i18n.add.tooltip); expect(button.attributes('title')).toBe(i18n.add.tooltip);
}); });
it('shows success alert on new schedule creation', async () => { it('shows alert with a tip on new schedule creation', async () => {
await findModal().vm.$emit('scheduleCreated'); await findModal().vm.$emit('scheduleCreated');
const alert = findAlert(); const alert = findTipAlert();
expect(alert.exists()).toBe(true); expect(alert.exists()).toBe(true);
expect(alert.props('title')).toBe(i18n.successNotification.title); expect(alert.props('title')).toBe(i18n.successNotification.title);
expect(findAlertLink().attributes('href')).toBe(escalationPolicyUrl); expect(findAlertLink().attributes('href')).toBe(escalationPoliciesPath);
});
it("hides tip alert and shows success alert on schedule's rotation update", async () => {
await findModal().vm.$emit('scheduleCreated');
expect(findTipAlert().exists()).toBe(true);
const rotationUpdateMsg = 'Rotation updated';
findSchedules().at(0).vm.$emit('rotation-updated', rotationUpdateMsg);
await nextTick();
expect(findTipAlert().exists()).toBe(false);
const successAlert = findSuccessAlert();
expect(successAlert.exists()).toBe(true);
expect(successAlert.text()).toBe(rotationUpdateMsg);
}); });
}); });
...@@ -152,7 +169,7 @@ describe('On-call schedule wrapper', () => { ...@@ -152,7 +169,7 @@ describe('On-call schedule wrapper', () => {
it('should render newly created schedule', async () => { it('should render newly created schedule', async () => {
mountComponentWithApollo(); mountComponentWithApollo();
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick(); await nextTick();
const schedule = findSchedules().at(1); const schedule = findSchedules().at(1);
expect(schedule.props('schedule')).toEqual(newlyCreatedSchedule); expect(schedule.props('schedule')).toEqual(newlyCreatedSchedule);
}); });
......
...@@ -10,7 +10,6 @@ import createOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/mu ...@@ -10,7 +10,6 @@ import createOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/mu
import getOncallSchedulesWithRotationsQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql'; import getOncallSchedulesWithRotationsQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash, { FLASH_TYPES } from '~/flash';
import searchProjectMembersQuery from '~/graphql_shared/queries/project_user_members_search.query.graphql'; import searchProjectMembersQuery from '~/graphql_shared/queries/project_user_members_search.query.graphql';
import { import {
participants, participants,
...@@ -331,18 +330,17 @@ describe('AddEditRotationModal', () => { ...@@ -331,18 +330,17 @@ describe('AddEditRotationModal', () => {
it('calls a mutation with correct parameters and creates a rotation', async () => { it('calls a mutation with correct parameters and creates a rotation', async () => {
createComponentWithApollo(); createComponentWithApollo();
expect(wrapper.emitted('fetch-rotation-shifts')).toBeUndefined(); expect(wrapper.emitted('rotation-updated')).toBeUndefined();
await createRotation(wrapper); await createRotation(wrapper);
await awaitApolloDomMock(); await awaitApolloDomMock();
expect(mockHideModal).toHaveBeenCalled(); expect(mockHideModal).toHaveBeenCalled();
expect(createRotationHandler).toHaveBeenCalled(); expect(createRotationHandler).toHaveBeenCalled();
expect(createFlash).toHaveBeenCalledWith({ const emittedEvents = wrapper.emitted('rotation-updated');
message: i18n.rotationCreated, const emittedMsg = emittedEvents[0][0];
type: FLASH_TYPES.SUCCESS, expect(emittedEvents).toHaveLength(1);
}); expect(emittedMsg).toBe(i18n.rotationCreated);
expect(wrapper.emitted('fetch-rotation-shifts')).toHaveLength(1);
}); });
it('displays alert if mutation had a recoverable error', async () => { it('displays alert if mutation had a recoverable error', async () => {
......
...@@ -133,11 +133,13 @@ describe('DeleteRotationModal', () => { ...@@ -133,11 +133,13 @@ describe('DeleteRotationModal', () => {
}); });
}); });
it('hides the modal on successful rotation deletion', async () => { it('hides the modal and emits the events on successful rotation deletion', async () => {
expect(wrapper.emitted('rotation-deleted')).toBeUndefined();
mutate.mockResolvedValueOnce({ data: { oncallRotationDestroy: { errors: [] } } }); mutate.mockResolvedValueOnce({ data: { oncallRotationDestroy: { errors: [] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() }); findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises(); await waitForPromises();
expect(mockHideModal).toHaveBeenCalled(); expect(mockHideModal).toHaveBeenCalled();
expect(wrapper.emitted('rotation-deleted')).toHaveLength(1);
}); });
it('does not hide the modal on deletion fail and shows the error alert', async () => { it('does not hide the modal on deletion fail and shows the error alert', async () => {
......
...@@ -12,7 +12,8 @@ RSpec.describe IncidentManagement::OncallScheduleHelper do ...@@ -12,7 +12,8 @@ RSpec.describe IncidentManagement::OncallScheduleHelper do
is_expected.to eq( is_expected.to eq(
'project-path' => project.full_path, 'project-path' => project.full_path,
'empty-oncall-schedules-svg-path' => helper.image_path('illustrations/empty-state/empty-on-call.svg'), 'empty-oncall-schedules-svg-path' => helper.image_path('illustrations/empty-state/empty-on-call.svg'),
'timezones' => helper.timezone_data(format: :full).to_json 'timezones' => helper.timezone_data(format: :full).to_json,
'escalation-policies-path' => project_incident_management_escalation_policies_path(project)
) )
end end
end end
......
...@@ -22894,7 +22894,7 @@ msgstr "" ...@@ -22894,7 +22894,7 @@ msgstr ""
msgid "OnCallSchedules|You are currently a part of:" msgid "OnCallSchedules|You are currently a part of:"
msgstr "" msgstr ""
msgid "OnCallSchedules|Your schedule has been successfully created. To add individual users to this schedule, use the add a rotation button. To create an escalation policy that defines which schedule is used when, visit the %{linkStart}escalation policy%{linkEnd} page." msgid "OnCallSchedules|Your schedule has been successfully created. To add individual users to this schedule, use the Add a rotation button. To enable notifications for this schedule, you must also create an %{linkStart}escalation policy%{linkEnd}."
msgstr "" msgstr ""
msgid "OnDemandScans|Could not fetch scanner profiles. Please refresh the page, or try again later." msgid "OnDemandScans|Could not fetch scanner profiles. Please refresh the page, or try again later."
......
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