[web] display global off treatment in settings modal (#32942)

* display global disabled state
* show loading indicator while project notification preferences load

GitOrigin-RevId: d7e905aaa3fc7b15b54bf99caeabf60c1e5d8050
This commit is contained in:
Kristina
2026-04-23 09:40:29 +02:00
committed by Copybot
parent 9a129e7cab
commit a64d1bbb6a
6 changed files with 114 additions and 38 deletions

View File

@@ -266,6 +266,7 @@
"change_primary_email_address_instructions": "",
"change_project_owner": "",
"change_role_and_department": "",
"change_settings": "",
"change_the_ownership_of_your_personal_projects": "",
"change_to_group_plan": "",
"change_to_this_plan": "",
@@ -1435,6 +1436,7 @@
"project_name": "",
"project_not_linked_to_github": "",
"project_notifications": "",
"project_notifications_muted_description": "",
"project_ownership_transfer_confirmation_1": "",
"project_ownership_transfer_confirmation_2": "",
"project_renamed_or_deleted": "",

View File

@@ -1,17 +1,22 @@
import { useTranslation } from 'react-i18next'
import RadioButtonSetting, { RadioOption } from '../radio-button-setting'
import {
NotificationLevel,
SettableNotificationLevel,
useProjectNotificationPreferences,
} from '../../hooks/use-project-notification-preferences'
import BetaBadgeIcon from '@/shared/components/beta-badge-icon'
import LoadingSpinner from '@/shared/components/loading-spinner'
export default function ProjectNotificationsSetting() {
const { t } = useTranslation()
const { notificationLevel, setNotificationLevel, isLoading } =
useProjectNotificationPreferences()
const options: Array<RadioOption<NotificationLevel>> = [
if (isLoading) {
return <LoadingSpinner loadingText={t('loading')} />
}
const options: Array<RadioOption<SettableNotificationLevel>> = [
{
value: 'all',
label: t('all_project_activity'),
@@ -31,38 +36,54 @@ export default function ProjectNotificationsSetting() {
return (
<>
<RadioButtonSetting
id="projectNotifications"
options={options}
value={isLoading ? undefined : notificationLevel}
onChange={setNotificationLevel}
/>
<div className="global-notifications-link">
<a
href="/user/notification-preferences"
target="_blank"
rel="noopener noreferrer"
>
{t('manage_overleaf_email_preferences')}
</a>
</div>
<div className="project-notifications-beta-note">
<BetaBadgeIcon />
<div>
<span className="beta-note-text">
{t('email_notifications_are_currently_in_beta')}{' '}
{t('these_settings_might_change_in_the_future')}{' '}
</span>
{/* TODO: update forms link */}
{notificationLevel === 'global-off' ? (
<div className="ide-setting-description">
{t('project_notifications_muted_description')}{' '}
<a
href="/user/notification-preferences"
target="_blank"
rel="noopener noreferrer"
href="https://forms.gle/"
>
{t('give_feedback')}
{t('change_settings')}
</a>
</div>
</div>
) : (
<>
<RadioButtonSetting
id="projectNotifications"
options={options}
value={notificationLevel}
onChange={setNotificationLevel}
/>
<div className="global-notifications-link">
<a
href="/user/notification-preferences"
target="_blank"
rel="noopener noreferrer"
>
{t('manage_overleaf_email_preferences')}
</a>
</div>
<div className="project-notifications-beta-note">
<BetaBadgeIcon />
<div>
<span className="ide-setting-description">
{t('email_notifications_are_currently_in_beta')}{' '}
{t('these_settings_might_change_in_the_future')}{' '}
</span>
{/* TODO: update forms link */}
<a
target="_blank"
rel="noopener noreferrer"
href="https://forms.gle/"
>
{t('give_feedback')}
</a>
</div>
</div>
</>
)}
</>
)
}

View File

@@ -2,17 +2,21 @@ import { useCallback, useEffect, useState } from 'react'
import { useProjectContext } from '@/shared/context/project-context'
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import type { NotificationPreferencesSchema } from '../../../../../modules/notifications/app/src/types.js'
import type {
GlobalNotificationPreferencesSchema,
NotificationPreferencesSchema,
} from '../../../../../modules/notifications/app/src/types.js'
import { sendMB } from '@/infrastructure/event-tracking'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
export type NotificationLevel = 'all' | 'replies' | 'off'
export type SettableNotificationLevel = 'all' | 'replies' | 'off'
export type NotificationLevel = SettableNotificationLevel | 'global-off'
/**
* Map UI notification level to backend preferences
*/
function levelToPreferences(
level: NotificationLevel
level: SettableNotificationLevel
): NotificationPreferencesSchema {
switch (level) {
case 'all':
@@ -55,8 +59,12 @@ function levelToPreferences(
* Map backend preferences to UI notification level
*/
function preferencesToLevel(
preferences: NotificationPreferencesSchema
preferences: GlobalNotificationPreferencesSchema
): NotificationLevel {
if (preferences.muteAllNotifications) {
return 'global-off'
}
// If all notifications are off
if (
!preferences.commentOnOwnProject &&
@@ -92,7 +100,7 @@ export function useProjectNotificationPreferences() {
// Load preferences on mount
useEffect(() => {
getJSON<NotificationPreferencesSchema>(
getJSON<GlobalNotificationPreferencesSchema>(
`/notifications/preferences/project/${projectId}`
)
.then(prefs => {
@@ -103,7 +111,7 @@ export function useProjectNotificationPreferences() {
}, [projectId])
const setLevel = useCallback(
(level: NotificationLevel) => {
(level: SettableNotificationLevel) => {
setNotificationLevel(level)
const preferences = levelToPreferences(level)
sendMB('setting-changed', {

View File

@@ -116,7 +116,3 @@
align-items: center;
gap: var(--spacing-05);
}
.beta-note-text {
color: var(--content-secondary);
}

View File

@@ -351,6 +351,7 @@
"change_primary_email_address_instructions": "To change your primary email, please add your new primary email address first (by clicking <0>Add another email</0>) and confirm it. Then click the <0>Make primary</0> button. <1>Learn more about managing your __appName__ emails</1>.",
"change_project_owner": "Change project owner",
"change_role_and_department": "Change role and department",
"change_settings": "Change settings",
"change_the_ownership_of_your_personal_projects": "Change the ownership of your personal projects to the new account. <0>Find out how to change project owner.</0>",
"change_to_group_plan": "Change to a group plan",
"change_to_this_plan": "Change to this plan",
@@ -1924,6 +1925,7 @@
"project_name": "Project name",
"project_not_linked_to_github": "This project is not linked to a GitHub repository. You can create a repository for it in GitHub:",
"project_notifications": "Project notifications",
"project_notifications_muted_description": "You are not receiving any notifications, as you have disabled all project notifications.",
"project_ownership_transfer_confirmation_1": "Are you sure you want to make <0>__user__</0> the owner of <1>__project__</1>?",
"project_ownership_transfer_confirmation_2": "This action cannot be undone. The new owner will be notified and will be able to change project access settings (including removing your own access).",
"project_renamed_or_deleted": "Project Renamed or Deleted",

View File

@@ -30,6 +30,18 @@ const repliesOnlyPreferences = {
repliesOnParticipatingThread: true,
}
const globallyMutedPreferences = {
trackedChangesOnOwnProject: false,
trackedChangesOnInvitedProject: false,
commentOnOwnProject: false,
commentOnInvitedProject: false,
repliesOnOwnProject: false,
repliesOnInvitedProject: false,
repliesOnAuthoredThread: false,
repliesOnParticipatingThread: false,
muteAllNotifications: true,
}
const allNotificationsOff = {
trackedChangesOnOwnProject: false,
trackedChangesOnInvitedProject: false,
@@ -56,6 +68,16 @@ describe('<ProjectNotificationsSetting />', function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows loading indicator while preferences are loading', async function () {
fetchMock.get(preferencesUrl, new Promise(() => {}))
renderComponent()
expect(await screen.findByRole('status')).to.exist
expect(screen.queryByLabelText('All project activity', { exact: false })).to
.not.exist
})
it('selects "All project activity" when all notifications are on', async function () {
fetchMock.get(preferencesUrl, allNotificationsOn)
@@ -140,6 +162,31 @@ describe('<ProjectNotificationsSetting />', function () {
).to.be.false
})
it('shows muted message and hides radio buttons when muteAllNotifications is true', async function () {
fetchMock.get(preferencesUrl, globallyMutedPreferences)
renderComponent()
await waitFor(
() =>
expect(
screen.getByText(
'You are not receiving any notifications, as you have disabled all project notifications.',
{ exact: false }
)
).to.exist
)
expect(
screen.getByRole('link', { name: 'Change settings' }).getAttribute('href')
).to.equal('/user/notification-preferences')
expect(screen.queryByLabelText('All project activity', { exact: false })).to
.not.exist
expect(
screen.queryByLabelText('Replies to your activity only', { exact: false })
).to.not.exist
expect(screen.queryByLabelText('Off', { exact: false })).to.not.exist
})
it('POSTs "replies" preferences when "Replies to your activity only" is selected', async function () {
fetchMock.get(preferencesUrl, allNotificationsOn)
const saveMock = fetchMock.post(preferencesUrl, { status: 200 })