@micha.bigler/ui-core-micha 2.10.1 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +3 -1
- package/dist/auth/AuthContext.js +1 -0
- package/dist/auth/authApi.js +3 -1
- package/dist/components/TurnstileWidget.js +84 -0
- package/dist/i18n/notificationsTranslations.js +3 -0
- package/dist/index.js +3 -0
- package/dist/notifications/NotificationBell.js +29 -0
- package/dist/notifications/NotificationsProvider.js +194 -0
- package/dist/notifications/feedApi.js +15 -0
- package/dist/pages/SignUpPage.js +22 -2
- package/package.json +1 -1
- package/src/auth/AuthContext.jsx +1 -0
- package/src/auth/authApi.jsx +2 -0
- package/src/components/TurnstileWidget.jsx +99 -0
- package/src/i18n/notificationsTranslations.ts +3 -0
- package/src/index.js +3 -0
- package/src/notifications/NotificationBell.jsx +84 -0
- package/src/notifications/NotificationsProvider.jsx +232 -0
- package/src/notifications/feedApi.js +18 -0
- package/src/pages/SignUpPage.jsx +38 -2
- package/tests/NotificationBell.test.jsx +60 -0
- package/tests/NotificationsProvider.test.jsx +197 -0
- package/tests/SignUpPage.test.jsx +138 -0
- package/tests/authApiRegistration.test.js +31 -0
- package/tests/feedApi.test.js +46 -0
- package/tests/notificationsExports.test.js +29 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import NotificationsIcon from '@mui/icons-material/Notifications';
|
|
4
|
+
import {
|
|
5
|
+
Badge,
|
|
6
|
+
Box,
|
|
7
|
+
Button,
|
|
8
|
+
IconButton,
|
|
9
|
+
List,
|
|
10
|
+
ListItemButton,
|
|
11
|
+
ListItemText,
|
|
12
|
+
Menu,
|
|
13
|
+
Typography,
|
|
14
|
+
} from '@mui/material';
|
|
15
|
+
import { useTranslation } from 'react-i18next';
|
|
16
|
+
|
|
17
|
+
import { useNotifications } from './NotificationsProvider';
|
|
18
|
+
|
|
19
|
+
export function NotificationBell({ resolveLink }) {
|
|
20
|
+
const { t } = useTranslation();
|
|
21
|
+
const { notifications, unreadCount, markSeen } = useNotifications();
|
|
22
|
+
const [anchorEl, setAnchorEl] = useState(null);
|
|
23
|
+
const open = Boolean(anchorEl);
|
|
24
|
+
const unreadIds = notifications
|
|
25
|
+
.filter((notification) => !notification.seen && !notification.dismissed && notification.id != null)
|
|
26
|
+
.map((notification) => notification.id);
|
|
27
|
+
|
|
28
|
+
const handleItemClick = (notification) => {
|
|
29
|
+
if (notification.id != null) markSeen([notification.id]);
|
|
30
|
+
setAnchorEl(null);
|
|
31
|
+
if (resolveLink && notification.content?.link) resolveLink(notification.content.link);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
<IconButton
|
|
37
|
+
aria-label={t('NotificationBell.TITLE')}
|
|
38
|
+
color="inherit"
|
|
39
|
+
onClick={(event) => setAnchorEl(event.currentTarget)}
|
|
40
|
+
>
|
|
41
|
+
<Badge badgeContent={unreadCount} color="error" max={99}>
|
|
42
|
+
<NotificationsIcon />
|
|
43
|
+
</Badge>
|
|
44
|
+
</IconButton>
|
|
45
|
+
<Menu
|
|
46
|
+
anchorEl={anchorEl}
|
|
47
|
+
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
|
48
|
+
onClose={() => setAnchorEl(null)}
|
|
49
|
+
open={open}
|
|
50
|
+
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
|
51
|
+
>
|
|
52
|
+
<Box sx={{ minWidth: 320 }}>
|
|
53
|
+
<Box sx={{ alignItems: 'center', display: 'flex', justifyContent: 'space-between', px: 2, py: 1 }}>
|
|
54
|
+
<Typography variant="subtitle1">{t('NotificationBell.TITLE')}</Typography>
|
|
55
|
+
{unreadIds.length > 0 && (
|
|
56
|
+
<Button onClick={() => markSeen(unreadIds)} size="small">
|
|
57
|
+
{t('NotificationBell.MARK_ALL')}
|
|
58
|
+
</Button>
|
|
59
|
+
)}
|
|
60
|
+
</Box>
|
|
61
|
+
{notifications.length === 0 ? (
|
|
62
|
+
<Typography sx={{ px: 2, py: 2 }} variant="body2">
|
|
63
|
+
{t('NotificationBell.EMPTY')}
|
|
64
|
+
</Typography>
|
|
65
|
+
) : (
|
|
66
|
+
<List dense disablePadding>
|
|
67
|
+
{notifications.map((notification) => (
|
|
68
|
+
<ListItemButton key={notification.id ?? notification.notification_id} onClick={() => handleItemClick(notification)}>
|
|
69
|
+
<ListItemText
|
|
70
|
+
primary={t(notification.content?.title_key, notification.content?.params || {})}
|
|
71
|
+
secondary={notification.content?.body_key
|
|
72
|
+
? t(notification.content.body_key, notification.content.params || {})
|
|
73
|
+
: undefined}
|
|
74
|
+
slotProps={{ primary: { fontWeight: notification.seen || notification.dismissed ? 'regular' : 'bold' } }}
|
|
75
|
+
/>
|
|
76
|
+
</ListItemButton>
|
|
77
|
+
))}
|
|
78
|
+
</List>
|
|
79
|
+
)}
|
|
80
|
+
</Box>
|
|
81
|
+
</Menu>
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { AuthContext } from '../auth/AuthContext';
|
|
4
|
+
import { getNotificationFeed, getUnreadCount, markNotifications } from './feedApi';
|
|
5
|
+
|
|
6
|
+
const INITIAL_BACKOFF_MS = 1_000;
|
|
7
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
const NotificationsContext = createContext(null);
|
|
10
|
+
|
|
11
|
+
function normalizeNotification(notification) {
|
|
12
|
+
return {
|
|
13
|
+
...notification,
|
|
14
|
+
content: notification.content || {},
|
|
15
|
+
seen: Boolean(notification.seen ?? notification.seen_at),
|
|
16
|
+
dismissed: Boolean(notification.dismissed ?? notification.dismissed_at),
|
|
17
|
+
done: Boolean(notification.done ?? notification.done_at),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeNotificationPush(data) {
|
|
22
|
+
return normalizeNotification({
|
|
23
|
+
id: data.id ?? data.recipient_id ?? data.notification_id,
|
|
24
|
+
notification_id: data.notification_id,
|
|
25
|
+
notification_type: data.type,
|
|
26
|
+
content: data.content || {},
|
|
27
|
+
created_at: data.created_at,
|
|
28
|
+
seen_at: null,
|
|
29
|
+
dismissed_at: null,
|
|
30
|
+
done_at: null,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getWebSocketUrl(wsUrlBase) {
|
|
35
|
+
if (wsUrlBase) {
|
|
36
|
+
return new URL('/ws/notifications/', wsUrlBase).toString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
40
|
+
return `${protocol}//${window.location.host}/ws/notifications/`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function patchStatus(notification, status) {
|
|
44
|
+
const seen = Boolean(status.seen);
|
|
45
|
+
const dismissed = Boolean(status.dismissed);
|
|
46
|
+
const done = Boolean(status.done);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
...notification,
|
|
50
|
+
seen,
|
|
51
|
+
dismissed,
|
|
52
|
+
done,
|
|
53
|
+
seen_at: seen ? notification.seen_at || true : null,
|
|
54
|
+
dismissed_at: dismissed ? notification.dismissed_at || true : null,
|
|
55
|
+
done_at: done ? notification.done_at || true : null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function NotificationsProvider({ children, wsUrlBase }) {
|
|
60
|
+
const auth = useContext(AuthContext);
|
|
61
|
+
const authenticated = Boolean(auth?.user);
|
|
62
|
+
const [notifications, setNotifications] = useState([]);
|
|
63
|
+
const [unreadCount, setUnreadCount] = useState(0);
|
|
64
|
+
const notificationsRef = useRef([]);
|
|
65
|
+
const socketRef = useRef(null);
|
|
66
|
+
|
|
67
|
+
const replaceNotifications = useCallback((nextNotifications) => {
|
|
68
|
+
notificationsRef.current = nextNotifications;
|
|
69
|
+
setNotifications(nextNotifications);
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const refresh = useCallback(async () => {
|
|
73
|
+
if (!authenticated) {
|
|
74
|
+
replaceNotifications([]);
|
|
75
|
+
setUnreadCount(0);
|
|
76
|
+
return { notifications: [], unreadCount: 0 };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const [feed, unread] = await Promise.all([getNotificationFeed(), getUnreadCount()]);
|
|
80
|
+
const nextNotifications = (feed.results || []).map(normalizeNotification);
|
|
81
|
+
const nextUnreadCount = unread.count || 0;
|
|
82
|
+
replaceNotifications(nextNotifications);
|
|
83
|
+
setUnreadCount(nextUnreadCount);
|
|
84
|
+
return { notifications: nextNotifications, unreadCount: nextUnreadCount };
|
|
85
|
+
}, [authenticated, replaceNotifications]);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!authenticated) {
|
|
89
|
+
replaceNotifications([]);
|
|
90
|
+
setUnreadCount(0);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let active = true;
|
|
95
|
+
let reconnectTimer = null;
|
|
96
|
+
let backoffMs = INITIAL_BACKOFF_MS;
|
|
97
|
+
|
|
98
|
+
const patchNotifications = (updater) => {
|
|
99
|
+
const nextNotifications = updater(notificationsRef.current);
|
|
100
|
+
replaceNotifications(nextNotifications);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleMessage = (event) => {
|
|
104
|
+
let data;
|
|
105
|
+
try {
|
|
106
|
+
data = JSON.parse(event.data);
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (data.type === 'notification.status') {
|
|
112
|
+
let unreadDelta = 0;
|
|
113
|
+
patchNotifications((current) => current.map((notification) => {
|
|
114
|
+
if (notification.notification_id !== data.notification_id) return notification;
|
|
115
|
+
|
|
116
|
+
const wasUnread = !notification.seen && !notification.dismissed;
|
|
117
|
+
const patched = patchStatus(notification, data.status || {});
|
|
118
|
+
const isUnread = !patched.seen && !patched.dismissed;
|
|
119
|
+
if (wasUnread !== isUnread) {
|
|
120
|
+
unreadDelta += isUnread ? 1 : -1;
|
|
121
|
+
}
|
|
122
|
+
return patched;
|
|
123
|
+
}));
|
|
124
|
+
if (unreadDelta) {
|
|
125
|
+
setUnreadCount((count) => Math.max(0, count + unreadDelta));
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const pushedNotification = normalizeNotificationPush(data);
|
|
131
|
+
patchNotifications((current) => [pushedNotification, ...current]);
|
|
132
|
+
setUnreadCount((count) => count + 1);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const connect = () => {
|
|
136
|
+
if (!active || socketRef.current) return;
|
|
137
|
+
|
|
138
|
+
const socket = new WebSocket(getWebSocketUrl(wsUrlBase));
|
|
139
|
+
socketRef.current = socket;
|
|
140
|
+
|
|
141
|
+
socket.onopen = () => {
|
|
142
|
+
backoffMs = INITIAL_BACKOFF_MS;
|
|
143
|
+
};
|
|
144
|
+
socket.onmessage = handleMessage;
|
|
145
|
+
socket.onerror = () => {
|
|
146
|
+
socket.close();
|
|
147
|
+
};
|
|
148
|
+
socket.onclose = () => {
|
|
149
|
+
if (socketRef.current === socket) {
|
|
150
|
+
socketRef.current = null;
|
|
151
|
+
}
|
|
152
|
+
if (!active || reconnectTimer) return;
|
|
153
|
+
reconnectTimer = setTimeout(() => {
|
|
154
|
+
reconnectTimer = null;
|
|
155
|
+
connect();
|
|
156
|
+
}, backoffMs);
|
|
157
|
+
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const seedAndConnect = async () => {
|
|
162
|
+
try {
|
|
163
|
+
await refresh();
|
|
164
|
+
} catch {
|
|
165
|
+
// The live stream remains useful if the initial REST request is temporarily unavailable.
|
|
166
|
+
}
|
|
167
|
+
if (active) connect();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
seedAndConnect();
|
|
171
|
+
|
|
172
|
+
return () => {
|
|
173
|
+
active = false;
|
|
174
|
+
clearTimeout(reconnectTimer);
|
|
175
|
+
const socket = socketRef.current;
|
|
176
|
+
socketRef.current = null;
|
|
177
|
+
if (socket) socket.close();
|
|
178
|
+
};
|
|
179
|
+
}, [authenticated, refresh, replaceNotifications, wsUrlBase]);
|
|
180
|
+
|
|
181
|
+
const mark = useCallback((action, ids) => {
|
|
182
|
+
const selectedIds = Array.isArray(ids) ? ids : [];
|
|
183
|
+
const selected = new Set(selectedIds);
|
|
184
|
+
const current = notificationsRef.current;
|
|
185
|
+
let unreadDelta = 0;
|
|
186
|
+
const nextNotifications = current.map((notification) => {
|
|
187
|
+
if (!selected.has(notification.id)) return notification;
|
|
188
|
+
|
|
189
|
+
const wasUnread = !notification.seen && !notification.dismissed;
|
|
190
|
+
if ((action === 'seen' || action === 'dismissed') && wasUnread) {
|
|
191
|
+
unreadDelta += 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return patchStatus(notification, {
|
|
195
|
+
seen: action === 'seen' ? true : notification.seen,
|
|
196
|
+
dismissed: action === 'dismissed' ? true : notification.dismissed,
|
|
197
|
+
done: action === 'done' ? true : notification.done,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
replaceNotifications(nextNotifications);
|
|
202
|
+
if (unreadDelta) {
|
|
203
|
+
setUnreadCount((count) => Math.max(0, count - unreadDelta));
|
|
204
|
+
}
|
|
205
|
+
return markNotifications({ action, ids: selectedIds });
|
|
206
|
+
}, [replaceNotifications]);
|
|
207
|
+
|
|
208
|
+
const markSeen = useCallback((ids) => mark('seen', ids), [mark]);
|
|
209
|
+
const markDismissed = useCallback((ids) => mark('dismissed', ids), [mark]);
|
|
210
|
+
const markDone = useCallback((ids) => mark('done', ids), [mark]);
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<NotificationsContext.Provider value={{
|
|
214
|
+
notifications,
|
|
215
|
+
unreadCount,
|
|
216
|
+
markSeen,
|
|
217
|
+
markDismissed,
|
|
218
|
+
markDone,
|
|
219
|
+
refresh,
|
|
220
|
+
}}>
|
|
221
|
+
{children}
|
|
222
|
+
</NotificationsContext.Provider>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function useNotifications() {
|
|
227
|
+
const context = useContext(NotificationsContext);
|
|
228
|
+
if (!context) {
|
|
229
|
+
throw new Error('useNotifications must be used within a NotificationsProvider');
|
|
230
|
+
}
|
|
231
|
+
return context;
|
|
232
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import apiClient from '../auth/apiClient';
|
|
2
|
+
|
|
3
|
+
export async function getNotificationFeed({ status } = {}) {
|
|
4
|
+
const response = await apiClient.get('/api/notifications/feed/', {
|
|
5
|
+
params: status ? { status } : undefined,
|
|
6
|
+
});
|
|
7
|
+
return response.data;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function getUnreadCount() {
|
|
11
|
+
const response = await apiClient.get('/api/notifications/feed/unread-count/');
|
|
12
|
+
return response.data;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function markNotifications({ action, ids }) {
|
|
16
|
+
const response = await apiClient.post('/api/notifications/feed/mark/', { action, ids });
|
|
17
|
+
return response.data;
|
|
18
|
+
}
|
package/src/pages/SignUpPage.jsx
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useContext,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
2
8
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
3
9
|
import {
|
|
4
10
|
Alert,
|
|
@@ -13,6 +19,12 @@ import { useTranslation } from 'react-i18next';
|
|
|
13
19
|
import { NarrowPage } from '../layout/PageLayout';
|
|
14
20
|
import { AuthContext } from '../auth/AuthContext';
|
|
15
21
|
import { submitRegistrationRequest } from '../auth/authApi';
|
|
22
|
+
import { TurnstileWidget } from '../components/TurnstileWidget';
|
|
23
|
+
|
|
24
|
+
const TURNSTILE_SIGNUP_MODES = new Set([
|
|
25
|
+
'self_signup_open',
|
|
26
|
+
'self_signup_email_domain',
|
|
27
|
+
]);
|
|
16
28
|
|
|
17
29
|
const MODE_LABELS = {
|
|
18
30
|
self_signup_access_code: 'Auth.SIGNUP_ACCESS_CODE_TAB',
|
|
@@ -60,6 +72,11 @@ export function SignUpPage() {
|
|
|
60
72
|
const [submitting, setSubmitting] = useState(false);
|
|
61
73
|
const [successKey, setSuccessKey] = useState(null);
|
|
62
74
|
const [errorKey, setErrorKey] = useState(null);
|
|
75
|
+
const [turnstileToken, setTurnstileToken] = useState('');
|
|
76
|
+
const turnstileWidgetRef = useRef(null);
|
|
77
|
+
|
|
78
|
+
const turnstileRequired = Boolean(authMethods?.turnstile_site_key)
|
|
79
|
+
&& TURNSTILE_SIGNUP_MODES.has(mode);
|
|
63
80
|
|
|
64
81
|
const pageSubtitle = useMemo(() => {
|
|
65
82
|
if (mode === 'self_signup_qr' && tokenFromUrl) {
|
|
@@ -78,6 +95,10 @@ export function SignUpPage() {
|
|
|
78
95
|
setMode(initialMode);
|
|
79
96
|
}, [initialMode]);
|
|
80
97
|
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
setTurnstileToken('');
|
|
100
|
+
}, [mode, authMethods?.turnstile_site_key]);
|
|
101
|
+
|
|
81
102
|
const handleSubmit = async (event) => {
|
|
82
103
|
event.preventDefault();
|
|
83
104
|
setSuccessKey(null);
|
|
@@ -98,6 +119,8 @@ export function SignUpPage() {
|
|
|
98
119
|
return;
|
|
99
120
|
}
|
|
100
121
|
|
|
122
|
+
if (turnstileRequired && !turnstileToken) return;
|
|
123
|
+
|
|
101
124
|
setSubmitting(true);
|
|
102
125
|
try {
|
|
103
126
|
await submitRegistrationRequest({
|
|
@@ -105,10 +128,15 @@ export function SignUpPage() {
|
|
|
105
128
|
mode,
|
|
106
129
|
accessCode,
|
|
107
130
|
registrationContextToken: mode === 'self_signup_qr' ? tokenFromUrl : null,
|
|
131
|
+
turnstileToken: turnstileRequired ? turnstileToken : undefined,
|
|
108
132
|
});
|
|
109
133
|
setSuccessKey('Auth.INVITE_REQUEST_SUCCESS');
|
|
110
134
|
} catch (err) {
|
|
111
135
|
setErrorKey(err.code || 'Auth.INVITE_FAILED');
|
|
136
|
+
if (turnstileRequired) {
|
|
137
|
+
setTurnstileToken('');
|
|
138
|
+
turnstileWidgetRef.current?.reset();
|
|
139
|
+
}
|
|
112
140
|
} finally {
|
|
113
141
|
setSubmitting(false);
|
|
114
142
|
}
|
|
@@ -186,10 +214,18 @@ export function SignUpPage() {
|
|
|
186
214
|
/>
|
|
187
215
|
)}
|
|
188
216
|
|
|
217
|
+
{turnstileRequired && (
|
|
218
|
+
<TurnstileWidget
|
|
219
|
+
ref={turnstileWidgetRef}
|
|
220
|
+
siteKey={authMethods.turnstile_site_key}
|
|
221
|
+
onToken={setTurnstileToken}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
|
|
189
225
|
<Button
|
|
190
226
|
type="submit"
|
|
191
227
|
variant="contained"
|
|
192
|
-
disabled={submitting || signupModes.length === 0}
|
|
228
|
+
disabled={submitting || signupModes.length === 0 || (turnstileRequired && !turnstileToken)}
|
|
193
229
|
>
|
|
194
230
|
{submitting
|
|
195
231
|
? t('Auth.SIGNUP_SUBMITTING')
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
const notifications = vi.hoisted(() => ({
|
|
7
|
+
useNotifications: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
const translation = vi.hoisted(() => ({
|
|
10
|
+
t: vi.fn((key) => `translated:${key}`),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('../src/notifications/NotificationsProvider', () => notifications);
|
|
14
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => translation }));
|
|
15
|
+
|
|
16
|
+
import { NotificationBell } from '../src/notifications/NotificationBell';
|
|
17
|
+
|
|
18
|
+
describe('NotificationBell', () => {
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
cleanup();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
notifications.useNotifications.mockReturnValue({
|
|
26
|
+
unreadCount: 2,
|
|
27
|
+
markSeen: vi.fn(),
|
|
28
|
+
notifications: [{
|
|
29
|
+
id: 7,
|
|
30
|
+
notification_id: 70,
|
|
31
|
+
seen: false,
|
|
32
|
+
dismissed: false,
|
|
33
|
+
content: {
|
|
34
|
+
title_key: 'domain.notification.title',
|
|
35
|
+
body_key: 'domain.notification.body',
|
|
36
|
+
params: { name: 'Ada' },
|
|
37
|
+
link: '/payments/7',
|
|
38
|
+
},
|
|
39
|
+
}],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('renders the unread badge, translates item content, and handles item navigation', () => {
|
|
44
|
+
const resolveLink = vi.fn();
|
|
45
|
+
render(<NotificationBell resolveLink={resolveLink} />);
|
|
46
|
+
|
|
47
|
+
expect(screen.getByText('2')).toBeTruthy();
|
|
48
|
+
fireEvent.click(screen.getByRole('button', { name: 'translated:NotificationBell.TITLE' }));
|
|
49
|
+
|
|
50
|
+
expect(screen.getByText('translated:domain.notification.title')).toBeTruthy();
|
|
51
|
+
expect(screen.getByText('translated:domain.notification.body')).toBeTruthy();
|
|
52
|
+
expect(translation.t).toHaveBeenCalledWith('domain.notification.title', { name: 'Ada' });
|
|
53
|
+
expect(translation.t).toHaveBeenCalledWith('domain.notification.body', { name: 'Ada' });
|
|
54
|
+
|
|
55
|
+
fireEvent.click(screen.getByText('translated:domain.notification.title'));
|
|
56
|
+
|
|
57
|
+
expect(notifications.useNotifications.mock.results[0].value.markSeen).toHaveBeenCalledWith([7]);
|
|
58
|
+
expect(resolveLink).toHaveBeenCalledWith('/payments/7');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
const feedApi = vi.hoisted(() => ({
|
|
7
|
+
getNotificationFeed: vi.fn(),
|
|
8
|
+
getUnreadCount: vi.fn(),
|
|
9
|
+
markNotifications: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('../src/notifications/feedApi', () => feedApi);
|
|
13
|
+
|
|
14
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
15
|
+
import { NotificationsProvider, useNotifications } from '../src/notifications/NotificationsProvider';
|
|
16
|
+
|
|
17
|
+
class MockWebSocket {
|
|
18
|
+
static instances = [];
|
|
19
|
+
|
|
20
|
+
constructor(url) {
|
|
21
|
+
this.url = url;
|
|
22
|
+
this.close = vi.fn(() => this.onclose?.());
|
|
23
|
+
MockWebSocket.instances.push(this);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const firstNotification = {
|
|
28
|
+
id: 1,
|
|
29
|
+
notification_id: 101,
|
|
30
|
+
content: { title_key: 'first.title' },
|
|
31
|
+
seen_at: null,
|
|
32
|
+
dismissed_at: null,
|
|
33
|
+
done_at: null,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const secondNotification = {
|
|
37
|
+
id: 2,
|
|
38
|
+
notification_id: 102,
|
|
39
|
+
content: { title_key: 'second.title' },
|
|
40
|
+
seen_at: null,
|
|
41
|
+
dismissed_at: null,
|
|
42
|
+
done_at: null,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function Consumer() {
|
|
46
|
+
const notifications = useNotifications();
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<output data-testid="notifications">{JSON.stringify(notifications.notifications)}</output>
|
|
50
|
+
<output data-testid="unread-count">{notifications.unreadCount}</output>
|
|
51
|
+
<button onClick={() => notifications.markSeen([1])}>seen</button>
|
|
52
|
+
<button onClick={() => notifications.markDismissed([2])}>dismissed</button>
|
|
53
|
+
<button onClick={() => notifications.markDone([1])}>done</button>
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderProvider({ children = <Consumer />, user = { id: 4 }, ...providerProps } = {}) {
|
|
59
|
+
return render(
|
|
60
|
+
<AuthContext.Provider value={{ user }}>
|
|
61
|
+
<NotificationsProvider {...providerProps}>{children}</NotificationsProvider>
|
|
62
|
+
</AuthContext.Provider>,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function currentNotifications() {
|
|
67
|
+
return JSON.parse(screen.getByTestId('notifications').textContent);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('NotificationsProvider', () => {
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
cleanup();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
vi.clearAllMocks();
|
|
77
|
+
MockWebSocket.instances = [];
|
|
78
|
+
vi.stubGlobal('WebSocket', MockWebSocket);
|
|
79
|
+
feedApi.getNotificationFeed.mockResolvedValue({ results: [firstNotification, secondNotification] });
|
|
80
|
+
feedApi.getUnreadCount.mockResolvedValue({ count: 2 });
|
|
81
|
+
feedApi.markNotifications.mockResolvedValue({ updated: 1 });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('seeds state from the feed and unread-count endpoints', async () => {
|
|
85
|
+
renderProvider();
|
|
86
|
+
|
|
87
|
+
await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
|
|
88
|
+
|
|
89
|
+
expect(currentNotifications()).toEqual(expect.arrayContaining([
|
|
90
|
+
expect.objectContaining({ id: 1, notification_id: 101, seen: false }),
|
|
91
|
+
expect.objectContaining({ id: 2, notification_id: 102, dismissed: false }),
|
|
92
|
+
]));
|
|
93
|
+
expect(MockWebSocket.instances).toHaveLength(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('prepends a new notification push and increments the unread count', async () => {
|
|
97
|
+
renderProvider();
|
|
98
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
99
|
+
|
|
100
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
|
|
101
|
+
type: 'payment_due',
|
|
102
|
+
notification_id: 303,
|
|
103
|
+
content: { title_key: 'payment.title', body_key: 'payment.body', params: { amount: 20 } },
|
|
104
|
+
}) });
|
|
105
|
+
|
|
106
|
+
await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('3'));
|
|
107
|
+
expect(currentNotifications()[0]).toEqual(expect.objectContaining({
|
|
108
|
+
id: 303,
|
|
109
|
+
notification_id: 303,
|
|
110
|
+
notification_type: 'payment_due',
|
|
111
|
+
seen: false,
|
|
112
|
+
}));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('applies a status push to the matching notification without duplication', async () => {
|
|
116
|
+
renderProvider();
|
|
117
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
118
|
+
|
|
119
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
|
|
120
|
+
type: 'notification.status',
|
|
121
|
+
notification_id: 101,
|
|
122
|
+
status: { seen: true, dismissed: false, done: true },
|
|
123
|
+
}) });
|
|
124
|
+
|
|
125
|
+
await waitFor(() => expect(currentNotifications()[0]).toEqual(expect.objectContaining({
|
|
126
|
+
notification_id: 101,
|
|
127
|
+
seen: true,
|
|
128
|
+
dismissed: false,
|
|
129
|
+
done: true,
|
|
130
|
+
})));
|
|
131
|
+
expect(currentNotifications()).toHaveLength(2);
|
|
132
|
+
expect(currentNotifications()[1]).toEqual(expect.objectContaining({ notification_id: 102, seen: false }));
|
|
133
|
+
expect(screen.getByTestId('unread-count').textContent).toBe('1');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('decrements unread count on a cross-tab status push and does not double-count a repeat push', async () => {
|
|
137
|
+
renderProvider();
|
|
138
|
+
await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
|
|
139
|
+
|
|
140
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
|
|
141
|
+
type: 'notification.status',
|
|
142
|
+
notification_id: 101,
|
|
143
|
+
status: { seen: true, dismissed: false, done: false },
|
|
144
|
+
}) });
|
|
145
|
+
await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('1'));
|
|
146
|
+
|
|
147
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
|
|
148
|
+
type: 'notification.status',
|
|
149
|
+
notification_id: 101,
|
|
150
|
+
status: { seen: true, dismissed: false, done: true },
|
|
151
|
+
}) });
|
|
152
|
+
await waitFor(() => expect(currentNotifications()[0]).toEqual(expect.objectContaining({ done: true })));
|
|
153
|
+
expect(screen.getByTestId('unread-count').textContent).toBe('1');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('constructs only one WebSocket across a provider re-render', async () => {
|
|
157
|
+
const { rerender } = renderProvider();
|
|
158
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
159
|
+
|
|
160
|
+
rerender(
|
|
161
|
+
<AuthContext.Provider value={{ user: { id: 4 } }}>
|
|
162
|
+
<NotificationsProvider><Consumer /></NotificationsProvider>
|
|
163
|
+
</AuthContext.Provider>,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(MockWebSocket.instances).toHaveLength(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('closes the socket on unmount', async () => {
|
|
170
|
+
const { unmount } = renderProvider();
|
|
171
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
172
|
+
const socket = MockWebSocket.instances[0];
|
|
173
|
+
|
|
174
|
+
unmount();
|
|
175
|
+
|
|
176
|
+
expect(socket.close).toHaveBeenCalledOnce();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('marks rows optimistically and sends each canonical action', async () => {
|
|
180
|
+
renderProvider();
|
|
181
|
+
await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
|
|
182
|
+
|
|
183
|
+
fireEvent.click(screen.getByRole('button', { name: 'seen' }));
|
|
184
|
+
expect(currentNotifications()[0]).toEqual(expect.objectContaining({ seen: true }));
|
|
185
|
+
expect(screen.getByTestId('unread-count').textContent).toBe('1');
|
|
186
|
+
expect(feedApi.markNotifications).toHaveBeenCalledWith({ action: 'seen', ids: [1] });
|
|
187
|
+
|
|
188
|
+
fireEvent.click(screen.getByRole('button', { name: 'dismissed' }));
|
|
189
|
+
expect(currentNotifications()[1]).toEqual(expect.objectContaining({ dismissed: true }));
|
|
190
|
+
expect(screen.getByTestId('unread-count').textContent).toBe('0');
|
|
191
|
+
expect(feedApi.markNotifications).toHaveBeenCalledWith({ action: 'dismissed', ids: [2] });
|
|
192
|
+
|
|
193
|
+
fireEvent.click(screen.getByRole('button', { name: 'done' }));
|
|
194
|
+
expect(currentNotifications()[0]).toEqual(expect.objectContaining({ done: true }));
|
|
195
|
+
expect(feedApi.markNotifications).toHaveBeenCalledWith({ action: 'done', ids: [1] });
|
|
196
|
+
});
|
|
197
|
+
});
|