@actuate-media/cms-admin 0.12.1 → 0.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/dist/__tests__/components/comment-side-panel.test.js +93 -0
- package/dist/__tests__/components/comment-side-panel.test.js.map +1 -1
- package/dist/__tests__/components/mentionable-textarea.test.d.ts +2 -0
- package/dist/__tests__/components/mentionable-textarea.test.d.ts.map +1 -0
- package/dist/__tests__/components/mentionable-textarea.test.js +190 -0
- package/dist/__tests__/components/mentionable-textarea.test.js.map +1 -0
- 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/mention-picker.test.d.ts +2 -0
- package/dist/__tests__/lib/mention-picker.test.d.ts.map +1 -0
- package/dist/__tests__/lib/mention-picker.test.js +122 -0
- package/dist/__tests__/lib/mention-picker.test.js.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.js +129 -1
- package/dist/__tests__/lib/notifications-client.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/CommentSidePanel.d.ts +9 -1
- package/dist/components/CommentSidePanel.d.ts.map +1 -1
- package/dist/components/CommentSidePanel.js +8 -7
- package/dist/components/CommentSidePanel.js.map +1 -1
- package/dist/components/MentionableTextarea.d.ts +68 -0
- package/dist/components/MentionableTextarea.d.ts.map +1 -0
- package/dist/components/MentionableTextarea.js +181 -0
- package/dist/components/MentionableTextarea.js.map +1 -0
- 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/mention-picker.d.ts +69 -0
- package/dist/lib/mention-picker.d.ts.map +1 -0
- package/dist/lib/mention-picker.js +138 -0
- package/dist/lib/mention-picker.js.map +1 -0
- 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/comment-side-panel.test.tsx +134 -0
- package/src/__tests__/components/mentionable-textarea.test.tsx +226 -0
- package/src/__tests__/components/notification-bell.test.tsx +167 -1
- package/src/__tests__/lib/mention-picker.test.ts +145 -0
- package/src/__tests__/lib/notifications-client.test.ts +145 -1
- package/src/components/CommentSidePanel.tsx +37 -14
- package/src/components/MentionableTextarea.tsx +328 -0
- package/src/components/NotificationBell.tsx +84 -12
- package/src/lib/api.ts +11 -0
- package/src/lib/mention-picker.ts +180 -0
- package/src/lib/notifications-client.ts +147 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mention picker client logic + REST helper.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities, deliberately separated so each can be unit
|
|
5
|
+
* tested without rendering React or hitting fetch:
|
|
6
|
+
*
|
|
7
|
+
* 1. `searchMentionUsers(query)` — typed wrapper over the new
|
|
8
|
+
* `GET /api/cms/users/search` endpoint introduced in Phase 7.
|
|
9
|
+
* Returns up to 10 `{ id, name, email }` rows matching the query.
|
|
10
|
+
* 2. `detectMentionContext(value, caret)` — pure parser that scans a
|
|
11
|
+
* text input value backwards from the caret to decide whether the
|
|
12
|
+
* user is currently typing a `@`-mention, and if so what the
|
|
13
|
+
* query fragment is. Returns `null` when no active mention.
|
|
14
|
+
*
|
|
15
|
+
* Wire format note: the picker INSERTS `@<userId>` tokens at the
|
|
16
|
+
* cursor (matching the server-side `extractMentionedUserIds` regex
|
|
17
|
+
* in `cms-core/realtime/notifications.ts`). The user sees the chosen
|
|
18
|
+
* user's *name* in the picker but the textarea always holds the id.
|
|
19
|
+
* Rendering names back in the comment view is a separate slice that
|
|
20
|
+
* can ship later without breaking the wire format.
|
|
21
|
+
*/
|
|
22
|
+
import { cmsApi } from './api.js';
|
|
23
|
+
export async function searchMentionUsers(query) {
|
|
24
|
+
const trimmed = query.trim();
|
|
25
|
+
if (trimmed.length === 0) {
|
|
26
|
+
// Mirror the server's contract: an empty query returns an empty
|
|
27
|
+
// array. We short-circuit here so the picker doesn't spend a
|
|
28
|
+
// round-trip on a guaranteed-empty response.
|
|
29
|
+
return { ok: true, result: [] };
|
|
30
|
+
}
|
|
31
|
+
const res = await cmsApi(`/users/search?q=${encodeURIComponent(trimmed)}`, {
|
|
32
|
+
method: 'GET',
|
|
33
|
+
});
|
|
34
|
+
if (res.error || !res.data) {
|
|
35
|
+
return { ok: false, error: res.error ?? `Request failed (${res.status})`, status: res.status };
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, result: res.data };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Find an active mention at the caret. Mirrors the server-side
|
|
41
|
+
* `extractMentionedUserIds` regex
|
|
42
|
+
* (`/(^|\s)@[a-zA-Z0-9_-]{3,64}\b/g`) so the picker's intuition
|
|
43
|
+
* about what counts as a mention can never disagree with the
|
|
44
|
+
* server's notification routing.
|
|
45
|
+
*
|
|
46
|
+
* Returns `null` whenever:
|
|
47
|
+
* - the caret is at offset 0,
|
|
48
|
+
* - there is no `@` between the caret and the previous whitespace
|
|
49
|
+
* boundary,
|
|
50
|
+
* - the `@` is preceded by a non-whitespace, non-newline character
|
|
51
|
+
* (so an email address mid-word like `foo@bar` does NOT open
|
|
52
|
+
* the picker), or
|
|
53
|
+
* - the candidate query contains a character outside the mention
|
|
54
|
+
* charset (`[a-zA-Z0-9_-]`).
|
|
55
|
+
*
|
|
56
|
+
* The picker DOES open with a 0–2 character query — that's the
|
|
57
|
+
* "you've started typing a mention" UX. But it can NEVER commit
|
|
58
|
+
* such a token: `insertMentionToken` only fires for a `MentionUser`
|
|
59
|
+
* which always has an id of at least 3 chars, matching the server
|
|
60
|
+
* regex's min-length requirement.
|
|
61
|
+
*/
|
|
62
|
+
export function detectMentionContext(value, caret) {
|
|
63
|
+
if (typeof value !== 'string')
|
|
64
|
+
return null;
|
|
65
|
+
if (caret <= 0 || caret > value.length)
|
|
66
|
+
return null;
|
|
67
|
+
// Walk back from the caret looking for a `@`. Stop early on any
|
|
68
|
+
// character that is neither a valid mention char nor `@`. This
|
|
69
|
+
// means a typed space, newline, punctuation, etc. terminates the
|
|
70
|
+
// mention candidate the user was building.
|
|
71
|
+
let i = caret - 1;
|
|
72
|
+
while (i >= 0) {
|
|
73
|
+
const ch = value.charCodeAt(i);
|
|
74
|
+
if (ch === 64 /* @ */)
|
|
75
|
+
break;
|
|
76
|
+
if (!isMentionChar(ch))
|
|
77
|
+
return null;
|
|
78
|
+
i -= 1;
|
|
79
|
+
}
|
|
80
|
+
if (i < 0)
|
|
81
|
+
return null;
|
|
82
|
+
// The `@` must sit at the start of the field or be preceded by
|
|
83
|
+
// whitespace / newline. Otherwise we're mid-word (think `foo@bar`),
|
|
84
|
+
// which is not a mention.
|
|
85
|
+
if (i > 0) {
|
|
86
|
+
const prev = value.charCodeAt(i - 1);
|
|
87
|
+
if (!isBoundary(prev))
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const query = value.slice(i + 1, caret);
|
|
91
|
+
// Server caps queries at 64 chars; the picker simply stops
|
|
92
|
+
// suggesting once the typed query exceeds that — extra typing
|
|
93
|
+
// closes the picker so the user can complete an email or other
|
|
94
|
+
// long token without staring at a dead dropdown.
|
|
95
|
+
if (query.length > 64)
|
|
96
|
+
return null;
|
|
97
|
+
return { start: i, end: caret, query };
|
|
98
|
+
}
|
|
99
|
+
function isMentionChar(code) {
|
|
100
|
+
// [a-zA-Z0-9_-] — keep this in lock-step with the regex in
|
|
101
|
+
// `cms-core/realtime/notifications.ts:extractMentionedUserIds`.
|
|
102
|
+
if (code === 95 /* _ */ || code === 45 /* - */)
|
|
103
|
+
return true;
|
|
104
|
+
if (code >= 48 && code <= 57)
|
|
105
|
+
return true; // 0-9
|
|
106
|
+
if (code >= 65 && code <= 90)
|
|
107
|
+
return true; // A-Z
|
|
108
|
+
if (code >= 97 && code <= 122)
|
|
109
|
+
return true; // a-z
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
function isBoundary(code) {
|
|
113
|
+
// ASCII space, tab, line-feed, or carriage-return. We deliberately
|
|
114
|
+
// do NOT widen this to the full Unicode whitespace class — the
|
|
115
|
+
// server-side `extractMentionedUserIds` regex in
|
|
116
|
+
// `cms-core/realtime/notifications.ts` uses `\s` which DOES match
|
|
117
|
+
// Unicode whitespace, but its `^` anchor means the only realistic
|
|
118
|
+
// mid-string boundaries in practice are these four ASCII chars
|
|
119
|
+
// (a CMS comment body containing U+00A0 NBSP before an `@` would
|
|
120
|
+
// be both unusual and not worth opening the picker for).
|
|
121
|
+
return code === 32 || code === 9 || code === 10 || code === 13;
|
|
122
|
+
}
|
|
123
|
+
export function insertMentionToken(value, context, userId) {
|
|
124
|
+
const hasTrailingSpace = value.charAt(context.end) === ' ';
|
|
125
|
+
// If the user already has a space immediately after the mention
|
|
126
|
+
// region, reuse it (don't double-space) but still advance the
|
|
127
|
+
// caret past that space so the next keystroke continues naturally
|
|
128
|
+
// rather than landing back in front of the space.
|
|
129
|
+
const trailingSpace = hasTrailingSpace ? '' : ' ';
|
|
130
|
+
const token = `@${userId}${trailingSpace}`;
|
|
131
|
+
const before = value.slice(0, context.start);
|
|
132
|
+
const after = value.slice(context.end);
|
|
133
|
+
return {
|
|
134
|
+
value: `${before}${token}${after}`,
|
|
135
|
+
caret: context.start + token.length + (hasTrailingSpace ? 1 : 0),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=mention-picker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mention-picker.js","sourceRoot":"","sources":["../../src/lib/mention-picker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAYjC,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,KAAa;IACpD,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;IAC5B,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACzB,gEAAgE;QAChE,6DAA6D;QAC7D,6CAA6C;QAC7C,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,CAAA;IACjC,CAAC;IACD,MAAM,GAAG,GAAG,MAAM,MAAM,CAAgB,mBAAmB,kBAAkB,CAAC,OAAO,CAAC,EAAE,EAAE;QACxF,MAAM,EAAE,KAAK;KACd,CAAC,CAAA;IACF,IAAI,GAAG,CAAC,KAAK,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;QAC3B,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,IAAI,mBAAmB,GAAG,CAAC,MAAM,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAA;IAChG,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,IAAI,EAAE,CAAA;AACvC,CAAC;AAoBD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAa,EAAE,KAAa;IAC/D,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAA;IAC1C,IAAI,KAAK,IAAI,CAAC,IAAI,KAAK,GAAG,KAAK,CAAC,MAAM;QAAE,OAAO,IAAI,CAAA;IAEnD,gEAAgE;IAChE,+DAA+D;IAC/D,iEAAiE;IACjE,2CAA2C;IAC3C,IAAI,CAAC,GAAG,KAAK,GAAG,CAAC,CAAA;IACjB,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACd,MAAM,EAAE,GAAG,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAA;QAC9B,IAAI,EAAE,KAAK,EAAE,CAAC,OAAO;YAAE,MAAK;QAC5B,IAAI,CAAC,aAAa,CAAC,EAAE,CAAC;YAAE,OAAO,IAAI,CAAA;QACnC,CAAC,IAAI,CAAC,CAAA;IACR,CAAC;IACD,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IAEtB,+DAA+D;IAC/D,oEAAoE;IACpE,0BAA0B;IAC1B,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QACV,MAAM,IAAI,GAAG,KAAK,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,CAAC,CAAA;QACpC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAA;IACpC,CAAC;IAED,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,CAAA;IACvC,2DAA2D;IAC3D,8DAA8D;IAC9D,+DAA+D;IAC/D,iDAAiD;IACjD,IAAI,KAAK,CAAC,MAAM,GAAG,EAAE;QAAE,OAAO,IAAI,CAAA;IAElC,OAAO,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,CAAA;AACxC,CAAC;AAED,SAAS,aAAa,CAAC,IAAY;IACjC,2DAA2D;IAC3D,gEAAgE;IAChE,IAAI,IAAI,KAAK,EAAE,CAAC,OAAO,IAAI,IAAI,KAAK,EAAE,CAAC,OAAO;QAAE,OAAO,IAAI,CAAA;IAC3D,IAAI,IAAI,IAAI,EAAE,IAAI,IAAI,IAAI,EAAE;QAAE,OAAO,IAAI,CAAA,CAAC,MAAM;IAChD,IAAI,IAAI,IAAI,EAAE,IAAI,IAAI,IAAI,EAAE;QAAE,OAAO,IAAI,CAAA,CAAC,MAAM;IAChD,IAAI,IAAI,IAAI,EAAE,IAAI,IAAI,IAAI,GAAG;QAAE,OAAO,IAAI,CAAA,CAAC,MAAM;IACjD,OAAO,KAAK,CAAA;AACd,CAAC;AAED,SAAS,UAAU,CAAC,IAAY;IAC9B,mEAAmE;IACnE,+DAA+D;IAC/D,iDAAiD;IACjD,kEAAkE;IAClE,kEAAkE;IAClE,+DAA+D;IAC/D,iEAAiE;IACjE,yDAAyD;IACzD,OAAO,IAAI,KAAK,EAAE,IAAI,IAAI,KAAK,CAAC,IAAI,IAAI,KAAK,EAAE,IAAI,IAAI,KAAK,EAAE,CAAA;AAChE,CAAC;AAeD,MAAM,UAAU,kBAAkB,CAChC,KAAa,EACb,OAAuB,EACvB,MAAc;IAEd,MAAM,gBAAgB,GAAG,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,CAAA;IAC1D,gEAAgE;IAChE,8DAA8D;IAC9D,kEAAkE;IAClE,kDAAkD;IAClD,MAAM,aAAa,GAAG,gBAAgB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAA;IACjD,MAAM,KAAK,GAAG,IAAI,MAAM,GAAG,aAAa,EAAE,CAAA;IAC1C,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,CAAA;IAC5C,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;IACtC,OAAO;QACL,KAAK,EAAE,GAAG,MAAM,GAAG,KAAK,GAAG,KAAK,EAAE;QAClC,KAAK,EAAE,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KACjE,CAAA;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.14.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.25.0",
|
|
91
91
|
"@actuate-media/component-blocks": "0.2.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
@@ -341,3 +341,137 @@ describe('CommentSidePanel — reply, edit, resolve, delete', () => {
|
|
|
341
341
|
expect(onActive).toHaveBeenCalledWith('c1')
|
|
342
342
|
})
|
|
343
343
|
})
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Mention picker integration — Phase 7 carry-over
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
describe('CommentSidePanel — mention picker integration', () => {
|
|
350
|
+
/**
|
|
351
|
+
* Drive a `<textarea>` the same way the picker test does. The
|
|
352
|
+
* comment panel's three composer surfaces (new thread, reply,
|
|
353
|
+
* edit) are all `<MentionableTextarea>` now, so this helper works
|
|
354
|
+
* uniformly across them.
|
|
355
|
+
*/
|
|
356
|
+
async function typeAt(el: HTMLTextAreaElement, value: string, caret = value.length) {
|
|
357
|
+
await act(async () => {
|
|
358
|
+
el.focus()
|
|
359
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
|
|
360
|
+
if (setter) setter.call(el, value)
|
|
361
|
+
else el.value = value
|
|
362
|
+
el.setSelectionRange(caret, caret)
|
|
363
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function fakeMentionSearch() {
|
|
368
|
+
return vi.fn(async (q: string) => {
|
|
369
|
+
const all = [
|
|
370
|
+
{ id: 'u_alice', name: 'Alice Anderson', email: 'alice@example.com' },
|
|
371
|
+
{ id: 'u_bob', name: 'Bob Brown', email: 'bob@example.com' },
|
|
372
|
+
]
|
|
373
|
+
const ql = q.toLowerCase()
|
|
374
|
+
return all.filter(
|
|
375
|
+
(u) => u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql),
|
|
376
|
+
)
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
it('opens the mention picker from the new-thread composer and submits @<userId>', async () => {
|
|
381
|
+
const fake = createFakeApi()
|
|
382
|
+
const mentionSearch = fakeMentionSearch()
|
|
383
|
+
render(
|
|
384
|
+
<CommentSidePanel
|
|
385
|
+
documentId="doc-1"
|
|
386
|
+
currentUserId="current"
|
|
387
|
+
api={fake.api}
|
|
388
|
+
mentionSearch={mentionSearch}
|
|
389
|
+
/>,
|
|
390
|
+
)
|
|
391
|
+
await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
|
|
392
|
+
|
|
393
|
+
const composer = screen.getByTestId('comment-composer') as HTMLTextAreaElement
|
|
394
|
+
await typeAt(composer, '@ali')
|
|
395
|
+
await waitFor(() => expect(screen.getByTestId('comment-composer-picker')).toBeTruthy())
|
|
396
|
+
await act(async () => {
|
|
397
|
+
fireEvent.mouseDown(screen.getByTestId('comment-composer-picker-row-u_alice'))
|
|
398
|
+
})
|
|
399
|
+
expect((composer as HTMLTextAreaElement).value).toBe('@u_alice ')
|
|
400
|
+
|
|
401
|
+
// Continue typing then submit — the resulting comment body must
|
|
402
|
+
// carry the `@<userId>` token so `extractMentionedUserIds` picks
|
|
403
|
+
// it up server-side.
|
|
404
|
+
await typeAt(composer, '@u_alice please review')
|
|
405
|
+
await act(async () => {
|
|
406
|
+
fireEvent.click(screen.getByTestId('comment-submit'))
|
|
407
|
+
})
|
|
408
|
+
expect(fake.api.create).toHaveBeenCalledWith(
|
|
409
|
+
expect.objectContaining({ body: '@u_alice please review' }),
|
|
410
|
+
)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('opens the mention picker from a reply input and inserts @<userId>', async () => {
|
|
414
|
+
const fake = createFakeApi()
|
|
415
|
+
fake.rows.push(comment({ id: 'root', body: 'root' }))
|
|
416
|
+
const mentionSearch = fakeMentionSearch()
|
|
417
|
+
render(
|
|
418
|
+
<CommentSidePanel
|
|
419
|
+
documentId="doc-1"
|
|
420
|
+
currentUserId="current"
|
|
421
|
+
api={fake.api}
|
|
422
|
+
mentionSearch={mentionSearch}
|
|
423
|
+
/>,
|
|
424
|
+
)
|
|
425
|
+
await waitFor(() => expect(screen.getByTestId('comment-thread-root')).toBeTruthy())
|
|
426
|
+
|
|
427
|
+
const reply = screen.getByTestId('reply-input-root') as HTMLTextAreaElement
|
|
428
|
+
await typeAt(reply, '@bo')
|
|
429
|
+
await waitFor(() => expect(screen.getByTestId('reply-input-root-picker')).toBeTruthy())
|
|
430
|
+
fireEvent.keyDown(reply, { key: 'Enter' })
|
|
431
|
+
await waitFor(() => expect(reply.value).toBe('@u_bob '))
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('opens the mention picker from the edit-textarea while editing a comment', async () => {
|
|
435
|
+
const fake = createFakeApi()
|
|
436
|
+
fake.rows.push(comment({ id: 'c1', userId: 'current', body: 'before' }))
|
|
437
|
+
const mentionSearch = fakeMentionSearch()
|
|
438
|
+
render(
|
|
439
|
+
<CommentSidePanel
|
|
440
|
+
documentId="doc-1"
|
|
441
|
+
currentUserId="current"
|
|
442
|
+
api={fake.api}
|
|
443
|
+
mentionSearch={mentionSearch}
|
|
444
|
+
/>,
|
|
445
|
+
)
|
|
446
|
+
await waitFor(() => expect(screen.getByTestId('comment-thread-c1')).toBeTruthy())
|
|
447
|
+
fireEvent.click(screen.getByTestId('edit-c1'))
|
|
448
|
+
const edit = screen.getByTestId('edit-textarea-c1') as HTMLTextAreaElement
|
|
449
|
+
await typeAt(edit, '@al')
|
|
450
|
+
await waitFor(() => expect(screen.getByTestId('edit-textarea-c1-picker')).toBeTruthy())
|
|
451
|
+
await act(async () => {
|
|
452
|
+
fireEvent.mouseDown(screen.getByTestId('edit-textarea-c1-picker-row-u_alice'))
|
|
453
|
+
})
|
|
454
|
+
expect(edit.value).toBe('@u_alice ')
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('escape closes the picker without committing a selection', async () => {
|
|
458
|
+
const fake = createFakeApi()
|
|
459
|
+
const mentionSearch = fakeMentionSearch()
|
|
460
|
+
render(
|
|
461
|
+
<CommentSidePanel
|
|
462
|
+
documentId="doc-1"
|
|
463
|
+
currentUserId="current"
|
|
464
|
+
api={fake.api}
|
|
465
|
+
mentionSearch={mentionSearch}
|
|
466
|
+
/>,
|
|
467
|
+
)
|
|
468
|
+
await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
|
|
469
|
+
|
|
470
|
+
const composer = screen.getByTestId('comment-composer') as HTMLTextAreaElement
|
|
471
|
+
await typeAt(composer, '@al')
|
|
472
|
+
await waitFor(() => expect(screen.getByTestId('comment-composer-picker')).toBeTruthy())
|
|
473
|
+
fireEvent.keyDown(composer, { key: 'Escape' })
|
|
474
|
+
expect(screen.queryByTestId('comment-composer-picker')).toBeNull()
|
|
475
|
+
expect((composer as HTMLTextAreaElement).value).toBe('@al')
|
|
476
|
+
})
|
|
477
|
+
})
|