@actuate-media/cms-admin 0.12.1 → 0.13.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/dist/__tests__/components/notification-bell.test.js +146 -0
- package/dist/__tests__/components/notification-bell.test.js.map +1 -1
- package/dist/__tests__/lib/notifications-client.test.js +129 -1
- package/dist/__tests__/lib/notifications-client.test.js.map +1 -1
- package/dist/components/NotificationBell.d.ts +30 -9
- package/dist/components/NotificationBell.d.ts.map +1 -1
- package/dist/components/NotificationBell.js +57 -5
- package/dist/components/NotificationBell.js.map +1 -1
- package/dist/lib/api.d.ts +8 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +10 -0
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/notifications-client.d.ts +53 -0
- package/dist/lib/notifications-client.d.ts.map +1 -1
- package/dist/lib/notifications-client.js +89 -1
- package/dist/lib/notifications-client.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/components/notification-bell.test.tsx +167 -1
- package/src/__tests__/lib/notifications-client.test.ts +145 -1
- package/src/components/NotificationBell.tsx +84 -12
- package/src/lib/api.ts +11 -0
- package/src/lib/notifications-client.ts +147 -1
package/dist/lib/api.js
CHANGED
|
@@ -3,6 +3,16 @@ let basePath = DEFAULT_BASE;
|
|
|
3
3
|
export function setApiBase(path) {
|
|
4
4
|
basePath = path;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* Current API base path. Exposed so non-`fetch` consumers (in
|
|
8
|
+
* particular `EventSource` for SSE channels) can resolve URLs against
|
|
9
|
+
* the same prefix configured via `setApiBase()`. Keep this read-only
|
|
10
|
+
* — mutation must always flow through `setApiBase()` so we never have
|
|
11
|
+
* two callers with diverging views of the base.
|
|
12
|
+
*/
|
|
13
|
+
export function getApiBase() {
|
|
14
|
+
return basePath;
|
|
15
|
+
}
|
|
6
16
|
function getCsrfToken() {
|
|
7
17
|
if (typeof document === 'undefined')
|
|
8
18
|
return '';
|
package/dist/lib/api.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/lib/api.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/lib/api.ts"],"names":[],"mappings":"AAAA,MAAM,YAAY,GAAG,UAAU,CAAA;AAE/B,IAAI,QAAQ,GAAG,YAAY,CAAA;AAE3B,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,QAAQ,GAAG,IAAI,CAAA;AACjB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU;IACxB,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED,SAAS,YAAY;IACnB,IAAI,OAAO,QAAQ,KAAK,WAAW;QAAE,OAAO,EAAE,CAAA;IAC9C,MAAM,KAAK,GAAG,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,gCAAgC,CAAC,CAAA;IACrE,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;AACzB,CAAC;AAED;;;;;;;;GAQG;AACH,IAAI,YAAY,GAAyB,IAAI,CAAA;AAE7C,MAAM,CAAC,KAAK,UAAU,eAAe;IACnC,IAAI,YAAY,EAAE;QAAE,OAAM;IAC1B,IAAI,YAAY;QAAE,OAAO,YAAY,CAAA;IACrC,YAAY,GAAG,CAAC,KAAK,IAAI,EAAE;QACzB,IAAI,CAAC;YACH,MAAM,KAAK,CAAC,GAAG,QAAQ,YAAY,EAAE,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC,CAAA;QAClE,CAAC;QAAC,MAAM,CAAC;YACP,oEAAoE;YACpE,mEAAmE;QACrE,CAAC;gBAAS,CAAC;YACT,YAAY,GAAG,IAAI,CAAA;QACrB,CAAC;IACH,CAAC,CAAC,EAAE,CAAA;IACJ,OAAO,YAAY,CAAA;AACrB,CAAC;AAED,SAAS,eAAe;IACtB,IAAI,OAAO,YAAY,KAAK,WAAW;QAAE,OAAO,IAAI,CAAA;IACpD,OAAO,YAAY,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAA;AAC/C,CAAC;AAED,SAAS,iBAAiB,CAAC,QAAgB;IACzC,MAAM,MAAM,GAAG,eAAe,EAAE,CAAA;IAChC,IAAI,CAAC,MAAM;QAAE,OAAO,QAAQ,CAAA;IAC5B,MAAM,SAAS,GAAG,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAA;IACpD,OAAO,GAAG,QAAQ,GAAG,SAAS,UAAU,kBAAkB,CAAC,MAAM,CAAC,EAAE,CAAA;AACtE,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,MAAM,CAC1B,QAAgB,EAChB,UAAuB,EAAE;IAEzB,MAAM,GAAG,GAAG,GAAG,QAAQ,GAAG,iBAAiB,CAAC,QAAQ,CAAC,EAAE,CAAA;IACvD,MAAM,UAAU,GAAG,OAAO,QAAQ,KAAK,WAAW,IAAI,OAAO,CAAC,IAAI,YAAY,QAAQ,CAAA;IAEtF,MAAM,OAAO,GAA2B;QACtC,GAAG,CAAE,OAAO,CAAC,OAAkC,IAAI,EAAE,CAAC;KACvD,CAAA;IAED,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO,CAAC,cAAc,CAAC,GAAG,kBAAkB,CAAA;IAC9C,CAAC;IAED,MAAM,MAAM,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC,WAAW,EAAE,CAAA;IACtD,IAAI,MAAM,KAAK,KAAK,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QAC1C,sEAAsE;QACtE,uEAAuE;QACvE,wEAAwE;QACxE,wEAAwE;QACxE,sBAAsB;QACtB,IAAI,CAAC,YAAY,EAAE,EAAE,CAAC;YACpB,MAAM,eAAe,EAAE,CAAA;QACzB,CAAC;QACD,MAAM,IAAI,GAAG,YAAY,EAAE,CAAA;QAC3B,IAAI,IAAI;YAAE,OAAO,CAAC,cAAc,CAAC,GAAG,IAAI,CAAA;IAC1C,CAAC;IAED,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,GAAG,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC,CAAA;QAC7E,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;QAE/C,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;YACZ,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,mBAAmB,GAAG,CAAC,MAAM,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAA;QACtF,CAAC;QAED,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAA;IACxD,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,EAAE,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe,EAAE,MAAM,EAAE,CAAC,EAAE,CAAA;IACnF,CAAC;AACH,CAAC"}
|
|
@@ -58,6 +58,59 @@ export declare function listNotifications(opts?: ListNotificationsOptions): Prom
|
|
|
58
58
|
export declare function unreadCount(): Promise<NotificationOutcome<number>>;
|
|
59
59
|
export declare function markNotificationRead(id: string): Promise<NotificationOutcome<Notification>>;
|
|
60
60
|
export declare function markAllNotificationsRead(): Promise<NotificationOutcome<number>>;
|
|
61
|
+
/**
|
|
62
|
+
* Subscribe to the per-user SSE push channel that ships notifications
|
|
63
|
+
* the instant they're persisted server-side. Replaces the 30-second
|
|
64
|
+
* poll loop in the notification bell.
|
|
65
|
+
*
|
|
66
|
+
* The browser's native `EventSource` handles reconnection for us — on
|
|
67
|
+
* a server-initiated close (the stream caps at ~4 minutes by design),
|
|
68
|
+
* the connection re-opens automatically. We DO NOT abort the poll
|
|
69
|
+
* fallback in callers: SSE is a latency-win, the poll is what keeps
|
|
70
|
+
* multi-node deployments correct (each node only pushes to its own
|
|
71
|
+
* subscribers).
|
|
72
|
+
*
|
|
73
|
+
* Returns a `close()` function that callers MUST invoke on unmount —
|
|
74
|
+
* otherwise the browser keeps the connection open and we leak event
|
|
75
|
+
* listeners.
|
|
76
|
+
*
|
|
77
|
+
* `EventSource` itself isn't available in the JSDOM test environment;
|
|
78
|
+
* callers can inject a `factory` to substitute a test double. In
|
|
79
|
+
* production we fall back to the global `EventSource` when no factory
|
|
80
|
+
* is supplied.
|
|
81
|
+
*/
|
|
82
|
+
export interface NotificationStreamOptions {
|
|
83
|
+
/** Called for every `event: notification` frame. */
|
|
84
|
+
onNotification: (n: Notification) => void;
|
|
85
|
+
/** Called once on the `event: ready` frame. */
|
|
86
|
+
onReady?: (payload: {
|
|
87
|
+
userId: string;
|
|
88
|
+
}) => void;
|
|
89
|
+
/** Called on connection errors. Useful for falling back to the poll loop. */
|
|
90
|
+
onError?: (event: Event) => void;
|
|
91
|
+
/**
|
|
92
|
+
* Optional factory so unit tests can inject a fake `EventSource`,
|
|
93
|
+
* or a null sentinel when the environment lacks one (SSR, JSDOM
|
|
94
|
+
* without a polyfill). Production code can rely on the default
|
|
95
|
+
* (global `EventSource`).
|
|
96
|
+
*/
|
|
97
|
+
factory?: (url: string) => EventSourceLike | null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Minimal subset of the `EventSource` interface our subscriber relies
|
|
101
|
+
* on. Browsers ship the full DOM type; the admin only needs add/remove
|
|
102
|
+
* event listeners plus `close()`.
|
|
103
|
+
*/
|
|
104
|
+
export interface EventSourceLike {
|
|
105
|
+
addEventListener: (type: string, listener: (event: MessageEvent | Event) => void) => void;
|
|
106
|
+
removeEventListener?: (type: string, listener: (event: MessageEvent | Event) => void) => void;
|
|
107
|
+
close: () => void;
|
|
108
|
+
}
|
|
109
|
+
export interface NotificationStreamHandle {
|
|
110
|
+
/** Tear down the EventSource and stop receiving events. Idempotent. */
|
|
111
|
+
close: () => void;
|
|
112
|
+
}
|
|
113
|
+
export declare function subscribeNotificationStream(opts: NotificationStreamOptions): NotificationStreamHandle;
|
|
61
114
|
/**
|
|
62
115
|
* Cheap single-line summary for the bell list — the server gives us a
|
|
63
116
|
* structured payload, but the dropdown wants one human-readable string
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notifications-client.d.ts","sourceRoot":"","sources":["../../src/lib/notifications-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,MAAM,MAAM,gBAAgB,GAAG,eAAe,GAAG,kBAAkB,GAAG,iBAAiB,CAAA;AAEvF,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,eAAe,CAAA;IACrB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,kBAAkB,CAAA;IACxB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,iBAAiB,CAAA;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,MAAM,mBAAmB,GAC3B,mBAAmB,GACnB,sBAAsB,GACtB,qBAAqB,CAAA;AAEzB,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,gBAAgB,CAAA;IACtB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,mBAAmB,CAAA;IAC5B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,MAAM,mBAAmB,CAAC,CAAC,IAC7B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,CAAC,CAAA;CAAE,GACvB;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAEhD,MAAM,WAAW,wBAAwB;IACvC,yEAAyE;IACzE,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAyBD,wBAAsB,iBAAiB,CACrC,IAAI,GAAE,wBAA6B,GAClC,OAAO,CAAC,mBAAmB,CAAC,YAAY,EAAE,CAAC,CAAC,CAK9C;AAED,wBAAsB,WAAW,IAAI,OAAO,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CAQxE;AAED,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,YAAY,CAAC,CAAC,CAOjG;AAED,wBAAsB,wBAAwB,IAAI,OAAO,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CASrF;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,YAAY,EAAE,YAAY,GAAG,MAAM,CAYtE"}
|
|
1
|
+
{"version":3,"file":"notifications-client.d.ts","sourceRoot":"","sources":["../../src/lib/notifications-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* shape here to avoid taking a hard dependency on the server module
|
|
11
11
|
* (which carries Prisma types that don't belong in the admin bundle).
|
|
12
12
|
*/
|
|
13
|
-
import { cmsApi } from './api.js';
|
|
13
|
+
import { cmsApi, getApiBase } from './api.js';
|
|
14
14
|
function failure(error, status) {
|
|
15
15
|
return { ok: false, error, status };
|
|
16
16
|
}
|
|
@@ -63,6 +63,94 @@ export async function markAllNotificationsRead() {
|
|
|
63
63
|
}
|
|
64
64
|
return { ok: true, result: res.data.count };
|
|
65
65
|
}
|
|
66
|
+
export function subscribeNotificationStream(opts) {
|
|
67
|
+
// The `cmsApi()` helper handles base-path / origin resolution for
|
|
68
|
+
// fetch calls, but EventSource takes a raw URL. We mirror its
|
|
69
|
+
// logic locally — `/api/cms/notifications/stream` is correct for
|
|
70
|
+
// both the local dev server and any production deploy that mounts
|
|
71
|
+
// the CMS API at the default prefix. Custom prefixes are handled by
|
|
72
|
+
// the consumer override (factory).
|
|
73
|
+
// Resolve against `setApiBase()` so a consumer mounting the CMS at
|
|
74
|
+
// a non-default prefix (e.g. `/cms-api`) still hits the right SSE
|
|
75
|
+
// endpoint — `EventSource` doesn't go through `cmsApi()`, so we
|
|
76
|
+
// can't rely on its url-construction logic.
|
|
77
|
+
const url = `${getApiBase()}/notifications/stream`;
|
|
78
|
+
const make = opts.factory ?? defaultEventSourceFactory;
|
|
79
|
+
const source = make(url);
|
|
80
|
+
if (!source) {
|
|
81
|
+
// No EventSource available (SSR? JSDOM without polyfill? Or the
|
|
82
|
+
// injected factory deliberately returns null in tests). Return a
|
|
83
|
+
// no-op handle so callers can compose without conditional logic.
|
|
84
|
+
return { close: () => { } };
|
|
85
|
+
}
|
|
86
|
+
let closed = false;
|
|
87
|
+
const onReady = (event) => {
|
|
88
|
+
if (!opts.onReady)
|
|
89
|
+
return;
|
|
90
|
+
const data = parseEventData(event);
|
|
91
|
+
if (data && typeof data.userId === 'string') {
|
|
92
|
+
opts.onReady({ userId: data.userId });
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const onNotification = (event) => {
|
|
96
|
+
const data = parseEventData(event);
|
|
97
|
+
if (data && isNotification(data)) {
|
|
98
|
+
opts.onNotification(data);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const onError = (event) => {
|
|
102
|
+
opts.onError?.(event);
|
|
103
|
+
};
|
|
104
|
+
source.addEventListener('ready', onReady);
|
|
105
|
+
source.addEventListener('notification', onNotification);
|
|
106
|
+
source.addEventListener('error', onError);
|
|
107
|
+
return {
|
|
108
|
+
close: () => {
|
|
109
|
+
if (closed)
|
|
110
|
+
return;
|
|
111
|
+
closed = true;
|
|
112
|
+
source.removeEventListener?.('ready', onReady);
|
|
113
|
+
source.removeEventListener?.('notification', onNotification);
|
|
114
|
+
source.removeEventListener?.('error', onError);
|
|
115
|
+
source.close();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function defaultEventSourceFactory(url) {
|
|
120
|
+
if (typeof EventSource === 'undefined')
|
|
121
|
+
return null;
|
|
122
|
+
// `withCredentials: true` so the admin session cookie travels with
|
|
123
|
+
// the connection upgrade — every notification stream requires auth
|
|
124
|
+
// and the same-origin cookie is the only credential we trust here.
|
|
125
|
+
return new EventSource(url, { withCredentials: true });
|
|
126
|
+
}
|
|
127
|
+
function parseEventData(event) {
|
|
128
|
+
// EventSource emits MessageEvents with `data: string`. Plain
|
|
129
|
+
// `Event` (e.g. the `error` event) has no payload; ignore.
|
|
130
|
+
const data = event.data;
|
|
131
|
+
if (typeof data !== 'string')
|
|
132
|
+
return null;
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(data);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function isNotification(value) {
|
|
141
|
+
if (!value || typeof value !== 'object')
|
|
142
|
+
return false;
|
|
143
|
+
const v = value;
|
|
144
|
+
// Defensive: don't trust the wire blindly — verify the discriminant
|
|
145
|
+
// properties our consumers will read. A drift in the server-side
|
|
146
|
+
// wire format must surface as "missing fields" in the test suite,
|
|
147
|
+
// not as runtime errors deep in the bell's render path.
|
|
148
|
+
return (typeof v.id === 'string' &&
|
|
149
|
+
typeof v.userId === 'string' &&
|
|
150
|
+
typeof v.createdAt === 'string' &&
|
|
151
|
+
!!v.payload &&
|
|
152
|
+
typeof v.payload.kind === 'string');
|
|
153
|
+
}
|
|
66
154
|
/**
|
|
67
155
|
* Cheap single-line summary for the bell list — the server gives us a
|
|
68
156
|
* structured payload, but the dropdown wants one human-readable string
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notifications-client.js","sourceRoot":"","sources":["../../src/lib/notifications-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"notifications-client.js","sourceRoot":"","sources":["../../src/lib/notifications-client.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"tailwindcss": "^4.0.0",
|
|
88
88
|
"typescript": "^5.7.0",
|
|
89
89
|
"vitest": "^3.0.0",
|
|
90
|
-
"@actuate-media/cms-core": "0.
|
|
90
|
+
"@actuate-media/cms-core": "0.24.0",
|
|
91
91
|
"@actuate-media/component-blocks": "0.2.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
@@ -4,7 +4,12 @@ import React from 'react'
|
|
|
4
4
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
5
5
|
|
|
6
6
|
import { NotificationBell, type NotificationsApi } from '../../components/NotificationBell.js'
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
Notification,
|
|
9
|
+
NotificationOutcome,
|
|
10
|
+
NotificationStreamHandle,
|
|
11
|
+
NotificationStreamOptions,
|
|
12
|
+
} from '../../lib/notifications-client.js'
|
|
8
13
|
|
|
9
14
|
function ok<T>(value: T): NotificationOutcome<T> {
|
|
10
15
|
return { ok: true, result: value }
|
|
@@ -211,3 +216,164 @@ describe('NotificationBell — dropdown', () => {
|
|
|
211
216
|
await waitFor(() => expect(onError).toHaveBeenCalledWith('CSRF'))
|
|
212
217
|
})
|
|
213
218
|
})
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// SSE push channel (Phase 7) — verify the bell wires up subscribe(),
|
|
222
|
+
// processes incoming notifications, and tears down on unmount.
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
describe('NotificationBell — SSE push', () => {
|
|
225
|
+
function withSubscribe(api: NotificationsApi) {
|
|
226
|
+
let captured: NotificationStreamOptions | undefined
|
|
227
|
+
let closed = false
|
|
228
|
+
const handle: NotificationStreamHandle = {
|
|
229
|
+
close: () => {
|
|
230
|
+
closed = true
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
const subscribe = vi.fn((opts: NotificationStreamOptions) => {
|
|
234
|
+
captured = opts
|
|
235
|
+
return handle
|
|
236
|
+
})
|
|
237
|
+
return {
|
|
238
|
+
api: { ...api, subscribe },
|
|
239
|
+
push: (n: Notification) => captured?.onNotification(n),
|
|
240
|
+
pushError: (e: Event) => captured?.onError?.(e),
|
|
241
|
+
isClosed: () => closed,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
it('subscribes on mount and bumps the badge when a new notification arrives', async () => {
|
|
246
|
+
const fake = createFakeApi()
|
|
247
|
+
const wired = withSubscribe(fake.api)
|
|
248
|
+
render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
|
|
249
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
250
|
+
await act(async () => {
|
|
251
|
+
wired.push(row({ id: 'sse-1' }))
|
|
252
|
+
})
|
|
253
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('does not double-count a notification that arrives via both SSE and REST', async () => {
|
|
257
|
+
const fake = createFakeApi()
|
|
258
|
+
const wired = withSubscribe(fake.api)
|
|
259
|
+
fake.setRows([row({ id: 'race' })])
|
|
260
|
+
render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
|
|
261
|
+
// Open the dropdown so the row is in `items` from the REST list.
|
|
262
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
263
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
264
|
+
await waitFor(() => expect(screen.getByTestId('bell-row-race')).toBeTruthy())
|
|
265
|
+
// Now push the same row via SSE — must NOT add a duplicate or
|
|
266
|
+
// bump the badge above 1.
|
|
267
|
+
await act(async () => {
|
|
268
|
+
wired.push(row({ id: 'race' }))
|
|
269
|
+
})
|
|
270
|
+
expect(screen.getAllByTestId('bell-row-race')).toHaveLength(1)
|
|
271
|
+
expect(screen.getByTestId('bell-badge').textContent).toBe('1')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('prepends new pushed rows to the open dropdown', async () => {
|
|
275
|
+
const fake = createFakeApi()
|
|
276
|
+
const wired = withSubscribe(fake.api)
|
|
277
|
+
fake.setRows([row({ id: 'old' })])
|
|
278
|
+
render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
|
|
279
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
280
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
281
|
+
await waitFor(() => expect(screen.getByTestId('bell-row-old')).toBeTruthy())
|
|
282
|
+
await act(async () => {
|
|
283
|
+
wired.push(row({ id: 'fresh' }))
|
|
284
|
+
})
|
|
285
|
+
const rows = screen.getAllByTestId(/^bell-row-/)
|
|
286
|
+
expect(rows[0]!.getAttribute('data-testid')).toBe('bell-row-fresh')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('does NOT bump the badge when the pushed notification is already read', async () => {
|
|
290
|
+
const fake = createFakeApi()
|
|
291
|
+
const wired = withSubscribe(fake.api)
|
|
292
|
+
render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
|
|
293
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
294
|
+
await act(async () => {
|
|
295
|
+
wired.push(row({ id: 'already-read', readAt: '2026-01-02T00:00:00.000Z' }))
|
|
296
|
+
})
|
|
297
|
+
// Already-read notifications still appear in the list when the
|
|
298
|
+
// user opens the dropdown with includeRead=true, but they must
|
|
299
|
+
// NOT register a fresh unread bump.
|
|
300
|
+
expect(screen.queryByTestId('bell-badge')).toBeNull()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('closes the SSE subscription when the bell unmounts', async () => {
|
|
304
|
+
const fake = createFakeApi()
|
|
305
|
+
const wired = withSubscribe(fake.api)
|
|
306
|
+
const { unmount } = render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
|
|
307
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
308
|
+
unmount()
|
|
309
|
+
expect(wired.isClosed()).toBe(true)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('does not surface transient SSE errors via onError (poll loop handles convergence)', async () => {
|
|
313
|
+
const fake = createFakeApi()
|
|
314
|
+
const wired = withSubscribe(fake.api)
|
|
315
|
+
const onError = vi.fn()
|
|
316
|
+
render(<NotificationBell api={wired.api} pollIntervalMs={0} onError={onError} />)
|
|
317
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
318
|
+
await act(async () => {
|
|
319
|
+
wired.pushError(new Event('error'))
|
|
320
|
+
})
|
|
321
|
+
// Deliberately quiet — see NotificationBell SSE handler comment.
|
|
322
|
+
expect(onError).not.toHaveBeenCalled()
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('mark-all-read failure does not drop SSE rows that arrived mid-flight', async () => {
|
|
326
|
+
const fake = createFakeApi()
|
|
327
|
+
const wired = withSubscribe(fake.api)
|
|
328
|
+
fake.setRows([row({ id: 'before' })])
|
|
329
|
+
// Hold the markAllRead promise so we can interleave an SSE push.
|
|
330
|
+
let resolveMark: (v: NotificationOutcome<number>) => void = () => undefined
|
|
331
|
+
;(wired.api.markAllRead as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
|
332
|
+
() =>
|
|
333
|
+
new Promise((resolve) => {
|
|
334
|
+
resolveMark = resolve
|
|
335
|
+
}),
|
|
336
|
+
)
|
|
337
|
+
const onError = vi.fn()
|
|
338
|
+
render(<NotificationBell api={wired.api} pollIntervalMs={0} onError={onError} />)
|
|
339
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
340
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
341
|
+
await waitFor(() => expect(screen.getByTestId('bell-row-before')).toBeTruthy())
|
|
342
|
+
|
|
343
|
+
// Click mark-all — handler optimistically marks 'before' read,
|
|
344
|
+
// but the API promise is still pending.
|
|
345
|
+
await act(async () => {
|
|
346
|
+
fireEvent.click(screen.getByTestId('bell-mark-all'))
|
|
347
|
+
})
|
|
348
|
+
// While in flight, a fresh notification arrives via SSE.
|
|
349
|
+
await act(async () => {
|
|
350
|
+
wired.push(row({ id: 'mid-flight' }))
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// Now resolve the API with a failure.
|
|
354
|
+
await act(async () => {
|
|
355
|
+
resolveMark({ ok: false, error: 'server', status: 500 })
|
|
356
|
+
// flush promise chain
|
|
357
|
+
await Promise.resolve()
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
expect(onError).toHaveBeenCalledWith('server')
|
|
361
|
+
// The original row was rolled back to unread...
|
|
362
|
+
expect(screen.getByTestId('bell-row-before').getAttribute('data-read')).toBe('false')
|
|
363
|
+
// ...and the SSE arrival is still present (not dropped by the
|
|
364
|
+
// legacy snapshot-replace rollback).
|
|
365
|
+
expect(screen.getByTestId('bell-row-mid-flight')).toBeTruthy()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('still mounts cleanly when api.subscribe is undefined (no SSE support)', async () => {
|
|
369
|
+
const fake = createFakeApi()
|
|
370
|
+
// Strip the `subscribe` member entirely — defaultApi has it,
|
|
371
|
+
// but consumer-provided test APIs may not.
|
|
372
|
+
const apiWithoutSubscribe: NotificationsApi = { ...fake.api }
|
|
373
|
+
delete (apiWithoutSubscribe as { subscribe?: unknown }).subscribe
|
|
374
|
+
render(<NotificationBell api={apiWithoutSubscribe} pollIntervalMs={0} />)
|
|
375
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
376
|
+
// No crash, badge from unread() shows nothing, no items.
|
|
377
|
+
expect(screen.queryByTestId('bell-badge')).toBeNull()
|
|
378
|
+
})
|
|
379
|
+
})
|
|
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
3
3
|
const cmsApiMock = vi.fn()
|
|
4
4
|
vi.mock('../../lib/api.js', () => ({
|
|
5
5
|
cmsApi: (...args: unknown[]) => cmsApiMock(...args),
|
|
6
|
+
// SSE uses EventSource, which doesn't go through cmsApi. The
|
|
7
|
+
// helper resolves the URL via getApiBase() — return the production
|
|
8
|
+
// default so tests don't need to know the prefix.
|
|
9
|
+
getApiBase: () => '/api/cms',
|
|
6
10
|
}))
|
|
7
11
|
|
|
8
12
|
const {
|
|
@@ -10,9 +14,14 @@ const {
|
|
|
10
14
|
markAllNotificationsRead,
|
|
11
15
|
markNotificationRead,
|
|
12
16
|
notificationSummary,
|
|
17
|
+
subscribeNotificationStream,
|
|
13
18
|
unreadCount,
|
|
14
19
|
} = await import('../../lib/notifications-client.js')
|
|
15
|
-
import type {
|
|
20
|
+
import type {
|
|
21
|
+
EventSourceLike,
|
|
22
|
+
Notification,
|
|
23
|
+
NotificationStreamOptions,
|
|
24
|
+
} from '../../lib/notifications-client.js'
|
|
16
25
|
|
|
17
26
|
function row(overrides: Partial<Notification> = {}): Notification {
|
|
18
27
|
return {
|
|
@@ -193,3 +202,138 @@ describe('notificationSummary', () => {
|
|
|
193
202
|
).toMatch(/^Someone replied/)
|
|
194
203
|
})
|
|
195
204
|
})
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// SSE push channel — subscribeNotificationStream
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* In-memory `EventSource` double. Tests drive the wire by invoking
|
|
212
|
+
* `emit(event, payload)`; the subscriber dispatches the right
|
|
213
|
+
* listener. We deliberately keep the shape minimal — `EventSource`
|
|
214
|
+
* has dozens of properties our admin doesn't touch.
|
|
215
|
+
*/
|
|
216
|
+
function makeFakeSource() {
|
|
217
|
+
const listeners = new Map<string, Set<(e: MessageEvent | Event) => void>>()
|
|
218
|
+
let closed = false
|
|
219
|
+
const source: EventSourceLike = {
|
|
220
|
+
addEventListener: (type, listener) => {
|
|
221
|
+
if (closed) return
|
|
222
|
+
let set = listeners.get(type)
|
|
223
|
+
if (!set) {
|
|
224
|
+
set = new Set()
|
|
225
|
+
listeners.set(type, set)
|
|
226
|
+
}
|
|
227
|
+
set.add(listener)
|
|
228
|
+
},
|
|
229
|
+
removeEventListener: (type, listener) => {
|
|
230
|
+
listeners.get(type)?.delete(listener)
|
|
231
|
+
},
|
|
232
|
+
close: () => {
|
|
233
|
+
closed = true
|
|
234
|
+
listeners.clear()
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
source,
|
|
239
|
+
listenerCount: (type: string) => listeners.get(type)?.size ?? 0,
|
|
240
|
+
isClosed: () => closed,
|
|
241
|
+
emit(type: string, payload: unknown) {
|
|
242
|
+
const set = listeners.get(type)
|
|
243
|
+
if (!set) return
|
|
244
|
+
const event =
|
|
245
|
+
typeof payload === 'string'
|
|
246
|
+
? ({ data: payload } as MessageEvent)
|
|
247
|
+
: payload === undefined
|
|
248
|
+
? (new Event(type) as Event)
|
|
249
|
+
: ({ data: JSON.stringify(payload) } as MessageEvent)
|
|
250
|
+
for (const l of [...set]) l(event)
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function startStream(
|
|
256
|
+
fake: ReturnType<typeof makeFakeSource>,
|
|
257
|
+
overrides: Partial<NotificationStreamOptions> = {},
|
|
258
|
+
) {
|
|
259
|
+
return subscribeNotificationStream({
|
|
260
|
+
onNotification: () => undefined,
|
|
261
|
+
factory: () => fake.source,
|
|
262
|
+
...overrides,
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
describe('subscribeNotificationStream', () => {
|
|
267
|
+
it('dispatches `notification` frames as Notification objects', () => {
|
|
268
|
+
const fake = makeFakeSource()
|
|
269
|
+
const seen: Notification[] = []
|
|
270
|
+
startStream(fake, { onNotification: (n) => seen.push(n) })
|
|
271
|
+
fake.emit('notification', row({ id: 'sse-1' }))
|
|
272
|
+
expect(seen.map((n) => n.id)).toEqual(['sse-1'])
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('dispatches `ready` frames once via onReady', () => {
|
|
276
|
+
const fake = makeFakeSource()
|
|
277
|
+
const readys: Array<{ userId: string }> = []
|
|
278
|
+
startStream(fake, { onReady: (p) => readys.push(p) })
|
|
279
|
+
fake.emit('ready', { userId: 'user-1' })
|
|
280
|
+
expect(readys).toEqual([{ userId: 'user-1' }])
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('ignores malformed payloads (defence against wire drift)', () => {
|
|
284
|
+
const fake = makeFakeSource()
|
|
285
|
+
const seen: Notification[] = []
|
|
286
|
+
startStream(fake, { onNotification: (n) => seen.push(n) })
|
|
287
|
+
// Missing id + payload — must not throw, must not deliver.
|
|
288
|
+
fake.emit('notification', { userId: 'user-1', createdAt: 'now' })
|
|
289
|
+
fake.emit('notification', 'not json')
|
|
290
|
+
expect(seen).toEqual([])
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('removes its listeners and closes the underlying source on close()', () => {
|
|
294
|
+
const fake = makeFakeSource()
|
|
295
|
+
const handle = startStream(fake)
|
|
296
|
+
expect(fake.listenerCount('notification')).toBe(1)
|
|
297
|
+
expect(fake.listenerCount('ready')).toBe(1)
|
|
298
|
+
expect(fake.listenerCount('error')).toBe(1)
|
|
299
|
+
handle.close()
|
|
300
|
+
expect(fake.isClosed()).toBe(true)
|
|
301
|
+
// Second close() is a no-op — guards against double-unmount loops.
|
|
302
|
+
handle.close()
|
|
303
|
+
expect(fake.isClosed()).toBe(true)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('returns a no-op handle when no EventSource is available', () => {
|
|
307
|
+
// No factory + no global EventSource (vitest jsdom env doesn't
|
|
308
|
+
// ship one). The subscriber must NOT throw — admin code must
|
|
309
|
+
// remain mountable in SSR / Node environments.
|
|
310
|
+
const handle = subscribeNotificationStream({
|
|
311
|
+
onNotification: () => undefined,
|
|
312
|
+
factory: () => null,
|
|
313
|
+
})
|
|
314
|
+
expect(() => handle.close()).not.toThrow()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('forwards error events to onError without crashing the page', () => {
|
|
318
|
+
const fake = makeFakeSource()
|
|
319
|
+
const errors: Event[] = []
|
|
320
|
+
startStream(fake, { onError: (e) => errors.push(e) })
|
|
321
|
+
fake.emit('error', undefined)
|
|
322
|
+
expect(errors).toHaveLength(1)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('builds the stream URL from getApiBase() so consumers with a custom prefix connect to the right host', () => {
|
|
326
|
+
// Custom factory inspects the URL it's handed. Verifies the
|
|
327
|
+
// subscriber composes /<base>/notifications/stream rather than
|
|
328
|
+
// hardcoding the production prefix.
|
|
329
|
+
const seen: string[] = []
|
|
330
|
+
subscribeNotificationStream({
|
|
331
|
+
onNotification: () => undefined,
|
|
332
|
+
factory: (url) => {
|
|
333
|
+
seen.push(url)
|
|
334
|
+
return null
|
|
335
|
+
},
|
|
336
|
+
})
|
|
337
|
+
expect(seen).toEqual(['/api/cms/notifications/stream'])
|
|
338
|
+
})
|
|
339
|
+
})
|