Commit fb4431b1 authored by wortschi's avatar wortschi

Add Vue notifications dropdown

- Renders a Vue notification dropdown
component on the project overview page
parent a8c61b1e
...@@ -83,6 +83,9 @@ const Api = { ...@@ -83,6 +83,9 @@ const Api = {
featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid', featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid',
billableGroupMembersPath: '/api/:version/groups/:id/billable_members', billableGroupMembersPath: '/api/:version/groups/:id/billable_members',
containerRegistryDetailsPath: '/api/:version/registry/repositories/:id/', containerRegistryDetailsPath: '/api/:version/registry/repositories/:id/',
projectNotificationSettingsPath: '/api/:version/projects/:id/notification_settings',
groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings',
notificationSettingsPath: '/api/:version/notification_settings',
group(groupId, callback = () => {}) { group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
...@@ -906,6 +909,34 @@ const Api = { ...@@ -906,6 +909,34 @@ const Api = {
return { data, headers }; return { data, headers };
}); });
}, },
async updateNotificationSettings(projectId, groupId, data = {}) {
let url = Api.buildUrl(this.notificationSettingsPath);
if (projectId) {
url = Api.buildUrl(this.projectNotificationSettingsPath).replace(':id', projectId);
} else if (groupId) {
url = Api.buildUrl(this.groupNotificationSettingsPath).replace(':id', groupId);
}
const result = await axios.put(url, data);
return result;
},
async getNotificationSettings(projectId, groupId) {
let url = Api.buildUrl(this.notificationSettingsPath);
if (projectId) {
url = Api.buildUrl(this.projectNotificationSettingsPath).replace(':id', projectId);
} else if (groupId) {
url = Api.buildUrl(this.groupNotificationSettingsPath).replace(':id', groupId);
}
const result = await axios.get(url);
return result;
},
}; };
export default Api; export default Api;
<script>
import {
GlButtonGroup,
GlButton,
GlDropdown,
GlDropdownDivider,
GlTooltipDirective,
} from '@gitlab/ui';
import { sprintf } from '~/locale';
import Api from '~/api';
import NotificationsDropdownItem from './notifications_dropdown_item.vue';
import { CUSTOM_LEVEL, i18n } from '../constants';
export default {
name: 'NotificationsDropdown',
components: {
GlButtonGroup,
GlButton,
GlDropdown,
GlDropdownDivider,
NotificationsDropdownItem,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
containerClass: {
default: '',
},
disabled: {
default: false,
},
dropdownItems: {
default: [],
},
buttonSize: {
default: 'medium',
},
initialNotificationLevel: {
default: '',
},
projectId: {
default: null,
},
groupId: {
default: null,
},
},
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';
},
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;
} catch (error) {
this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' });
} finally {
this.isLoading = false;
}
},
},
customLevel: CUSTOM_LEVEL,
i18n,
};
</script>
<template>
<div :class="containerClass">
<gl-button-group
v-if="isCustomNotification"
v-gl-tooltip="{ title: buttonTooltip }"
data-testid="notificationButton"
:size="buttonSize"
>
<gl-button :size="buttonSize" :icon="buttonIcon" :loading="isLoading" :disabled="disabled" />
<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"
: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>
</div>
</template>
<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>
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.',
),
};
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils';
import NotificationsDropdown from './components/notifications_dropdown.vue';
Vue.use(GlToast);
export default () => {
const el = document.querySelector('.js-vue-notification-dropdown');
if (!el) return false;
const {
containerClass,
buttonSize,
disabled,
dropdownItems,
notificationLevel,
projectId,
groupId,
} = el.dataset;
return new Vue({
el,
provide: {
containerClass,
buttonSize,
disabled: parseBoolean(disabled),
dropdownItems: JSON.parse(dropdownItems),
initialNotificationLevel: notificationLevel,
projectId,
groupId,
},
render(h) {
return h(NotificationsDropdown);
},
});
};
...@@ -12,6 +12,7 @@ import notificationsDropdown from '../../../notifications_dropdown'; ...@@ -12,6 +12,7 @@ import notificationsDropdown from '../../../notifications_dropdown';
import { showLearnGitLabProjectPopover } from '~/onboarding_issues'; import { showLearnGitLabProjectPopover } from '~/onboarding_issues';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initVueNotificationsDropdown from '~/notifications';
initReadMore(); initReadMore();
new Star(); // eslint-disable-line no-new new Star(); // eslint-disable-line no-new
...@@ -42,7 +43,14 @@ leaveByUrl('project'); ...@@ -42,7 +43,14 @@ leaveByUrl('project');
showLearnGitLabProjectPopover(); showLearnGitLabProjectPopover();
notificationsDropdown(); if (gon.features?.vueNotificationDropdown) {
initVueNotificationsDropdown();
} else {
notificationsDropdown();
}
initVueNotificationsDropdown();
new ShortcutsNavigation(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new
initInviteMembersTrigger(); initInviteMembersTrigger();
......
...@@ -31,6 +31,10 @@ class ProjectsController < Projects::ApplicationController ...@@ -31,6 +31,10 @@ class ProjectsController < Projects::ApplicationController
# Project Export Rate Limit # Project Export Rate Limit
before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export] before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export]
before_action do
push_frontend_feature_flag(:vue_notification_dropdown, @project, default_enabled: :yaml)
end
before_action only: [:edit] do before_action only: [:edit] do
push_frontend_feature_flag(:approval_suggestions, @project, default_enabled: true) push_frontend_feature_flag(:approval_suggestions, @project, default_enabled: true)
push_frontend_feature_flag(:allow_editing_commit_messages, @project) push_frontend_feature_flag(:allow_editing_commit_messages, @project)
......
...@@ -125,4 +125,13 @@ module NotificationsHelper ...@@ -125,4 +125,13 @@ module NotificationsHelper
def can_read_project?(project) def can_read_project?(project)
can?(current_user, :read_project, project) can?(current_user, :read_project, project)
end end
def notification_dropdown_items(notification_setting)
NotificationSetting.levels.each_key.map do |level|
next if level == "custom"
next if level == "global" && notification_setting.source.nil?
level
end.compact
end
end end
...@@ -46,7 +46,11 @@ ...@@ -46,7 +46,11 @@
.project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end .project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end
- if current_user - if current_user
.d-inline-flex .d-inline-flex
= render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn-xs', dropdown_container_class: 'gl-mr-3', emails_disabled: emails_disabled - if Feature.enabled?(:vue_notification_dropdown, @project, default_enabled: :yaml)
- if @notification_setting
.js-vue-notification-dropdown{ data: { button_size: "small", disabled: emails_disabled, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id, container_class: 'gl-mr-3 gl-mt-5 gl-vertical-align-top' } }
- else
= render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn-xs', dropdown_container_class: 'gl-mr-3', emails_disabled: emails_disabled
.count-buttons.d-inline-flex .count-buttons.d-inline-flex
= render 'projects/buttons/star' = render 'projects/buttons/star'
......
---
name: vue_notification_dropdown
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52068
rollout_issue_url:
milestone: '13.8'
type: development
group: group::optimize
default_enabled: false
...@@ -29,7 +29,10 @@ RSpec.describe 'Kerberos clone instructions', :js do ...@@ -29,7 +29,10 @@ RSpec.describe 'Kerberos clone instructions', :js do
it 'shows the Kerberos clone information' do it 'shows the Kerberos clone information' do
resize_screen_xs resize_screen_xs
visit_project visit_project
find('.dropdown-toggle').click
within('.js-mobile-git-clone') do
find('.dropdown-toggle').click
end
expect(page).to have_content('Copy KRB5 clone URL') expect(page).to have_content('Copy KRB5 clone URL')
end end
......
...@@ -3054,6 +3054,9 @@ msgstr "" ...@@ -3054,6 +3054,9 @@ msgstr ""
msgid "An error occured while saving changes: %{error}" msgid "An error occured while saving changes: %{error}"
msgstr "" msgstr ""
msgid "An error occured while updating the notification settings. Please try again."
msgstr ""
msgid "An error occurred adding a draft to the thread." msgid "An error occurred adding a draft to the thread."
msgstr "" msgstr ""
......
...@@ -37,7 +37,10 @@ RSpec.describe 'Admin disables Git access protocol', :js do ...@@ -37,7 +37,10 @@ RSpec.describe 'Admin disables Git access protocol', :js do
it 'shows only the SSH clone information' do it 'shows only the SSH clone information' do
resize_screen_xs resize_screen_xs
visit_project visit_project
find('.dropdown-toggle').click
within('.js-mobile-git-clone') do
find('.dropdown-toggle').click
end
expect(page).to have_content('Copy SSH clone URL') expect(page).to have_content('Copy SSH clone URL')
expect(page).not_to have_content('Copy HTTP clone URL') expect(page).not_to have_content('Copy HTTP clone URL')
...@@ -66,7 +69,10 @@ RSpec.describe 'Admin disables Git access protocol', :js do ...@@ -66,7 +69,10 @@ RSpec.describe 'Admin disables Git access protocol', :js do
it 'shows only the HTTP clone information' do it 'shows only the HTTP clone information' do
resize_screen_xs resize_screen_xs
visit_project visit_project
find('.dropdown-toggle').click
within('.js-mobile-git-clone') do
find('.dropdown-toggle').click
end
expect(page).to have_content('Copy HTTP clone URL') expect(page).to have_content('Copy HTTP clone URL')
expect(page).not_to have_content('Copy SSH clone URL') expect(page).not_to have_content('Copy SSH clone URL')
...@@ -97,7 +103,10 @@ RSpec.describe 'Admin disables Git access protocol', :js do ...@@ -97,7 +103,10 @@ RSpec.describe 'Admin disables Git access protocol', :js do
it 'shows both SSH and HTTP clone information' do it 'shows both SSH and HTTP clone information' do
resize_screen_xs resize_screen_xs
visit_project visit_project
find('.dropdown-toggle').click
within('.js-mobile-git-clone') do
find('.dropdown-toggle').click
end
expect(page).to have_content('Copy HTTP clone URL') expect(page).to have_content('Copy HTTP clone URL')
expect(page).to have_content('Copy SSH clone URL') expect(page).to have_content('Copy SSH clone URL')
......
...@@ -6,6 +6,7 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do ...@@ -6,6 +6,7 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do
let(:project) { create(:project, :public, :repository) } let(:project) { create(:project, :public, :repository) }
before do before do
stub_feature_flags(vue_notification_dropdown: false)
sign_in(project.owner) sign_in(project.owner)
end end
......
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import waitForPromises from 'helpers/wait_for_promises';
import { GlButtonGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import httpStatus from '~/lib/utils/http_status';
import NotificationsDropdown from '~/notifications/components/notifications_dropdown.vue';
import NotificationsDropdownItem from '~/notifications/components/notifications_dropdown_item.vue';
const mockDropdownItems = ['global', 'watch', 'participating', 'mention', 'disabled'];
const mockToastShow = jest.fn();
describe('NotificationsDropdown', () => {
let wrapper;
let mockAxios;
function createComponent(injectedProperties = {}) {
return shallowMount(NotificationsDropdown, {
stubs: {
GlButtonGroup,
GlDropdown,
GlDropdownItem,
NotificationsDropdownItem,
},
directives: {
GlTooltip: createMockDirective(),
},
provide: {
...injectedProperties,
},
mocks: {
$toast: {
show: mockToastShow,
},
},
});
}
const findButtonGroup = () => wrapper.find(GlButtonGroup);
const findDropdown = () => wrapper.find(GlDropdown);
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findAllNotificationsDropdownItems = () => wrapper.findAll(NotificationsDropdownItem);
const findDropdownItemAt = (index) =>
findAllNotificationsDropdownItems().at(index).find(GlDropdownItem);
const clickDropdownItemAt = async (index) => {
const dropdownItem = findDropdownItemAt(index);
dropdownItem.vm.$emit('click');
await waitForPromises();
};
beforeEach(() => {
gon.api_version = 'v4';
mockAxios = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
mockAxios.restore();
});
describe('template', () => {
describe('when notification level is "custom"', () => {
beforeEach(() => {
wrapper = createComponent({
dropdownItems: mockDropdownItems,
initialNotificationLevel: 'custom',
});
});
it('renders a button group', () => {
expect(findButtonGroup().exists()).toBe(true);
});
});
describe('when notification level is not "custom"', () => {
beforeEach(() => {
wrapper = createComponent({
dropdownItems: mockDropdownItems,
initialNotificationLevel: 'global',
});
});
it('does not render a button group', () => {
expect(findButtonGroup().exists()).toBe(false);
});
});
describe('button tooltip', () => {
const tooltipTitlePrefix = 'Notification setting';
it.each`
level | title
${'global'} | ${'Global'}
${'watch'} | ${'Watch'}
${'participating'} | ${'Participate'}
${'mention'} | ${'On mention'}
${'disabled'} | ${'Disabled'}
${'custom'} | ${'Custom'}
`(`renders "${tooltipTitlePrefix} - $title" for "$level" level`, ({ level, title }) => {
wrapper = createComponent({
dropdownItems: mockDropdownItems,
initialNotificationLevel: level,
});
const tooltipElement = findByTestId('notificationButton');
const tooltip = getBinding(tooltipElement.element, 'gl-tooltip');
expect(tooltip.value.title).toBe(`${tooltipTitlePrefix} - ${title}`);
});
});
describe('button icon', () => {
beforeEach(() => {
wrapper = createComponent({
dropdownItems: mockDropdownItems,
initialNotificationLevel: 'disabled',
});
});
it('renders the "notifications-off" icon when notification level is "disabled"', () => {
expect(findDropdown().props('icon')).toBe('notifications-off');
});
it('renders the "notifications" icon when notification level is not "disabled"', () => {
wrapper = createComponent({
dropdownItems: mockDropdownItems,
initialNotificationLevel: 'global',
});
expect(findDropdown().props('icon')).toBe('notifications');
});
});
describe('dropdown items', () => {
it.each`
dropdownIndex | level | title | description
${0} | ${'global'} | ${'Global'} | ${'Use your global notification setting'}
${1} | ${'watch'} | ${'Watch'} | ${'You will receive notifications for any activity'}
${2} | ${'participating'} | ${'Participate'} | ${'You will only receive notifications for threads you have participated in'}
${3} | ${'mention'} | ${'On mention'} | ${'You will receive notifications only for comments in which you were @mentioned'}
${4} | ${'disabled'} | ${'Disabled'} | ${'You will not get any notifications via email'}
${5} | ${'custom'} | ${'Custom'} | ${'You will only receive notifications for the events you choose'}
`('displays "$title" and "$description"', ({ dropdownIndex, title, description }) => {
wrapper = createComponent({
dropdownItems: mockDropdownItems,
initialNotificationLevel: 'global',
});
expect(findAllNotificationsDropdownItems().at(dropdownIndex).props('title')).toBe(title);
expect(findAllNotificationsDropdownItems().at(dropdownIndex).props('description')).toBe(
description,
);
});
});
});
describe('when selecting an item', () => {
beforeEach(() => {
jest.spyOn(axios, 'put');
});
it.each`
projectId | groupId | endpointUrl | endpointType | condition
${1} | ${null} | ${'/api/v4/projects/1/notification_settings'} | ${'project notifications'} | ${'a projectId is given'}
${null} | ${1} | ${'/api/v4/groups/1/notification_settings'} | ${'group notifications'} | ${'a groupId is given'}
${null} | ${null} | ${'/api/v4/notification_settings'} | ${'global notifications'} | ${'when neither projectId nor groupId are given'}
`(
'calls the $endpointType endpoint when $condition',
async ({ projectId, groupId, endpointUrl }) => {
wrapper = createComponent({
dropdownItems: mockDropdownItems,
initialNotificationLevel: 'global',
projectId,
groupId,
});
await clickDropdownItemAt(1);
expect(axios.put).toHaveBeenCalledWith(endpointUrl, {
level: 'watch',
});
},
);
it('updates the selectedNotificationLevel and marks the item with a checkmark', async () => {
mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.OK, {});
wrapper = createComponent({
dropdownItems: mockDropdownItems,
initialNotificationLevel: 'global',
});
const dropdownItem = findDropdownItemAt(1);
await clickDropdownItemAt(1);
expect(wrapper.vm.selectedNotificationLevel).toBe('watch');
expect(dropdownItem.props('isChecked')).toBe(true);
});
it("won't update the selectedNotificationLevel and shows a toast message when the request fails and ", async () => {
mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.NOT_FOUND, {});
wrapper = createComponent({
dropdownItems: mockDropdownItems,
initialNotificationLevel: 'global',
});
await clickDropdownItemAt(1);
expect(wrapper.vm.selectedNotificationLevel).toBe('global');
expect(
mockToastShow,
).toHaveBeenCalledWith(
'An error occured while updating the notification settings. Please try again.',
{ type: 'error' },
);
});
});
});
...@@ -9,6 +9,7 @@ RSpec.describe 'projects/_home_panel' do ...@@ -9,6 +9,7 @@ RSpec.describe 'projects/_home_panel' do
let(:project) { create(:project) } let(:project) { create(:project) }
before do before do
stub_feature_flags(vue_notification_dropdown: false)
assign(:project, project) assign(:project, project)
allow(view).to receive(:current_user).and_return(user) allow(view).to receive(:current_user).and_return(user)
......
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