diff options
Diffstat (limited to 'app/assets/javascripts/notifications')
5 files changed, 468 insertions, 0 deletions
diff --git a/app/assets/javascripts/notifications/components/custom_notifications_modal.vue b/app/assets/javascripts/notifications/components/custom_notifications_modal.vue new file mode 100644 index 00000000000..0f628897e17 --- /dev/null +++ b/app/assets/javascripts/notifications/components/custom_notifications_modal.vue @@ -0,0 +1,128 @@ +<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> diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown.vue b/app/assets/javascripts/notifications/components/notifications_dropdown.vue new file mode 100644 index 00000000000..e4cedfdb810 --- /dev/null +++ b/app/assets/javascripts/notifications/components/notifications_dropdown.vue @@ -0,0 +1,196 @@ +<script> +import { + GlButtonGroup, + GlButton, + GlDropdown, + GlDropdownDivider, + GlTooltipDirective, + GlModalDirective, +} from '@gitlab/ui'; +import Api from '~/api'; +import { sprintf } from '~/locale'; +import { CUSTOM_LEVEL, i18n } from '../constants'; +import CustomNotificationsModal from './custom_notifications_modal.vue'; +import NotificationsDropdownItem from './notifications_dropdown_item.vue'; + +export default { + name: 'NotificationsDropdown', + components: { + GlButtonGroup, + GlButton, + GlDropdown, + GlDropdownDivider, + NotificationsDropdownItem, + CustomNotificationsModal, + }, + directives: { + GlTooltip: GlTooltipDirective, + 'gl-modal': GlModalDirective, + }, + inject: { + containerClass: { + default: '', + }, + disabled: { + default: false, + }, + dropdownItems: { + default: [], + }, + buttonSize: { + default: 'medium', + }, + initialNotificationLevel: { + default: '', + }, + projectId: { + default: null, + }, + groupId: { + default: null, + }, + showLabel: { + default: false, + }, + }, + data() { + return { + selectedNotificationLevel: this.initialNotificationLevel, + isLoading: false, + }; + }, + computed: { + notificationLevels() { + return this.dropdownItems.map((level) => ({ + level, + title: this.$options.i18n.notificationTitles[level] || '', + description: this.$options.i18n.notificationDescriptions[level] || '', + })); + }, + isCustomNotification() { + return this.selectedNotificationLevel === CUSTOM_LEVEL; + }, + buttonIcon() { + if (this.isLoading) { + return null; + } + + return this.selectedNotificationLevel === 'disabled' ? 'notifications-off' : 'notifications'; + }, + buttonText() { + return this.showLabel + ? this.$options.i18n.notificationTitles[this.selectedNotificationLevel] + : null; + }, + buttonTooltip() { + const notificationTitle = + this.$options.i18n.notificationTitles[this.selectedNotificationLevel] || + this.selectedNotificationLevel; + + return this.disabled + ? this.$options.i18n.notificationDescriptions.owner_disabled + : sprintf(this.$options.i18n.notificationTooltipTitle, { + notification_title: notificationTitle, + }); + }, + }, + methods: { + selectItem(level) { + if (level !== this.selectedNotificationLevel) { + this.updateNotificationLevel(level); + } + }, + async updateNotificationLevel(level) { + this.isLoading = true; + + try { + await Api.updateNotificationSettings(this.projectId, this.groupId, { level }); + this.selectedNotificationLevel = level; + + if (level === CUSTOM_LEVEL) { + this.$refs.customNotificationsModal.open(); + } + } catch (error) { + this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' }); + } finally { + this.isLoading = false; + } + }, + }, + customLevel: CUSTOM_LEVEL, + i18n, + modalId: 'custom-notifications-modal', +}; +</script> + +<template> + <div :class="containerClass"> + <gl-button-group + v-if="isCustomNotification" + v-gl-tooltip="{ title: buttonTooltip }" + data-testid="notificationButton" + :size="buttonSize" + > + <gl-button + v-gl-modal="$options.modalId" + :size="buttonSize" + :icon="buttonIcon" + :loading="isLoading" + :disabled="disabled" + > + <template v-if="buttonText">{{ buttonText }}</template> + </gl-button> + <gl-dropdown :size="buttonSize" :disabled="disabled"> + <notifications-dropdown-item + v-for="item in notificationLevels" + :key="item.level" + :level="item.level" + :title="item.title" + :description="item.description" + :notification-level="selectedNotificationLevel" + @item-selected="selectItem" + /> + <gl-dropdown-divider /> + <notifications-dropdown-item + :key="$options.customLevel" + :level="$options.customLevel" + :title="$options.i18n.notificationTitles.custom" + :description="$options.i18n.notificationDescriptions.custom" + :notification-level="selectedNotificationLevel" + @item-selected="selectItem" + /> + </gl-dropdown> + </gl-button-group> + + <gl-dropdown + v-else + v-gl-tooltip="{ title: buttonTooltip }" + data-testid="notificationButton" + :text="buttonText" + :icon="buttonIcon" + :loading="isLoading" + :size="buttonSize" + :disabled="disabled" + > + <notifications-dropdown-item + v-for="item in notificationLevels" + :key="item.level" + :level="item.level" + :title="item.title" + :description="item.description" + :notification-level="selectedNotificationLevel" + @item-selected="selectItem" + /> + <gl-dropdown-divider /> + <notifications-dropdown-item + :key="$options.customLevel" + :level="$options.customLevel" + :title="$options.i18n.notificationTitles.custom" + :description="$options.i18n.notificationDescriptions.custom" + :notification-level="selectedNotificationLevel" + @item-selected="selectItem" + /> + </gl-dropdown> + <custom-notifications-modal ref="customNotificationsModal" :modal-id="$options.modalId" /> + </div> +</template> diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue new file mode 100644 index 00000000000..73bb9c1b36f --- /dev/null +++ b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue @@ -0,0 +1,42 @@ +<script> +import { GlDropdownItem } from '@gitlab/ui'; + +export default { + name: 'NotificationsDropdownItem', + components: { + GlDropdownItem, + }, + props: { + level: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + description: { + type: String, + required: true, + }, + notificationLevel: { + type: String, + required: true, + }, + }, + computed: { + isActive() { + return this.notificationLevel === this.level; + }, + }, +}; +</script> + +<template> + <gl-dropdown-item is-check-item :is-checked="isActive" @click="$emit('item-selected', level)"> + <div class="gl-display-flex gl-flex-direction-column"> + <span class="gl-font-weight-bold">{{ title }}</span> + <span class="gl-text-gray-500">{{ description }}</span> + </div> + </gl-dropdown-item> +</template> diff --git a/app/assets/javascripts/notifications/constants.js b/app/assets/javascripts/notifications/constants.js new file mode 100644 index 00000000000..07c569a0293 --- /dev/null +++ b/app/assets/javascripts/notifications/constants.js @@ -0,0 +1,58 @@ +import { __, s__ } from '~/locale'; + +export const CUSTOM_LEVEL = 'custom'; + +export const i18n = { + notificationTitles: { + participating: s__('NotificationLevel|Participate'), + mention: s__('NotificationLevel|On mention'), + watch: s__('NotificationLevel|Watch'), + global: s__('NotificationLevel|Global'), + disabled: s__('NotificationLevel|Disabled'), + custom: s__('NotificationLevel|Custom'), + }, + notificationTooltipTitle: __('Notification setting - %{notification_title}'), + notificationDescriptions: { + participating: __('You will only receive notifications for threads you have participated in'), + mention: __('You will receive notifications only for comments in which you were @mentioned'), + watch: __('You will receive notifications for any activity'), + disabled: __('You will not get any notifications via email'), + global: __('Use your global notification setting'), + custom: __('You will only receive notifications for the events you choose'), + owner_disabled: __('Notifications have been disabled by the project or group owner'), + }, + updateNotificationLevelErrorMessage: __( + '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'), + }, +}; diff --git a/app/assets/javascripts/notifications/index.js b/app/assets/javascripts/notifications/index.js new file mode 100644 index 00000000000..d60a368703c --- /dev/null +++ b/app/assets/javascripts/notifications/index.js @@ -0,0 +1,44 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import NotificationsDropdown from './components/notifications_dropdown.vue'; + +Vue.use(GlToast); + +export default () => { + const containers = document.querySelectorAll('.js-vue-notification-dropdown'); + + if (!containers.length) return false; + + return containers.forEach((el) => { + const { + containerClass, + buttonSize, + disabled, + dropdownItems, + notificationLevel, + helpPagePath, + projectId, + groupId, + showLabel, + } = el.dataset; + + return new Vue({ + el, + provide: { + containerClass, + buttonSize, + disabled: parseBoolean(disabled), + dropdownItems: JSON.parse(dropdownItems), + initialNotificationLevel: notificationLevel, + helpPagePath, + projectId, + groupId, + showLabel: parseBoolean(showLabel), + }, + render(h) { + return h(NotificationsDropdown); + }, + }); + }); +}; |