@micha.bigler/ui-core-micha 2.10.1 → 2.11.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/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/package.json +1 -1
- 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/tests/NotificationBell.test.jsx +60 -0
- package/tests/NotificationsProvider.test.jsx +197 -0
- package/tests/feedApi.test.js +46 -0
- package/tests/notificationsExports.test.js +29 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -10,6 +10,7 @@ rules are defined centrally in `webapps/AGENTS.md` → "Work-Order Register".
|
|
|
10
10
|
|---|---|
|
|
11
11
|
| `ONB-*` | Onboarding wizard (steps, conditions, persistence) |
|
|
12
12
|
| `PERF-*` | Performance improvements |
|
|
13
|
+
| `NOTIF-*` | Shared notifications platform (dcm register is canonical; this repo's rows are its ucm-side WOs) |
|
|
13
14
|
|
|
14
15
|
Introduce a new prefix when none fits and add it here. New WOs always get a
|
|
15
16
|
prefixed ID; never reuse a bare flat number across workstreams.
|
|
@@ -19,4 +20,5 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
19
20
|
| ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
|
|
20
21
|
|---|---|---|---|---|---|---|
|
|
21
22
|
| ONB-1 | Per-app configurable notifications onboarding step | New `browserPush` prop `{nagUntil, showOnce}` on `OnboardingProvider`; parameterizes the `browser_push` descriptor's condition (default changes from implicit "all-channels" to "any-channel", stopping the over-nag); `showOnce` via a persisted `onboarding_seen` set (frozen-at-mount ref, no mid-session flicker) | 2026-07-17 | done | 1ae7c20 | Default behavior change affects cockpit and all consumers on their next ucm bump. jg-ferien companion WO pins the new version with `browserPush={{nagUntil: 'any-channel'}}` explicit (matches new default, but pinned explicitly per the WO). |
|
|
22
|
-
| PERF-3B1 | Parallel auth bootstrap after CSRF | Starts auth-methods and current-user concurrently once CSRF is available, while preserving error handling and loading semantics. | 2026-07-19 | done |
|
|
23
|
+
| PERF-3B1 | Parallel auth bootstrap after CSRF | Starts auth-methods and current-user concurrently once CSRF is available, while preserving error handling and loading semantics. | 2026-07-19 | done | ee89329 | Independent `reviewer` + `sec_reviewer` passes both clean (no findings); one P3 test-coverage gap from the reviewer (missing mirror case: auth-methods rejects, current-user succeeds) closed with an added regression test — 45/45 tests green. Published as 2.10.1 (patch, no interface change) and pinned in jg-ferien alongside its PERF-3A companion WO. |
|
|
24
|
+
| NOTIF-6 | NotificationsProvider + bell on canonical dcm feed/* API | Single-WS-owner `NotificationsProvider`/`useNotifications` (initial REST seed via `feedApi.js` + one `/ws/notifications/` socket, reconnect w/ backoff) + generic MUI `NotificationBell`; additive exports only | 2026-07-27 | done | 6c63fb9 | Codex-built direct (no nesting), independent `reviewer`: single-WS-owner invariant traced clean (no way to break it — `authenticated`/`replaceNotifications`/`refresh` all referentially stable across unrelated re-renders, re-render test is genuine not a no-op). R1 (P2, real bug): the `notification.status` WS handler patched item flags but never adjusted `unreadCount`, so the badge went stale after a cross-tab/cross-device mark — the test had locked in the stale count as expected behavior. Fixed: delta computed from before/after unread state on the WS patch path, mirroring `mark()`'s own logic; test corrected + a new cross-tab regression test added. 57 passing, build clean. No new npm dependency. Full history + backend contract: dcm's `WORK_ORDERS.md`/`docs/design/notifications-platform.md`. Publishes ucm 2.11.0 |
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export const notificationsTranslations = {
|
|
2
|
+
'NotificationBell.TITLE': { de: 'Benachrichtigungen', fr: 'Notifications', en: 'Notifications', sw: 'Arifa' },
|
|
3
|
+
'NotificationBell.EMPTY': { de: 'Keine Benachrichtigungen vorhanden.', fr: 'Aucune notification.', en: 'No notifications.', sw: 'Hakuna arifa.' },
|
|
4
|
+
'NotificationBell.MARK_ALL': { de: 'Alle als gelesen markieren', fr: 'Tout marquer comme lu', en: 'Mark all as read', sw: 'Weka zote alama kuwa zimesomwa' },
|
|
2
5
|
'NotificationSettings.TITLE': { de: 'Benachrichtigungen', fr: 'Notifications', en: 'Notifications', sw: 'Arifa' },
|
|
3
6
|
'NotificationSettings.SUBTITLE': { de: 'Lege fest, wie du informiert werden möchtest.', fr: 'Choisissez comment vous souhaitez être informé.', en: 'Choose how you want to be notified.', sw: 'Chagua jinsi unavyotaka kuarifiwa.' },
|
|
4
7
|
'NotificationSettings.LOAD_ERROR': { de: 'Benachrichtigungseinstellungen konnten nicht geladen werden.', fr: 'Impossible de charger les paramètres de notification.', en: 'Notification settings could not be loaded.', sw: 'Mipangilio ya arifa haikuweza kupakiwa.' },
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,9 @@ export { authTranslations } from './i18n/authTranslations';
|
|
|
32
32
|
// --- 7. Notifications ---
|
|
33
33
|
export { NotificationSettings } from './notifications/NotificationSettings';
|
|
34
34
|
export * from './notifications/api';
|
|
35
|
+
export { NotificationsProvider, useNotifications } from './notifications/NotificationsProvider';
|
|
36
|
+
export { NotificationBell } from './notifications/NotificationBell';
|
|
37
|
+
export { getNotificationFeed, getUnreadCount, markNotifications } from './notifications/feedApi';
|
|
35
38
|
// --- 8. Onboarding ---
|
|
36
39
|
export * from './onboarding/api';
|
|
37
40
|
export { selectActiveSteps } from './onboarding/stepSelection';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import NotificationsIcon from '@mui/icons-material/Notifications';
|
|
4
|
+
import { Badge, Box, Button, IconButton, List, ListItemButton, ListItemText, Menu, Typography, } from '@mui/material';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { useNotifications } from './NotificationsProvider';
|
|
7
|
+
export function NotificationBell({ resolveLink }) {
|
|
8
|
+
const { t } = useTranslation();
|
|
9
|
+
const { notifications, unreadCount, markSeen } = useNotifications();
|
|
10
|
+
const [anchorEl, setAnchorEl] = useState(null);
|
|
11
|
+
const open = Boolean(anchorEl);
|
|
12
|
+
const unreadIds = notifications
|
|
13
|
+
.filter((notification) => !notification.seen && !notification.dismissed && notification.id != null)
|
|
14
|
+
.map((notification) => notification.id);
|
|
15
|
+
const handleItemClick = (notification) => {
|
|
16
|
+
var _a;
|
|
17
|
+
if (notification.id != null)
|
|
18
|
+
markSeen([notification.id]);
|
|
19
|
+
setAnchorEl(null);
|
|
20
|
+
if (resolveLink && ((_a = notification.content) === null || _a === void 0 ? void 0 : _a.link))
|
|
21
|
+
resolveLink(notification.content.link);
|
|
22
|
+
};
|
|
23
|
+
return (_jsxs(_Fragment, { children: [_jsx(IconButton, { "aria-label": t('NotificationBell.TITLE'), color: "inherit", onClick: (event) => setAnchorEl(event.currentTarget), children: _jsx(Badge, { badgeContent: unreadCount, color: "error", max: 99, children: _jsx(NotificationsIcon, {}) }) }), _jsx(Menu, { anchorEl: anchorEl, anchorOrigin: { horizontal: 'right', vertical: 'bottom' }, onClose: () => setAnchorEl(null), open: open, transformOrigin: { horizontal: 'right', vertical: 'top' }, children: _jsxs(Box, { sx: { minWidth: 320 }, children: [_jsxs(Box, { sx: { alignItems: 'center', display: 'flex', justifyContent: 'space-between', px: 2, py: 1 }, children: [_jsx(Typography, { variant: "subtitle1", children: t('NotificationBell.TITLE') }), unreadIds.length > 0 && (_jsx(Button, { onClick: () => markSeen(unreadIds), size: "small", children: t('NotificationBell.MARK_ALL') }))] }), notifications.length === 0 ? (_jsx(Typography, { sx: { px: 2, py: 2 }, variant: "body2", children: t('NotificationBell.EMPTY') })) : (_jsx(List, { dense: true, disablePadding: true, children: notifications.map((notification) => {
|
|
24
|
+
var _a, _b, _c, _d;
|
|
25
|
+
return (_jsx(ListItemButton, { onClick: () => handleItemClick(notification), children: _jsx(ListItemText, { primary: t((_a = notification.content) === null || _a === void 0 ? void 0 : _a.title_key, ((_b = notification.content) === null || _b === void 0 ? void 0 : _b.params) || {}), secondary: ((_c = notification.content) === null || _c === void 0 ? void 0 : _c.body_key)
|
|
26
|
+
? t(notification.content.body_key, notification.content.params || {})
|
|
27
|
+
: undefined, slotProps: { primary: { fontWeight: notification.seen || notification.dismissed ? 'regular' : 'bold' } } }) }, (_d = notification.id) !== null && _d !== void 0 ? _d : notification.notification_id));
|
|
28
|
+
}) }))] }) })] }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { AuthContext } from '../auth/AuthContext';
|
|
4
|
+
import { getNotificationFeed, getUnreadCount, markNotifications } from './feedApi';
|
|
5
|
+
const INITIAL_BACKOFF_MS = 1000;
|
|
6
|
+
const MAX_BACKOFF_MS = 30000;
|
|
7
|
+
const NotificationsContext = createContext(null);
|
|
8
|
+
function normalizeNotification(notification) {
|
|
9
|
+
var _a, _b, _c;
|
|
10
|
+
return Object.assign(Object.assign({}, notification), { content: notification.content || {}, seen: Boolean((_a = notification.seen) !== null && _a !== void 0 ? _a : notification.seen_at), dismissed: Boolean((_b = notification.dismissed) !== null && _b !== void 0 ? _b : notification.dismissed_at), done: Boolean((_c = notification.done) !== null && _c !== void 0 ? _c : notification.done_at) });
|
|
11
|
+
}
|
|
12
|
+
function normalizeNotificationPush(data) {
|
|
13
|
+
var _a, _b;
|
|
14
|
+
return normalizeNotification({
|
|
15
|
+
id: (_b = (_a = data.id) !== null && _a !== void 0 ? _a : data.recipient_id) !== null && _b !== void 0 ? _b : data.notification_id,
|
|
16
|
+
notification_id: data.notification_id,
|
|
17
|
+
notification_type: data.type,
|
|
18
|
+
content: data.content || {},
|
|
19
|
+
created_at: data.created_at,
|
|
20
|
+
seen_at: null,
|
|
21
|
+
dismissed_at: null,
|
|
22
|
+
done_at: null,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function getWebSocketUrl(wsUrlBase) {
|
|
26
|
+
if (wsUrlBase) {
|
|
27
|
+
return new URL('/ws/notifications/', wsUrlBase).toString();
|
|
28
|
+
}
|
|
29
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
30
|
+
return `${protocol}//${window.location.host}/ws/notifications/`;
|
|
31
|
+
}
|
|
32
|
+
function patchStatus(notification, status) {
|
|
33
|
+
const seen = Boolean(status.seen);
|
|
34
|
+
const dismissed = Boolean(status.dismissed);
|
|
35
|
+
const done = Boolean(status.done);
|
|
36
|
+
return Object.assign(Object.assign({}, notification), { seen,
|
|
37
|
+
dismissed,
|
|
38
|
+
done, seen_at: seen ? notification.seen_at || true : null, dismissed_at: dismissed ? notification.dismissed_at || true : null, done_at: done ? notification.done_at || true : null });
|
|
39
|
+
}
|
|
40
|
+
export function NotificationsProvider({ children, wsUrlBase }) {
|
|
41
|
+
const auth = useContext(AuthContext);
|
|
42
|
+
const authenticated = Boolean(auth === null || auth === void 0 ? void 0 : auth.user);
|
|
43
|
+
const [notifications, setNotifications] = useState([]);
|
|
44
|
+
const [unreadCount, setUnreadCount] = useState(0);
|
|
45
|
+
const notificationsRef = useRef([]);
|
|
46
|
+
const socketRef = useRef(null);
|
|
47
|
+
const replaceNotifications = useCallback((nextNotifications) => {
|
|
48
|
+
notificationsRef.current = nextNotifications;
|
|
49
|
+
setNotifications(nextNotifications);
|
|
50
|
+
}, []);
|
|
51
|
+
const refresh = useCallback(async () => {
|
|
52
|
+
if (!authenticated) {
|
|
53
|
+
replaceNotifications([]);
|
|
54
|
+
setUnreadCount(0);
|
|
55
|
+
return { notifications: [], unreadCount: 0 };
|
|
56
|
+
}
|
|
57
|
+
const [feed, unread] = await Promise.all([getNotificationFeed(), getUnreadCount()]);
|
|
58
|
+
const nextNotifications = (feed.results || []).map(normalizeNotification);
|
|
59
|
+
const nextUnreadCount = unread.count || 0;
|
|
60
|
+
replaceNotifications(nextNotifications);
|
|
61
|
+
setUnreadCount(nextUnreadCount);
|
|
62
|
+
return { notifications: nextNotifications, unreadCount: nextUnreadCount };
|
|
63
|
+
}, [authenticated, replaceNotifications]);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!authenticated) {
|
|
66
|
+
replaceNotifications([]);
|
|
67
|
+
setUnreadCount(0);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
let active = true;
|
|
71
|
+
let reconnectTimer = null;
|
|
72
|
+
let backoffMs = INITIAL_BACKOFF_MS;
|
|
73
|
+
const patchNotifications = (updater) => {
|
|
74
|
+
const nextNotifications = updater(notificationsRef.current);
|
|
75
|
+
replaceNotifications(nextNotifications);
|
|
76
|
+
};
|
|
77
|
+
const handleMessage = (event) => {
|
|
78
|
+
let data;
|
|
79
|
+
try {
|
|
80
|
+
data = JSON.parse(event.data);
|
|
81
|
+
}
|
|
82
|
+
catch (_a) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (data.type === 'notification.status') {
|
|
86
|
+
let unreadDelta = 0;
|
|
87
|
+
patchNotifications((current) => current.map((notification) => {
|
|
88
|
+
if (notification.notification_id !== data.notification_id)
|
|
89
|
+
return notification;
|
|
90
|
+
const wasUnread = !notification.seen && !notification.dismissed;
|
|
91
|
+
const patched = patchStatus(notification, data.status || {});
|
|
92
|
+
const isUnread = !patched.seen && !patched.dismissed;
|
|
93
|
+
if (wasUnread !== isUnread) {
|
|
94
|
+
unreadDelta += isUnread ? 1 : -1;
|
|
95
|
+
}
|
|
96
|
+
return patched;
|
|
97
|
+
}));
|
|
98
|
+
if (unreadDelta) {
|
|
99
|
+
setUnreadCount((count) => Math.max(0, count + unreadDelta));
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const pushedNotification = normalizeNotificationPush(data);
|
|
104
|
+
patchNotifications((current) => [pushedNotification, ...current]);
|
|
105
|
+
setUnreadCount((count) => count + 1);
|
|
106
|
+
};
|
|
107
|
+
const connect = () => {
|
|
108
|
+
if (!active || socketRef.current)
|
|
109
|
+
return;
|
|
110
|
+
const socket = new WebSocket(getWebSocketUrl(wsUrlBase));
|
|
111
|
+
socketRef.current = socket;
|
|
112
|
+
socket.onopen = () => {
|
|
113
|
+
backoffMs = INITIAL_BACKOFF_MS;
|
|
114
|
+
};
|
|
115
|
+
socket.onmessage = handleMessage;
|
|
116
|
+
socket.onerror = () => {
|
|
117
|
+
socket.close();
|
|
118
|
+
};
|
|
119
|
+
socket.onclose = () => {
|
|
120
|
+
if (socketRef.current === socket) {
|
|
121
|
+
socketRef.current = null;
|
|
122
|
+
}
|
|
123
|
+
if (!active || reconnectTimer)
|
|
124
|
+
return;
|
|
125
|
+
reconnectTimer = setTimeout(() => {
|
|
126
|
+
reconnectTimer = null;
|
|
127
|
+
connect();
|
|
128
|
+
}, backoffMs);
|
|
129
|
+
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
const seedAndConnect = async () => {
|
|
133
|
+
try {
|
|
134
|
+
await refresh();
|
|
135
|
+
}
|
|
136
|
+
catch (_a) {
|
|
137
|
+
// The live stream remains useful if the initial REST request is temporarily unavailable.
|
|
138
|
+
}
|
|
139
|
+
if (active)
|
|
140
|
+
connect();
|
|
141
|
+
};
|
|
142
|
+
seedAndConnect();
|
|
143
|
+
return () => {
|
|
144
|
+
active = false;
|
|
145
|
+
clearTimeout(reconnectTimer);
|
|
146
|
+
const socket = socketRef.current;
|
|
147
|
+
socketRef.current = null;
|
|
148
|
+
if (socket)
|
|
149
|
+
socket.close();
|
|
150
|
+
};
|
|
151
|
+
}, [authenticated, refresh, replaceNotifications, wsUrlBase]);
|
|
152
|
+
const mark = useCallback((action, ids) => {
|
|
153
|
+
const selectedIds = Array.isArray(ids) ? ids : [];
|
|
154
|
+
const selected = new Set(selectedIds);
|
|
155
|
+
const current = notificationsRef.current;
|
|
156
|
+
let unreadDelta = 0;
|
|
157
|
+
const nextNotifications = current.map((notification) => {
|
|
158
|
+
if (!selected.has(notification.id))
|
|
159
|
+
return notification;
|
|
160
|
+
const wasUnread = !notification.seen && !notification.dismissed;
|
|
161
|
+
if ((action === 'seen' || action === 'dismissed') && wasUnread) {
|
|
162
|
+
unreadDelta += 1;
|
|
163
|
+
}
|
|
164
|
+
return patchStatus(notification, {
|
|
165
|
+
seen: action === 'seen' ? true : notification.seen,
|
|
166
|
+
dismissed: action === 'dismissed' ? true : notification.dismissed,
|
|
167
|
+
done: action === 'done' ? true : notification.done,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
replaceNotifications(nextNotifications);
|
|
171
|
+
if (unreadDelta) {
|
|
172
|
+
setUnreadCount((count) => Math.max(0, count - unreadDelta));
|
|
173
|
+
}
|
|
174
|
+
return markNotifications({ action, ids: selectedIds });
|
|
175
|
+
}, [replaceNotifications]);
|
|
176
|
+
const markSeen = useCallback((ids) => mark('seen', ids), [mark]);
|
|
177
|
+
const markDismissed = useCallback((ids) => mark('dismissed', ids), [mark]);
|
|
178
|
+
const markDone = useCallback((ids) => mark('done', ids), [mark]);
|
|
179
|
+
return (_jsx(NotificationsContext.Provider, { value: {
|
|
180
|
+
notifications,
|
|
181
|
+
unreadCount,
|
|
182
|
+
markSeen,
|
|
183
|
+
markDismissed,
|
|
184
|
+
markDone,
|
|
185
|
+
refresh,
|
|
186
|
+
}, children: children }));
|
|
187
|
+
}
|
|
188
|
+
export function useNotifications() {
|
|
189
|
+
const context = useContext(NotificationsContext);
|
|
190
|
+
if (!context) {
|
|
191
|
+
throw new Error('useNotifications must be used within a NotificationsProvider');
|
|
192
|
+
}
|
|
193
|
+
return context;
|
|
194
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import apiClient from '../auth/apiClient';
|
|
2
|
+
export async function getNotificationFeed({ status } = {}) {
|
|
3
|
+
const response = await apiClient.get('/api/notifications/feed/', {
|
|
4
|
+
params: status ? { status } : undefined,
|
|
5
|
+
});
|
|
6
|
+
return response.data;
|
|
7
|
+
}
|
|
8
|
+
export async function getUnreadCount() {
|
|
9
|
+
const response = await apiClient.get('/api/notifications/feed/unread-count/');
|
|
10
|
+
return response.data;
|
|
11
|
+
}
|
|
12
|
+
export async function markNotifications({ action, ids }) {
|
|
13
|
+
const response = await apiClient.post('/api/notifications/feed/mark/', { action, ids });
|
|
14
|
+
return response.data;
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export const notificationsTranslations = {
|
|
2
|
+
'NotificationBell.TITLE': { de: 'Benachrichtigungen', fr: 'Notifications', en: 'Notifications', sw: 'Arifa' },
|
|
3
|
+
'NotificationBell.EMPTY': { de: 'Keine Benachrichtigungen vorhanden.', fr: 'Aucune notification.', en: 'No notifications.', sw: 'Hakuna arifa.' },
|
|
4
|
+
'NotificationBell.MARK_ALL': { de: 'Alle als gelesen markieren', fr: 'Tout marquer comme lu', en: 'Mark all as read', sw: 'Weka zote alama kuwa zimesomwa' },
|
|
2
5
|
'NotificationSettings.TITLE': { de: 'Benachrichtigungen', fr: 'Notifications', en: 'Notifications', sw: 'Arifa' },
|
|
3
6
|
'NotificationSettings.SUBTITLE': { de: 'Lege fest, wie du informiert werden möchtest.', fr: 'Choisissez comment vous souhaitez être informé.', en: 'Choose how you want to be notified.', sw: 'Chagua jinsi unavyotaka kuarifiwa.' },
|
|
4
7
|
'NotificationSettings.LOAD_ERROR': { de: 'Benachrichtigungseinstellungen konnten nicht geladen werden.', fr: 'Impossible de charger les paramètres de notification.', en: 'Notification settings could not be loaded.', sw: 'Mipangilio ya arifa haikuweza kupakiwa.' },
|
package/src/index.js
CHANGED
|
@@ -45,6 +45,9 @@ export { authTranslations } from './i18n/authTranslations';
|
|
|
45
45
|
// --- 7. Notifications ---
|
|
46
46
|
export { NotificationSettings } from './notifications/NotificationSettings';
|
|
47
47
|
export * from './notifications/api';
|
|
48
|
+
export { NotificationsProvider, useNotifications } from './notifications/NotificationsProvider';
|
|
49
|
+
export { NotificationBell } from './notifications/NotificationBell';
|
|
50
|
+
export { getNotificationFeed, getUnreadCount, markNotifications } from './notifications/feedApi';
|
|
48
51
|
|
|
49
52
|
// --- 8. Onboarding ---
|
|
50
53
|
export * from './onboarding/api';
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const client = vi.hoisted(() => ({
|
|
4
|
+
get: vi.fn(),
|
|
5
|
+
post: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('../src/auth/apiClient', () => ({ default: client }));
|
|
9
|
+
|
|
10
|
+
import { getNotificationFeed, getUnreadCount, markNotifications } from '../src/notifications/feedApi';
|
|
11
|
+
|
|
12
|
+
describe('notification feed API', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('loads the feed with an optional status filter', async () => {
|
|
18
|
+
const data = { count: 1, results: [] };
|
|
19
|
+
client.get.mockResolvedValue({ data });
|
|
20
|
+
|
|
21
|
+
await expect(getNotificationFeed({ status: 'unseen' })).resolves.toBe(data);
|
|
22
|
+
|
|
23
|
+
expect(client.get).toHaveBeenCalledWith('/api/notifications/feed/', { params: { status: 'unseen' } });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('loads the unread count', async () => {
|
|
27
|
+
const data = { count: 3 };
|
|
28
|
+
client.get.mockResolvedValue({ data });
|
|
29
|
+
|
|
30
|
+
await expect(getUnreadCount()).resolves.toBe(data);
|
|
31
|
+
|
|
32
|
+
expect(client.get).toHaveBeenCalledWith('/api/notifications/feed/unread-count/');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('marks recipient rows with the supplied action and ids', async () => {
|
|
36
|
+
const data = { updated: 2 };
|
|
37
|
+
client.post.mockResolvedValue({ data });
|
|
38
|
+
|
|
39
|
+
await expect(markNotifications({ action: 'seen', ids: [4, 8] })).resolves.toBe(data);
|
|
40
|
+
|
|
41
|
+
expect(client.post).toHaveBeenCalledWith('/api/notifications/feed/mark/', {
|
|
42
|
+
action: 'seen',
|
|
43
|
+
ids: [4, 8],
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
NotificationSettings,
|
|
5
|
+
getNotificationPreferences,
|
|
6
|
+
getVapidPublicKey,
|
|
7
|
+
listPushSubscriptions,
|
|
8
|
+
notificationsTranslations,
|
|
9
|
+
patchNotificationPreferences,
|
|
10
|
+
removePushSubscription,
|
|
11
|
+
savePushSubscription,
|
|
12
|
+
urlBase64ToUint8Array,
|
|
13
|
+
} from '../src/index';
|
|
14
|
+
|
|
15
|
+
describe('notification barrel exports', () => {
|
|
16
|
+
it('preserves legacy notification exports and translations', () => {
|
|
17
|
+
[
|
|
18
|
+
NotificationSettings,
|
|
19
|
+
getNotificationPreferences,
|
|
20
|
+
patchNotificationPreferences,
|
|
21
|
+
getVapidPublicKey,
|
|
22
|
+
listPushSubscriptions,
|
|
23
|
+
savePushSubscription,
|
|
24
|
+
removePushSubscription,
|
|
25
|
+
urlBase64ToUint8Array,
|
|
26
|
+
notificationsTranslations,
|
|
27
|
+
].forEach((value) => expect(value).toBeDefined());
|
|
28
|
+
});
|
|
29
|
+
});
|