feat(notifications): admin defaults for users, and channels blocked for everyone

Admin > Notifications gets a Defaults for users matrix: each event and channel starts On, Off (a user may still turn it on) or Blocked (off for everyone). A user's own choice is kept against the default, and a value equal to it is stored as no row, so a later change of the default reaches everyone who never chose. Blocked cells show as locked in the user settings on desktop and phone, and the server ignores any attempt to turn one on. (#1536)
This commit is contained in:
Maurice
2026-10-01 12:28:36 +02:00
parent 7acf7f5655
commit f4626c9f3e
70 changed files with 680 additions and 57 deletions
@@ -0,0 +1,58 @@
// FE-ADMIN-NOTIFDEF-001 to FE-ADMIN-NOTIFDEF-003
import { describe, it, expect } from 'vitest'
import { http, HttpResponse } from 'msw'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import AdminNotificationDefaultsPanel from './AdminNotificationDefaultsPanel'
const matrix = (defaults: Record<string, Record<string, string>>) => ({
defaults,
channels: [
{ id: 'inapp', labelKey: 'settings.notificationPreferences.inapp', active: true, configured: true },
{ id: 'email', labelKey: 'settings.notificationPreferences.email', active: true, configured: true },
],
event_types: ['trip_invite', 'booking_change'],
implemented_combos: { trip_invite: ['inapp', 'email'], booking_change: ['inapp'] },
})
describe('AdminNotificationDefaultsPanel (#1536)', () => {
it('FE-ADMIN-NOTIFDEF-001: draws each cell in its state, and a dash where the channel does not apply', async () => {
server.use(http.get('/api/admin/notification-preferences/defaults', () => HttpResponse.json(matrix({
trip_invite: { inapp: 'on', email: 'blocked' }, booking_change: { inapp: 'off' },
}))))
render(<AdminNotificationDefaultsPanel />)
expect(await screen.findByText('Defaults for users')).toBeInTheDocument()
const states = Array.from(document.querySelectorAll('button[data-state]')).map(b => b.getAttribute('data-state'))
expect(states).toEqual(['on', 'blocked', 'off'])
expect(screen.getByText('—')).toBeInTheDocument()
})
it('FE-ADMIN-NOTIFDEF-002: a click moves the cell on to the next state and saves just that cell', async () => {
const sent: unknown[] = []
server.use(
http.get('/api/admin/notification-preferences/defaults', () => HttpResponse.json(matrix({ trip_invite: { inapp: 'on', email: 'on' }, booking_change: { inapp: 'on' } }))),
http.put('/api/admin/notification-preferences/defaults', async ({ request }) => {
const body = await request.json() as { defaults: Record<string, Record<string, string>> }
sent.push(body)
return HttpResponse.json(matrix({ trip_invite: { inapp: 'on', email: 'off' }, booking_change: { inapp: 'on' } }))
}),
)
render(<AdminNotificationDefaultsPanel />)
await screen.findByText('Defaults for users')
const emailCell = document.querySelectorAll('button[data-state]')[1] as HTMLElement
fireEvent.click(emailCell)
await waitFor(() => expect(sent).toEqual([{ defaults: { trip_invite: { email: 'off' } } }]))
await waitFor(() => expect(document.querySelectorAll('button[data-state]')[1].getAttribute('data-state')).toBe('off'))
})
it('FE-ADMIN-NOTIFDEF-003: a refused save puts the cell back', async () => {
server.use(
http.get('/api/admin/notification-preferences/defaults', () => HttpResponse.json(matrix({ trip_invite: { inapp: 'on', email: 'off' }, booking_change: { inapp: 'on' } }))),
http.put('/api/admin/notification-preferences/defaults', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
)
render(<AdminNotificationDefaultsPanel />)
await screen.findByText('Defaults for users')
fireEvent.click(document.querySelectorAll('button[data-state]')[1] as HTMLElement)
await waitFor(() => expect(document.querySelectorAll('button[data-state]')[1].getAttribute('data-state')).toBe('off'))
})
})