@micha.bigler/ui-core-micha 2.12.0 → 2.14.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 +2 -0
- package/dist/i18n/notificationsTranslations.js +2 -0
- package/dist/index.js +2 -0
- package/dist/notifications/NotificationsProvider.js +74 -91
- package/dist/notifications/PopupSurface.js +84 -0
- package/dist/notifications/realtime.js +110 -0
- package/dist/onboarding/OnboardingWizard.js +3 -12
- package/dist/onboarding/WizardDialogShell.js +29 -0
- package/package.json +1 -1
- package/src/i18n/notificationsTranslations.ts +2 -0
- package/src/index.js +2 -0
- package/src/notifications/NotificationsProvider.jsx +88 -96
- package/src/notifications/PopupSurface.jsx +103 -0
- package/src/notifications/realtime.jsx +118 -0
- package/src/onboarding/OnboardingWizard.jsx +9 -23
- package/src/onboarding/WizardDialogShell.jsx +51 -0
- package/tests/NotificationsProvider.test.jsx +39 -0
- package/tests/OnboardingWizard.test.jsx +143 -0
- package/tests/PopupSurface.test.jsx +142 -0
- package/tests/notificationsExports.test.js +5 -0
- package/tests/realtime.test.jsx +192 -0
- package/work-orders/NOTIF-12.md +214 -0
- package/work-orders/NOTIF-13.md +142 -0
|
@@ -2,9 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
|
|
|
2
2
|
|
|
3
3
|
import { AuthContext } from '../auth/AuthContext';
|
|
4
4
|
import { getNotificationFeed, getUnreadCount, markNotifications } from './feedApi';
|
|
5
|
-
|
|
6
|
-
const INITIAL_BACKOFF_MS = 1_000;
|
|
7
|
-
const MAX_BACKOFF_MS = 30_000;
|
|
5
|
+
import { DEFAULT_ENVELOPE, RealtimeContext, useRealtimeCore } from './realtime';
|
|
8
6
|
|
|
9
7
|
const NotificationsContext = createContext(null);
|
|
10
8
|
|
|
@@ -62,7 +60,6 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
62
60
|
const [notifications, setNotifications] = useState([]);
|
|
63
61
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
64
62
|
const notificationsRef = useRef([]);
|
|
65
|
-
const socketRef = useRef(null);
|
|
66
63
|
|
|
67
64
|
const replaceNotifications = useCallback((nextNotifications) => {
|
|
68
65
|
notificationsRef.current = nextNotifications;
|
|
@@ -84,99 +81,92 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
84
81
|
return { notifications: nextNotifications, unreadCount: nextUnreadCount };
|
|
85
82
|
}, [authenticated, replaceNotifications]);
|
|
86
83
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
84
|
+
// Seeded gates the socket connect, not just `authenticated`: the prior single-purpose
|
|
85
|
+
// socket owner awaited the initial REST feed/unread-count fetch before opening the
|
|
86
|
+
// WebSocket, so a live push could never race an empty/incomplete `notificationsRef`.
|
|
87
|
+
// Preserved here rather than connecting immediately, since `refresh()`'s
|
|
88
|
+
// `replaceNotifications(...)` is unconditional and would silently drop a message that
|
|
89
|
+
// arrived and patched state before the initial REST response landed.
|
|
90
|
+
const [seeded, setSeeded] = useState(false);
|
|
91
|
+
|
|
92
|
+
const { subscribe } = useRealtimeCore({
|
|
93
|
+
active: authenticated && seeded,
|
|
94
|
+
wsUrl: getWebSocketUrl(wsUrlBase),
|
|
95
|
+
});
|
|
97
96
|
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
const handleRealtimeMessage = useCallback((data) => {
|
|
98
|
+
if (data.type === 'notification.status') {
|
|
99
|
+
let unreadDelta = 0;
|
|
100
|
+
const nextNotifications = notificationsRef.current.map((notification) => {
|
|
101
|
+
if (notification.notification_id !== data.notification_id) return notification;
|
|
102
|
+
|
|
103
|
+
const wasUnread = !notification.seen && !notification.dismissed;
|
|
104
|
+
const patched = patchStatus(notification, data.status || {});
|
|
105
|
+
const isUnread = !patched.seen && !patched.dismissed;
|
|
106
|
+
if (wasUnread !== isUnread) {
|
|
107
|
+
unreadDelta += isUnread ? 1 : -1;
|
|
108
|
+
}
|
|
109
|
+
return patched;
|
|
110
|
+
});
|
|
100
111
|
replaceNotifications(nextNotifications);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const handleMessage = (event) => {
|
|
104
|
-
let data;
|
|
105
|
-
try {
|
|
106
|
-
data = JSON.parse(event.data);
|
|
107
|
-
} catch {
|
|
108
|
-
return;
|
|
112
|
+
if (unreadDelta) {
|
|
113
|
+
setUnreadCount((count) => Math.max(0, count + unreadDelta));
|
|
109
114
|
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
110
117
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
118
|
+
const pushedNotification = normalizeNotificationPush(data);
|
|
119
|
+
|
|
120
|
+
// NOTIF-12 D2: a notification routed to more than one channel (e.g. chip
|
|
121
|
+
// + popup) arrives as multiple WS messages sharing one notification_id.
|
|
122
|
+
// Fold repeats into the existing feed entry (last-write-wins on content)
|
|
123
|
+
// instead of appending a duplicate and double-incrementing the badge.
|
|
124
|
+
const existingIndex = pushedNotification.notification_id == null
|
|
125
|
+
? -1
|
|
126
|
+
: notificationsRef.current.findIndex(
|
|
127
|
+
(notification) => notification.notification_id === pushedNotification.notification_id,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (existingIndex !== -1) {
|
|
131
|
+
const nextNotifications = notificationsRef.current.map((notification, index) => (
|
|
132
|
+
index === existingIndex
|
|
133
|
+
? { ...notification, notification_type: pushedNotification.notification_type, content: pushedNotification.content }
|
|
134
|
+
: notification
|
|
135
|
+
));
|
|
136
|
+
replaceNotifications(nextNotifications);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
129
139
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
};
|
|
140
|
+
replaceNotifications([pushedNotification, ...notificationsRef.current]);
|
|
141
|
+
setUnreadCount((count) => count + 1);
|
|
142
|
+
}, [replaceNotifications]);
|
|
134
143
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
};
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!authenticated) return undefined;
|
|
146
|
+
return subscribe(DEFAULT_ENVELOPE, handleRealtimeMessage);
|
|
147
|
+
}, [authenticated, subscribe, handleRealtimeMessage]);
|
|
160
148
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
};
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!authenticated) {
|
|
151
|
+
replaceNotifications([]);
|
|
152
|
+
setUnreadCount(0);
|
|
153
|
+
setSeeded(false);
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
169
156
|
|
|
170
|
-
|
|
157
|
+
let cancelled = false;
|
|
158
|
+
refresh()
|
|
159
|
+
.catch(() => {
|
|
160
|
+
// The live stream remains useful if the initial REST request is temporarily unavailable.
|
|
161
|
+
})
|
|
162
|
+
.finally(() => {
|
|
163
|
+
if (!cancelled) setSeeded(true);
|
|
164
|
+
});
|
|
171
165
|
|
|
172
166
|
return () => {
|
|
173
|
-
|
|
174
|
-
clearTimeout(reconnectTimer);
|
|
175
|
-
const socket = socketRef.current;
|
|
176
|
-
socketRef.current = null;
|
|
177
|
-
if (socket) socket.close();
|
|
167
|
+
cancelled = true;
|
|
178
168
|
};
|
|
179
|
-
}, [authenticated, refresh, replaceNotifications
|
|
169
|
+
}, [authenticated, refresh, replaceNotifications]);
|
|
180
170
|
|
|
181
171
|
const mark = useCallback((action, ids) => {
|
|
182
172
|
const selectedIds = Array.isArray(ids) ? ids : [];
|
|
@@ -210,16 +200,18 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
210
200
|
const markDone = useCallback((ids) => mark('done', ids), [mark]);
|
|
211
201
|
|
|
212
202
|
return (
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
203
|
+
<RealtimeContext.Provider value={{ subscribe }}>
|
|
204
|
+
<NotificationsContext.Provider value={{
|
|
205
|
+
notifications,
|
|
206
|
+
unreadCount,
|
|
207
|
+
markSeen,
|
|
208
|
+
markDismissed,
|
|
209
|
+
markDone,
|
|
210
|
+
refresh,
|
|
211
|
+
}}>
|
|
212
|
+
{children}
|
|
213
|
+
</NotificationsContext.Provider>
|
|
214
|
+
</RealtimeContext.Provider>
|
|
223
215
|
);
|
|
224
216
|
}
|
|
225
217
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import Button from '@mui/material/Button';
|
|
4
|
+
import Stack from '@mui/material/Stack';
|
|
5
|
+
import Typography from '@mui/material/Typography';
|
|
6
|
+
import { useOnboarding } from '../onboarding/OnboardingProvider';
|
|
7
|
+
import { WizardDialogShell } from '../onboarding/WizardDialogShell';
|
|
8
|
+
import { useNotifications } from './NotificationsProvider';
|
|
9
|
+
import { DEFAULT_ENVELOPE, useRealtime } from './realtime';
|
|
10
|
+
|
|
11
|
+
function PopupStepComponent({ onDismiss, ctx }) {
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
const content = ctx?.content || {};
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Stack spacing={2}>
|
|
17
|
+
{content.body_key && (
|
|
18
|
+
<Typography variant="body1">{t(content.body_key, content.params || {})}</Typography>
|
|
19
|
+
)}
|
|
20
|
+
<Button onClick={onDismiss} sx={{ alignSelf: 'flex-end' }}>
|
|
21
|
+
{t('PopupSurface.CLOSE')}
|
|
22
|
+
</Button>
|
|
23
|
+
</Stack>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const POPUP_STEP = { blocking: false, Component: PopupStepComponent };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders popup-eligible notifications (NOTIF-12) through the onboarding
|
|
31
|
+
* wizard's dialog shell, reusing only the renderer (design D-F7) — status
|
|
32
|
+
* lives on NotificationRecipient via the existing feed/mark path, never in
|
|
33
|
+
* the onboarding progress store.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately session-live only: it watches the raw realtime stream for
|
|
36
|
+
* `channel === "popup"` messages rather than the already-seeded feed list,
|
|
37
|
+
* since the feed carries no per-channel discriminator (see NotificationsProvider).
|
|
38
|
+
*/
|
|
39
|
+
export function PopupSurface() {
|
|
40
|
+
const { t } = useTranslation();
|
|
41
|
+
const { markSeen, markDismissed } = useNotifications();
|
|
42
|
+
const { subscribe } = useRealtime();
|
|
43
|
+
const onboarding = useOnboarding();
|
|
44
|
+
const [queue, setQueue] = useState([]);
|
|
45
|
+
const seenIdsRef = useRef(new Set());
|
|
46
|
+
|
|
47
|
+
const handleRealtimeMessage = useCallback((data) => {
|
|
48
|
+
if (data.type === 'notification.status') return;
|
|
49
|
+
if (data.channel !== 'popup') return;
|
|
50
|
+
setQueue((previous) => {
|
|
51
|
+
if (previous.some((item) => item.notification_id === data.notification_id)) return previous;
|
|
52
|
+
return [...previous, {
|
|
53
|
+
notification_id: data.notification_id,
|
|
54
|
+
// recipient_id is the id feed/mark/ actually resolves against
|
|
55
|
+
// (NotificationRecipient.pk, not Notification.pk). Fall back to
|
|
56
|
+
// notification_id only for an older dcm that predates it — matching
|
|
57
|
+
// NotificationsProvider's own normalizeNotificationPush precedent.
|
|
58
|
+
markId: data.recipient_id ?? data.notification_id,
|
|
59
|
+
content: data.content || {},
|
|
60
|
+
}];
|
|
61
|
+
});
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
useEffect(() => subscribe(DEFAULT_ENVELOPE, handleRealtimeMessage), [subscribe, handleRealtimeMessage]);
|
|
65
|
+
|
|
66
|
+
// Any active onboarding step — blocking or not — must win: OnboardingWizard
|
|
67
|
+
// renders its own dialog for as long as onboarding.activeSteps is non-empty,
|
|
68
|
+
// and two independent MUI Dialogs must never be mounted at once.
|
|
69
|
+
const onboardingActive = Boolean(onboarding?.activeSteps?.length);
|
|
70
|
+
const activePopup = queue[0];
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!activePopup || onboardingActive) return;
|
|
74
|
+
if (activePopup.markId == null) return;
|
|
75
|
+
if (seenIdsRef.current.has(activePopup.notification_id)) return;
|
|
76
|
+
seenIdsRef.current.add(activePopup.notification_id);
|
|
77
|
+
markSeen([activePopup.markId]);
|
|
78
|
+
}, [activePopup, onboardingActive, markSeen]);
|
|
79
|
+
|
|
80
|
+
const dismissActivePopup = useCallback(() => {
|
|
81
|
+
if (!activePopup) return;
|
|
82
|
+
if (activePopup.markId != null) markDismissed([activePopup.markId]);
|
|
83
|
+
setQueue((previous) => previous.filter((item) => item.notification_id !== activePopup.notification_id));
|
|
84
|
+
}, [activePopup, markDismissed]);
|
|
85
|
+
|
|
86
|
+
if (!activePopup || onboardingActive) return null;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<WizardDialogShell
|
|
90
|
+
step={POPUP_STEP}
|
|
91
|
+
stepIndex={0}
|
|
92
|
+
total={1}
|
|
93
|
+
onComplete={dismissActivePopup}
|
|
94
|
+
onDismiss={dismissActivePopup}
|
|
95
|
+
ctx={{ content: activePopup.content }}
|
|
96
|
+
title={activePopup.content.title_key
|
|
97
|
+
? t(activePopup.content.title_key, activePopup.content.params || {})
|
|
98
|
+
: t('PopupSurface.TITLE')}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default PopupSurface;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
const INITIAL_BACKOFF_MS = 1_000;
|
|
4
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
// Envelope discriminator for the notifications domain. A message with no
|
|
7
|
+
// `envelope` field (pre-NOTIF-13 dcm) is treated as this default so an
|
|
8
|
+
// un-bumped backend keeps working unchanged (backward-compat).
|
|
9
|
+
export const DEFAULT_ENVELOPE = 'notification';
|
|
10
|
+
|
|
11
|
+
const RealtimeContext = createContext(null);
|
|
12
|
+
|
|
13
|
+
export function useRealtime() {
|
|
14
|
+
const context = useContext(RealtimeContext);
|
|
15
|
+
if (!context) {
|
|
16
|
+
throw new Error('useRealtime must be used within a NotificationsProvider');
|
|
17
|
+
}
|
|
18
|
+
return context;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { RealtimeContext };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Owns the single WebSocket connection and an envelope-keyed subscriber
|
|
25
|
+
* registry. Internal — consumed only by NotificationsProvider, which
|
|
26
|
+
* re-exposes `subscribe` via RealtimeContext so useRealtime() works without
|
|
27
|
+
* a separate provider apps have to mount.
|
|
28
|
+
*
|
|
29
|
+
* Backoff, reconnect, and cleanup semantics are unchanged from the prior
|
|
30
|
+
* single-purpose socket owner. `active`/`wsUrl` are primitives, so the
|
|
31
|
+
* effect does not re-run (and does not reconnect) on unrelated re-renders.
|
|
32
|
+
*/
|
|
33
|
+
export function useRealtimeCore({ active, wsUrl }) {
|
|
34
|
+
const socketRef = useRef(null);
|
|
35
|
+
const subscribersRef = useRef(new Map());
|
|
36
|
+
|
|
37
|
+
const subscribe = useCallback((envelope, handler) => {
|
|
38
|
+
const subscribers = subscribersRef.current;
|
|
39
|
+
if (!subscribers.has(envelope)) {
|
|
40
|
+
subscribers.set(envelope, new Set());
|
|
41
|
+
}
|
|
42
|
+
subscribers.get(envelope).add(handler);
|
|
43
|
+
return () => {
|
|
44
|
+
const handlers = subscribers.get(envelope);
|
|
45
|
+
if (!handlers) return;
|
|
46
|
+
handlers.delete(handler);
|
|
47
|
+
if (handlers.size === 0) {
|
|
48
|
+
subscribers.delete(envelope);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!active) return undefined;
|
|
55
|
+
|
|
56
|
+
let isActive = true;
|
|
57
|
+
let reconnectTimer = null;
|
|
58
|
+
let backoffMs = INITIAL_BACKOFF_MS;
|
|
59
|
+
|
|
60
|
+
const handleMessage = (event) => {
|
|
61
|
+
let data;
|
|
62
|
+
try {
|
|
63
|
+
data = JSON.parse(event.data);
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const envelope = data.envelope ?? DEFAULT_ENVELOPE;
|
|
69
|
+
const handlers = subscribersRef.current.get(envelope);
|
|
70
|
+
if (!handlers || handlers.size === 0) return;
|
|
71
|
+
handlers.forEach((handler) => {
|
|
72
|
+
try {
|
|
73
|
+
handler(data);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('Realtime subscriber error', error);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const connect = () => {
|
|
81
|
+
if (!isActive || socketRef.current) return;
|
|
82
|
+
|
|
83
|
+
const socket = new WebSocket(wsUrl);
|
|
84
|
+
socketRef.current = socket;
|
|
85
|
+
|
|
86
|
+
socket.onopen = () => {
|
|
87
|
+
backoffMs = INITIAL_BACKOFF_MS;
|
|
88
|
+
};
|
|
89
|
+
socket.onmessage = handleMessage;
|
|
90
|
+
socket.onerror = () => {
|
|
91
|
+
socket.close();
|
|
92
|
+
};
|
|
93
|
+
socket.onclose = () => {
|
|
94
|
+
if (socketRef.current === socket) {
|
|
95
|
+
socketRef.current = null;
|
|
96
|
+
}
|
|
97
|
+
if (!isActive || reconnectTimer) return;
|
|
98
|
+
reconnectTimer = setTimeout(() => {
|
|
99
|
+
reconnectTimer = null;
|
|
100
|
+
connect();
|
|
101
|
+
}, backoffMs);
|
|
102
|
+
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
connect();
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
isActive = false;
|
|
110
|
+
clearTimeout(reconnectTimer);
|
|
111
|
+
const socket = socketRef.current;
|
|
112
|
+
socketRef.current = null;
|
|
113
|
+
if (socket) socket.close();
|
|
114
|
+
};
|
|
115
|
+
}, [active, wsUrl]);
|
|
116
|
+
|
|
117
|
+
return { subscribe };
|
|
118
|
+
}
|
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { useTranslation } from 'react-i18next';
|
|
3
|
-
import Box from '@mui/material/Box';
|
|
4
|
-
import Dialog from '@mui/material/Dialog';
|
|
5
|
-
import DialogContent from '@mui/material/DialogContent';
|
|
6
|
-
import DialogTitle from '@mui/material/DialogTitle';
|
|
7
|
-
import LinearProgress from '@mui/material/LinearProgress';
|
|
8
|
-
import Typography from '@mui/material/Typography';
|
|
9
2
|
import { AuthContext } from '../auth/AuthContext';
|
|
10
3
|
import { useOnboarding } from './OnboardingProvider';
|
|
4
|
+
import { WizardDialogShell } from './WizardDialogShell';
|
|
11
5
|
|
|
12
6
|
export function OnboardingWizard() {
|
|
13
|
-
const { t } = useTranslation();
|
|
14
7
|
const onboarding = useOnboarding();
|
|
15
8
|
const authContext = useContext(AuthContext);
|
|
16
9
|
const [sessionDismissed, setSessionDismissed] = useState(() => new Set());
|
|
@@ -39,8 +32,6 @@ export function OnboardingWizard() {
|
|
|
39
32
|
|
|
40
33
|
const { dismissStep, ctx } = onboarding;
|
|
41
34
|
const total = totalRef.current || visibleSteps.length;
|
|
42
|
-
const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
|
|
43
|
-
const StepComponent = currentStep.Component;
|
|
44
35
|
|
|
45
36
|
const completeCurrentStep = () => {
|
|
46
37
|
setSessionDismissed((previous) => new Set([...previous, currentStep.id]));
|
|
@@ -56,19 +47,14 @@ export function OnboardingWizard() {
|
|
|
56
47
|
};
|
|
57
48
|
|
|
58
49
|
return (
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
</DialogTitle>
|
|
68
|
-
<DialogContent sx={{ pt: 2 }}>
|
|
69
|
-
<StepComponent onComplete={completeCurrentStep} onDismiss={dismissCurrentStep} ctx={ctx} />
|
|
70
|
-
</DialogContent>
|
|
71
|
-
</Dialog>
|
|
50
|
+
<WizardDialogShell
|
|
51
|
+
step={currentStep}
|
|
52
|
+
stepIndex={completed}
|
|
53
|
+
total={total}
|
|
54
|
+
onComplete={completeCurrentStep}
|
|
55
|
+
onDismiss={dismissCurrentStep}
|
|
56
|
+
ctx={ctx}
|
|
57
|
+
/>
|
|
72
58
|
);
|
|
73
59
|
}
|
|
74
60
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import Box from '@mui/material/Box';
|
|
4
|
+
import Dialog from '@mui/material/Dialog';
|
|
5
|
+
import DialogContent from '@mui/material/DialogContent';
|
|
6
|
+
import DialogTitle from '@mui/material/DialogTitle';
|
|
7
|
+
import LinearProgress from '@mui/material/LinearProgress';
|
|
8
|
+
import Typography from '@mui/material/Typography';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Presentational dialog shell shared by OnboardingWizard and the popup
|
|
12
|
+
* notification surface (NOTIF-12). Owns only the dialog frame, the
|
|
13
|
+
* counter/progress header, and the onComplete/onDismiss/ctx child contract —
|
|
14
|
+
* step selection, dismissal persistence, and any progress store stay with
|
|
15
|
+
* the caller.
|
|
16
|
+
*/
|
|
17
|
+
export function WizardDialogShell({ step, stepIndex, total, onComplete, onDismiss, ctx, title }) {
|
|
18
|
+
const { t } = useTranslation();
|
|
19
|
+
const progress = total > 1 ? Math.round((stepIndex / total) * 100) : 0;
|
|
20
|
+
const StepComponent = step.Component;
|
|
21
|
+
// `title` lets a non-onboarding caller (e.g. the popup surface) supply its
|
|
22
|
+
// own header instead of the onboarding-specific step counter/"Setup" copy.
|
|
23
|
+
// Omitted by OnboardingWizard, so its header text is unchanged.
|
|
24
|
+
const headerLabel = title ?? (total > 1
|
|
25
|
+
? t('Onboarding.STEP_COUNTER', { current: stepIndex + 1, total })
|
|
26
|
+
: t('Onboarding.SETUP'));
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Dialog
|
|
30
|
+
open
|
|
31
|
+
fullWidth
|
|
32
|
+
maxWidth="sm"
|
|
33
|
+
disableEscapeKeyDown={step.blocking}
|
|
34
|
+
onClose={step.blocking ? undefined : onDismiss}
|
|
35
|
+
>
|
|
36
|
+
<DialogTitle sx={{ pb: 0 }}>
|
|
37
|
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
38
|
+
<Typography variant="subtitle2" color="text.secondary">
|
|
39
|
+
{headerLabel}
|
|
40
|
+
</Typography>
|
|
41
|
+
</Box>
|
|
42
|
+
{total > 1 && <LinearProgress variant="determinate" value={progress} sx={{ mt: 1, borderRadius: 1 }} />}
|
|
43
|
+
</DialogTitle>
|
|
44
|
+
<DialogContent sx={{ pt: 2 }}>
|
|
45
|
+
<StepComponent onComplete={onComplete} onDismiss={onDismiss} ctx={ctx} />
|
|
46
|
+
</DialogContent>
|
|
47
|
+
</Dialog>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default WizardDialogShell;
|
|
@@ -136,6 +136,7 @@ describe('NotificationsProvider', () => {
|
|
|
136
136
|
it('decrements unread count on a cross-tab status push and does not double-count a repeat push', async () => {
|
|
137
137
|
renderProvider();
|
|
138
138
|
await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
|
|
139
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
139
140
|
|
|
140
141
|
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
|
|
141
142
|
type: 'notification.status',
|
|
@@ -194,4 +195,42 @@ describe('NotificationsProvider', () => {
|
|
|
194
195
|
expect(currentNotifications()[0]).toEqual(expect.objectContaining({ done: true }));
|
|
195
196
|
expect(feedApi.markNotifications).toHaveBeenCalledWith({ action: 'done', ids: [1] });
|
|
196
197
|
});
|
|
198
|
+
|
|
199
|
+
it('folds two WS messages for the same notification_id into one feed entry with a single unread increment (NOTIF-12 D2)', async () => {
|
|
200
|
+
renderProvider();
|
|
201
|
+
await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
|
|
202
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
203
|
+
|
|
204
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
|
|
205
|
+
type: 'payment_due',
|
|
206
|
+
channel: 'chip',
|
|
207
|
+
notification_id: 404,
|
|
208
|
+
content: { title_key: 'chip.title' },
|
|
209
|
+
}) });
|
|
210
|
+
await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('3'));
|
|
211
|
+
|
|
212
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
|
|
213
|
+
type: 'payment_due',
|
|
214
|
+
channel: 'popup',
|
|
215
|
+
notification_id: 404,
|
|
216
|
+
content: { title_key: 'popup.title' },
|
|
217
|
+
}) });
|
|
218
|
+
|
|
219
|
+
await waitFor(() => expect(currentNotifications().find((n) => n.notification_id === 404).content)
|
|
220
|
+
.toEqual({ title_key: 'popup.title' }));
|
|
221
|
+
expect(currentNotifications().filter((n) => n.notification_id === 404)).toHaveLength(1);
|
|
222
|
+
expect(screen.getByTestId('unread-count').textContent).toBe('3');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('NOTIF-13 R1 guard: does not open the socket until the REST seed resolves', async () => {
|
|
226
|
+
let resolveFeed;
|
|
227
|
+
feedApi.getNotificationFeed.mockReturnValue(new Promise((resolve) => { resolveFeed = resolve; }));
|
|
228
|
+
|
|
229
|
+
renderProvider();
|
|
230
|
+
|
|
231
|
+
expect(MockWebSocket.instances).toHaveLength(0);
|
|
232
|
+
|
|
233
|
+
resolveFeed({ results: [] });
|
|
234
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
235
|
+
});
|
|
197
236
|
});
|