Commit f1df59c6 authored by Brandon Labuschagne's avatar Brandon Labuschagne

Merge branch 'mw-vue-notification-settings-modal' into 'master'

Add custom notification settings modal

See merge request gitlab-org/gitlab!52809
parents 1ba0b0c7 6e8ae821
<script>
import { GlModal, GlSprintf, GlLink, GlLoadingIcon, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
import Api from '~/api';
import { i18n } from '../constants';
export default {
name: 'CustomNotificationsModal',
components: {
GlModal,
GlSprintf,
GlLink,
GlLoadingIcon,
GlFormGroup,
GlFormCheckbox,
},
inject: {
projectId: {
default: null,
},
groupId: {
default: null,
},
helpPagePath: {
default: '',
},
},
props: {
modalId: {
type: String,
required: false,
default: 'custom-notifications-modal',
},
},
data() {
return {
isLoading: false,
events: [],
};
},
methods: {
open() {
this.$refs.modal.show();
},
buildEvents(events) {
return Object.keys(events).map((key) => ({
id: key,
enabled: Boolean(events[key]),
name: this.$options.i18n.eventNames[key] || '',
loading: false,
}));
},
async onOpen() {
if (!this.events.length) {
await this.loadNotificationSettings();
}
},
async loadNotificationSettings() {
this.isLoading = true;
try {
const {
data: { events },
} = await Api.getNotificationSettings(this.projectId, this.groupId);
this.events = this.buildEvents(events);
} catch (error) {
this.$toast.show(this.$options.i18n.loadNotificationLevelErrorMessage, { type: 'error' });
} finally {
this.isLoading = false;
}
},
async updateEvent(isEnabled, event) {
const index = this.events.findIndex((e) => e.id === event.id);
// update loading state for the given event
this.$set(this.events, index, { ...this.events[index], loading: true });
try {
const {
data: { events },
} = await Api.updateNotificationSettings(this.projectId, this.groupId, {
[event.id]: isEnabled,
});
this.events = this.buildEvents(events);
} catch (error) {
this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' });
}
},
},
i18n,
};
</script>
<template>
<gl-modal
ref="modal"
:modal-id="modalId"
:title="$options.i18n.customNotificationsModal.title"
@show="onOpen"
>
<div class="container-fluid">
<div class="row">
<div class="col-lg-4">
<h4 class="gl-mt-0" data-testid="modalBodyTitle">
{{ $options.i18n.customNotificationsModal.bodyTitle }}
</h4>
<gl-sprintf :message="$options.i18n.customNotificationsModal.bodyMessage">
<template #notificationLink="{ content }">
<gl-link :href="helpPagePath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
<div class="col-lg-8">
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" />
<template v-else>
<gl-form-group v-for="event in events" :key="event.id">
<gl-form-checkbox v-model="event.enabled" @change="updateEvent($event, event)">
<strong>{{ event.name }}</strong
><gl-loading-icon v-if="event.loading" :inline="true" class="gl-ml-2" />
</gl-form-checkbox>
</gl-form-group>
</template>
</div>
</div>
</div>
</gl-modal>
</template>
...@@ -5,11 +5,13 @@ import { ...@@ -5,11 +5,13 @@ import {
GlDropdown, GlDropdown,
GlDropdownDivider, GlDropdownDivider,
GlTooltipDirective, GlTooltipDirective,
GlModalDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
import Api from '~/api'; import Api from '~/api';
import { CUSTOM_LEVEL, i18n } from '../constants'; import { CUSTOM_LEVEL, i18n } from '../constants';
import NotificationsDropdownItem from './notifications_dropdown_item.vue'; import NotificationsDropdownItem from './notifications_dropdown_item.vue';
import CustomNotificationsModal from './custom_notifications_modal.vue';
export default { export default {
name: 'NotificationsDropdown', name: 'NotificationsDropdown',
...@@ -19,9 +21,11 @@ export default { ...@@ -19,9 +21,11 @@ export default {
GlDropdown, GlDropdown,
GlDropdownDivider, GlDropdownDivider,
NotificationsDropdownItem, NotificationsDropdownItem,
CustomNotificationsModal,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
'gl-modal': GlModalDirective,
}, },
inject: { inject: {
containerClass: { containerClass: {
...@@ -102,6 +106,10 @@ export default { ...@@ -102,6 +106,10 @@ export default {
try { try {
await Api.updateNotificationSettings(this.projectId, this.groupId, { level }); await Api.updateNotificationSettings(this.projectId, this.groupId, { level });
this.selectedNotificationLevel = level; this.selectedNotificationLevel = level;
if (level === CUSTOM_LEVEL) {
this.$refs.customNotificationsModal.open();
}
} catch (error) { } catch (error) {
this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' }); this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' });
} finally { } finally {
...@@ -111,6 +119,7 @@ export default { ...@@ -111,6 +119,7 @@ export default {
}, },
customLevel: CUSTOM_LEVEL, customLevel: CUSTOM_LEVEL,
i18n, i18n,
modalId: 'custom-notifications-modal',
}; };
</script> </script>
...@@ -122,7 +131,13 @@ export default { ...@@ -122,7 +131,13 @@ export default {
data-testid="notificationButton" data-testid="notificationButton"
:size="buttonSize" :size="buttonSize"
> >
<gl-button :size="buttonSize" :icon="buttonIcon" :loading="isLoading" :disabled="disabled"> <gl-button
v-gl-modal="$options.modalId"
:size="buttonSize"
:icon="buttonIcon"
:loading="isLoading"
:disabled="disabled"
>
<template v-if="buttonText">{{ buttonText }}</template> <template v-if="buttonText">{{ buttonText }}</template>
</gl-button> </gl-button>
<gl-dropdown :size="buttonSize" :disabled="disabled"> <gl-dropdown :size="buttonSize" :disabled="disabled">
...@@ -176,5 +191,6 @@ export default { ...@@ -176,5 +191,6 @@ export default {
@item-selected="selectItem" @item-selected="selectItem"
/> />
</gl-dropdown> </gl-dropdown>
<custom-notifications-modal ref="customNotificationsModal" :modal-id="$options.modalId" />
</div> </div>
</template> </template>
...@@ -24,4 +24,35 @@ export const i18n = { ...@@ -24,4 +24,35 @@ export const i18n = {
updateNotificationLevelErrorMessage: __( updateNotificationLevelErrorMessage: __(
'An error occured while updating the notification settings. Please try again.', 'An error occured while updating the notification settings. Please try again.',
), ),
loadNotificationLevelErrorMessage: __(
'An error occured while loading the notification settings. Please try again.',
),
customNotificationsModal: {
title: __('Custom notification events'),
bodyTitle: __('Notification events'),
bodyMessage: __(
'Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart} notification emails%{notificationLinkEnd}.',
),
},
eventNames: {
change_reviewer_merge_request: s__('NotificationEvent|Change reviewer merge request'),
close_issue: s__('NotificationEvent|Close issue'),
close_merge_request: s__('NotificationEvent|Close merge request'),
failed_pipeline: s__('NotificationEvent|Failed pipeline'),
fixed_pipeline: s__('NotificationEvent|Fixed pipeline'),
issue_due: s__('NotificationEvent|Issue due'),
merge_merge_request: s__('NotificationEvent|Merge merge request'),
moved_project: s__('NotificationEvent|Moved project'),
new_epic: s__('NotificationEvent|New epic'),
new_issue: s__('NotificationEvent|New issue'),
new_merge_request: s__('NotificationEvent|New merge request'),
new_note: s__('NotificationEvent|New note'),
new_release: s__('NotificationEvent|New release'),
push_to_merge_request: s__('NotificationEvent|Push to merge request'),
reassign_issue: s__('NotificationEvent|Reassign issue'),
reassign_merge_request: s__('NotificationEvent|Reassign merge request'),
reopen_issue: s__('NotificationEvent|Reopen issue'),
reopen_merge_request: s__('NotificationEvent|Reopen merge request'),
success_pipeline: s__('NotificationEvent|Successful pipeline'),
},
}; };
...@@ -17,6 +17,7 @@ export default () => { ...@@ -17,6 +17,7 @@ export default () => {
disabled, disabled,
dropdownItems, dropdownItems,
notificationLevel, notificationLevel,
helpPagePath,
projectId, projectId,
groupId, groupId,
showLabel, showLabel,
...@@ -30,6 +31,7 @@ export default () => { ...@@ -30,6 +31,7 @@ export default () => {
disabled: parseBoolean(disabled), disabled: parseBoolean(disabled),
dropdownItems: JSON.parse(dropdownItems), dropdownItems: JSON.parse(dropdownItems),
initialNotificationLevel: notificationLevel, initialNotificationLevel: notificationLevel,
helpPagePath,
projectId, projectId,
groupId, groupId,
showLabel: parseBoolean(showLabel), showLabel: parseBoolean(showLabel),
......
...@@ -3050,6 +3050,9 @@ msgstr "" ...@@ -3050,6 +3050,9 @@ msgstr ""
msgid "An error has occurred" msgid "An error has occurred"
msgstr "" msgstr ""
msgid "An error occured while loading the notification settings. Please try again."
msgstr ""
msgid "An error occured while saving changes: %{error}" msgid "An error occured while saving changes: %{error}"
msgstr "" msgstr ""
...@@ -8665,6 +8668,9 @@ msgstr "" ...@@ -8665,6 +8668,9 @@ msgstr ""
msgid "Custom notification events" msgid "Custom notification events"
msgstr "" msgstr ""
msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart} notification emails%{notificationLinkEnd}."
msgstr ""
msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}." msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}."
msgstr "" msgstr ""
......
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { shallowMount } from '@vue/test-utils';
import { GlSprintf, GlModal, GlFormGroup, GlFormCheckbox, GlLoadingIcon } from '@gitlab/ui';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import httpStatus from '~/lib/utils/http_status';
import CustomNotificationsModal from '~/notifications/components/custom_notifications_modal.vue';
import { i18n } from '~/notifications/constants';
const mockNotificationSettingsResponses = {
default: {
level: 'custom',
events: {
new_release: true,
new_note: false,
},
},
updated: {
level: 'custom',
events: {
new_release: true,
new_note: true,
},
},
};
const mockToastShow = jest.fn();
describe('CustomNotificationsModal', () => {
let wrapper;
let mockAxios;
function createComponent(options = {}) {
const { injectedProperties = {}, props = {} } = options;
return extendedWrapper(
shallowMount(CustomNotificationsModal, {
props: {
...props,
},
provide: {
...injectedProperties,
},
mocks: {
$toast: {
show: mockToastShow,
},
},
stubs: {
GlModal,
GlFormGroup,
GlFormCheckbox,
},
}),
);
}
const findModalBodyDescription = () => wrapper.find(GlSprintf);
const findAllCheckboxes = () => wrapper.findAll(GlFormCheckbox);
const findCheckboxAt = (index) => findAllCheckboxes().at(index);
beforeEach(() => {
gon.api_version = 'v4';
mockAxios = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
mockAxios.restore();
});
describe('template', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('displays the body title and the body message', () => {
expect(wrapper.findByTestId('modalBodyTitle').text()).toBe(
i18n.customNotificationsModal.bodyTitle,
);
expect(findModalBodyDescription().attributes('message')).toContain(
i18n.customNotificationsModal.bodyMessage,
);
});
describe('checkbox items', () => {
beforeEach(async () => {
wrapper = createComponent();
wrapper.setData({
events: [
{ id: 'new_release', enabled: true, name: 'New release', loading: false },
{ id: 'new_note', enabled: false, name: 'New note', loading: true },
],
});
await wrapper.vm.$nextTick();
});
it.each`
index | eventId | eventName | enabled | loading
${0} | ${'new_release'} | ${'New release'} | ${true} | ${false}
${1} | ${'new_note'} | ${'New note'} | ${false} | ${true}
`(
'renders a checkbox for "$eventName" with checked=$enabled',
async ({ index, eventName, enabled, loading }) => {
const checkbox = findCheckboxAt(index);
expect(checkbox.text()).toContain(eventName);
expect(checkbox.vm.$attrs.checked).toBe(enabled);
expect(checkbox.find(GlLoadingIcon).exists()).toBe(loading);
},
);
});
});
describe('API calls', () => {
describe('load notification settings', () => {
beforeEach(() => {
jest.spyOn(axios, 'get');
});
it.each`
projectId | groupId | endpointUrl | notificationType | condition
${1} | ${null} | ${'/api/v4/projects/1/notification_settings'} | ${'project'} | ${'a projectId is given'}
${null} | ${1} | ${'/api/v4/groups/1/notification_settings'} | ${'group'} | ${'a groupId is given'}
${null} | ${null} | ${'/api/v4/notification_settings'} | ${'global'} | ${'neither projectId nor groupId are given'}
`(
'requests $notificationType notification settings when $condition',
async ({ projectId, groupId, endpointUrl }) => {
const injectedProperties = {
projectId,
groupId,
};
mockAxios
.onGet(endpointUrl)
.reply(httpStatus.OK, mockNotificationSettingsResponses.default);
wrapper = createComponent({ injectedProperties });
wrapper.find(GlModal).vm.$emit('show');
await waitForPromises();
expect(axios.get).toHaveBeenCalledWith(endpointUrl);
},
);
it('updates the loading state and the events property', async () => {
const endpointUrl = '/api/v4/notification_settings';
mockAxios
.onGet(endpointUrl)
.reply(httpStatus.OK, mockNotificationSettingsResponses.default);
wrapper = createComponent();
wrapper.find(GlModal).vm.$emit('show');
expect(wrapper.vm.isLoading).toBe(true);
await waitForPromises();
expect(axios.get).toHaveBeenCalledWith(endpointUrl);
expect(wrapper.vm.isLoading).toBe(false);
expect(wrapper.vm.events).toEqual([
{ id: 'new_release', enabled: true, name: 'New release', loading: false },
{ id: 'new_note', enabled: false, name: 'New note', loading: false },
]);
});
it('shows a toast message when the request fails', async () => {
mockAxios.onGet('/api/v4/notification_settings').reply(httpStatus.NOT_FOUND, {});
wrapper = createComponent();
wrapper.find(GlModal).vm.$emit('show');
await waitForPromises();
expect(
mockToastShow,
).toHaveBeenCalledWith(
'An error occured while loading the notification settings. Please try again.',
{ type: 'error' },
);
});
});
describe('update notification settings', () => {
beforeEach(() => {
jest.spyOn(axios, 'put');
});
it.each`
projectId | groupId | endpointUrl | notificationType | condition
${1} | ${null} | ${'/api/v4/projects/1/notification_settings'} | ${'project'} | ${'a projectId is given'}
${null} | ${1} | ${'/api/v4/groups/1/notification_settings'} | ${'group'} | ${'a groupId is given'}
${null} | ${null} | ${'/api/v4/notification_settings'} | ${'global'} | ${'neither projectId nor groupId are given'}
`(
'updates the $notificationType notification settings when $condition and the user clicks the checkbox ',
async ({ projectId, groupId, endpointUrl }) => {
mockAxios
.onGet(endpointUrl)
.reply(httpStatus.OK, mockNotificationSettingsResponses.default);
mockAxios
.onPut(endpointUrl)
.reply(httpStatus.OK, mockNotificationSettingsResponses.updated);
const injectedProperties = {
projectId,
groupId,
};
wrapper = createComponent({ injectedProperties });
wrapper.setData({
events: [
{ id: 'new_release', enabled: true, name: 'New release', loading: false },
{ id: 'new_note', enabled: false, name: 'New note', loading: false },
],
});
await wrapper.vm.$nextTick();
findCheckboxAt(1).vm.$emit('change', true);
await waitForPromises();
expect(axios.put).toHaveBeenCalledWith(endpointUrl, {
new_note: true,
});
expect(wrapper.vm.events).toEqual([
{ id: 'new_release', enabled: true, name: 'New release', loading: false },
{ id: 'new_note', enabled: true, name: 'New note', loading: false },
]);
},
);
it('shows a toast message when the request fails', async () => {
mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.NOT_FOUND, {});
wrapper = createComponent();
wrapper.setData({
events: [
{ id: 'new_release', enabled: true, name: 'New release', loading: false },
{ id: 'new_note', enabled: false, name: 'New note', loading: false },
],
});
await wrapper.vm.$nextTick();
findCheckboxAt(1).vm.$emit('change', true);
await waitForPromises();
expect(
mockToastShow,
).toHaveBeenCalledWith(
'An error occured while updating the notification settings. Please try again.',
{ type: 'error' },
);
});
});
});
});
...@@ -7,6 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises'; ...@@ -7,6 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import NotificationsDropdown from '~/notifications/components/notifications_dropdown.vue'; import NotificationsDropdown from '~/notifications/components/notifications_dropdown.vue';
import NotificationsDropdownItem from '~/notifications/components/notifications_dropdown_item.vue'; import NotificationsDropdownItem from '~/notifications/components/notifications_dropdown_item.vue';
import CustomNotificationsModal from '~/notifications/components/custom_notifications_modal.vue';
const mockDropdownItems = ['global', 'watch', 'participating', 'mention', 'disabled']; const mockDropdownItems = ['global', 'watch', 'participating', 'mention', 'disabled'];
const mockToastShow = jest.fn(); const mockToastShow = jest.fn();
...@@ -14,17 +15,26 @@ const mockToastShow = jest.fn(); ...@@ -14,17 +15,26 @@ const mockToastShow = jest.fn();
describe('NotificationsDropdown', () => { describe('NotificationsDropdown', () => {
let wrapper; let wrapper;
let mockAxios; let mockAxios;
let glModalDirective;
function createComponent(injectedProperties = {}) { function createComponent(injectedProperties = {}) {
glModalDirective = jest.fn();
return shallowMount(NotificationsDropdown, { return shallowMount(NotificationsDropdown, {
stubs: { stubs: {
GlButtonGroup, GlButtonGroup,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
NotificationsDropdownItem, NotificationsDropdownItem,
CustomNotificationsModal,
}, },
directives: { directives: {
GlTooltip: createMockDirective(), GlTooltip: createMockDirective(),
glModal: {
bind(_, { value }) {
glModalDirective(value);
},
},
}, },
provide: { provide: {
dropdownItems: mockDropdownItems, dropdownItems: mockDropdownItems,
...@@ -94,6 +104,19 @@ describe('NotificationsDropdown', () => { ...@@ -94,6 +104,19 @@ describe('NotificationsDropdown', () => {
expect(findButton().text()).toBe(''); expect(findButton().text()).toBe('');
}); });
it('opens the modal when the user clicks the button', async () => {
jest.spyOn(axios, 'put');
mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.OK, {});
wrapper = createComponent({
initialNotificationLevel: 'custom',
});
findButton().vm.$emit('click');
expect(glModalDirective).toHaveBeenCalled();
});
}); });
describe('when notification level is not "custom"', () => { describe('when notification level is not "custom"', () => {
...@@ -236,5 +259,16 @@ describe('NotificationsDropdown', () => { ...@@ -236,5 +259,16 @@ describe('NotificationsDropdown', () => {
{ type: 'error' }, { type: 'error' },
); );
}); });
it('opens the modal when the user clicks on the "Custom" dropdown item', async () => {
mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.OK, {});
wrapper = createComponent();
const mockModalShow = jest.spyOn(wrapper.vm.$refs.customNotificationsModal, 'open');
await clickDropdownItemAt(5);
expect(mockModalShow).toHaveBeenCalled();
});
}); });
}); });
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