@actuate-media/cms-admin 0.12.0 → 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/comment-mark.test.d.ts +2 -0
- package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-mark.test.js +89 -0
- package/dist/__tests__/components/comment-mark.test.js.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.js +291 -0
- package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
- package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
- package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
- package/dist/__tests__/components/notification-bell.test.js +346 -0
- package/dist/__tests__/components/notification-bell.test.js.map +1 -0
- package/dist/__tests__/components/offline-status.test.d.ts +2 -0
- package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
- package/dist/__tests__/components/offline-status.test.js +56 -0
- package/dist/__tests__/components/offline-status.test.js.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
- package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
- package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/active-editor.test.js +57 -0
- package/dist/__tests__/lib/active-editor.test.js.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.js +173 -0
- package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
- package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.js +173 -0
- package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
- package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
- package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comments-client.test.js +202 -0
- package/dist/__tests__/lib/comments-client.test.js.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.js +283 -0
- package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
- package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
- package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
- package/dist/__tests__/lib/slash-command.test.js +123 -0
- package/dist/__tests__/lib/slash-command.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
- package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
- package/dist/components/AICoauthorBubbleMenu.js +113 -0
- package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
- package/dist/components/CoauthorResultPopover.d.ts +28 -0
- package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
- package/dist/components/CoauthorResultPopover.js +42 -0
- package/dist/components/CoauthorResultPopover.js.map +1 -0
- package/dist/components/CommandPalette.d.ts +1 -1
- package/dist/components/CommandPalette.d.ts.map +1 -1
- package/dist/components/CommandPalette.js +138 -40
- package/dist/components/CommandPalette.js.map +1 -1
- package/dist/components/CommentMark.d.ts +54 -0
- package/dist/components/CommentMark.d.ts.map +1 -0
- package/dist/components/CommentMark.js +104 -0
- package/dist/components/CommentMark.js.map +1 -0
- package/dist/components/CommentSidePanel.d.ts +86 -0
- package/dist/components/CommentSidePanel.d.ts.map +1 -0
- package/dist/components/CommentSidePanel.js +165 -0
- package/dist/components/CommentSidePanel.js.map +1 -0
- package/dist/components/LivePreview.d.ts.map +1 -1
- package/dist/components/LivePreview.js +80 -16
- package/dist/components/LivePreview.js.map +1 -1
- package/dist/components/NotificationBell.d.ts +66 -0
- package/dist/components/NotificationBell.d.ts.map +1 -0
- package/dist/components/NotificationBell.js +142 -0
- package/dist/components/NotificationBell.js.map +1 -0
- package/dist/components/OfflineStatus.d.ts +39 -0
- package/dist/components/OfflineStatus.d.ts.map +1 -0
- package/dist/components/OfflineStatus.js +47 -0
- package/dist/components/OfflineStatus.js.map +1 -0
- package/dist/components/PresenceChips.d.ts +31 -0
- package/dist/components/PresenceChips.d.ts.map +1 -0
- package/dist/components/PresenceChips.js +58 -0
- package/dist/components/PresenceChips.js.map +1 -0
- package/dist/components/TipTapEditor.d.ts +61 -1
- package/dist/components/TipTapEditor.d.ts.map +1 -1
- package/dist/components/TipTapEditor.js +211 -8
- package/dist/components/TipTapEditor.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/active-editor.d.ts +6 -0
- package/dist/lib/active-editor.d.ts.map +1 -0
- package/dist/lib/active-editor.js +35 -0
- package/dist/lib/active-editor.js.map +1 -0
- 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/coauthor-client.d.ts +72 -0
- package/dist/lib/coauthor-client.d.ts.map +1 -0
- package/dist/lib/coauthor-client.js +98 -0
- package/dist/lib/coauthor-client.js.map +1 -0
- package/dist/lib/collaboration-provider.d.ts +142 -0
- package/dist/lib/collaboration-provider.d.ts.map +1 -0
- package/dist/lib/collaboration-provider.js +168 -0
- package/dist/lib/collaboration-provider.js.map +1 -0
- package/dist/lib/comment-anchor.d.ts +86 -0
- package/dist/lib/comment-anchor.d.ts.map +1 -0
- package/dist/lib/comment-anchor.js +139 -0
- package/dist/lib/comment-anchor.js.map +1 -0
- package/dist/lib/comments-client.d.ts +106 -0
- package/dist/lib/comments-client.d.ts.map +1 -0
- package/dist/lib/comments-client.js +138 -0
- package/dist/lib/comments-client.js.map +1 -0
- package/dist/lib/notifications-client.d.ts +121 -0
- package/dist/lib/notifications-client.d.ts.map +1 -0
- package/dist/lib/notifications-client.js +173 -0
- package/dist/lib/notifications-client.js.map +1 -0
- package/dist/lib/slash-command.d.ts +39 -0
- package/dist/lib/slash-command.d.ts.map +1 -0
- package/dist/lib/slash-command.js +50 -0
- package/dist/lib/slash-command.js.map +1 -0
- package/package.json +12 -3
- package/src/__tests__/components/comment-mark.test.ts +99 -0
- package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
- package/src/__tests__/components/notification-bell.test.tsx +379 -0
- package/src/__tests__/components/offline-status.test.tsx +66 -0
- package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
- package/src/__tests__/lib/active-editor.test.ts +70 -0
- package/src/__tests__/lib/coauthor-client.test.ts +213 -0
- package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
- package/src/__tests__/lib/comment-anchor.test.ts +188 -0
- package/src/__tests__/lib/comments-client.test.ts +239 -0
- package/src/__tests__/lib/notifications-client.test.ts +339 -0
- package/src/__tests__/lib/slash-command.test.ts +146 -0
- package/src/components/AICoauthorBubbleMenu.tsx +187 -0
- package/src/components/CoauthorResultPopover.tsx +193 -0
- package/src/components/CommandPalette.tsx +397 -209
- package/src/components/CommentMark.ts +138 -0
- package/src/components/CommentSidePanel.tsx +521 -0
- package/src/components/LivePreview.tsx +117 -11
- package/src/components/NotificationBell.tsx +314 -0
- package/src/components/OfflineStatus.tsx +90 -0
- package/src/components/PresenceChips.tsx +150 -0
- package/src/components/TipTapEditor.tsx +310 -12
- package/src/index.ts +20 -0
- package/src/lib/active-editor.ts +39 -0
- package/src/lib/api.ts +11 -0
- package/src/lib/coauthor-client.ts +160 -0
- package/src/lib/collaboration-provider.ts +306 -0
- package/src/lib/comment-anchor.ts +179 -0
- package/src/lib/comments-client.ts +208 -0
- package/src/lib/notifications-client.ts +292 -0
- package/src/lib/slash-command.ts +67 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const cmsApiMock = vi.fn()
|
|
4
|
+
vi.mock('../../lib/api.js', () => ({
|
|
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',
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
listNotifications,
|
|
14
|
+
markAllNotificationsRead,
|
|
15
|
+
markNotificationRead,
|
|
16
|
+
notificationSummary,
|
|
17
|
+
subscribeNotificationStream,
|
|
18
|
+
unreadCount,
|
|
19
|
+
} = await import('../../lib/notifications-client.js')
|
|
20
|
+
import type {
|
|
21
|
+
EventSourceLike,
|
|
22
|
+
Notification,
|
|
23
|
+
NotificationStreamOptions,
|
|
24
|
+
} from '../../lib/notifications-client.js'
|
|
25
|
+
|
|
26
|
+
function row(overrides: Partial<Notification> = {}): Notification {
|
|
27
|
+
return {
|
|
28
|
+
id: 'n-1',
|
|
29
|
+
userId: 'user-1',
|
|
30
|
+
kind: 'comment_reply',
|
|
31
|
+
documentId: 'doc-1',
|
|
32
|
+
commentId: 'c-1',
|
|
33
|
+
payload: {
|
|
34
|
+
kind: 'comment_reply',
|
|
35
|
+
actorId: 'someone',
|
|
36
|
+
actorName: 'Someone',
|
|
37
|
+
excerpt: 'hi',
|
|
38
|
+
},
|
|
39
|
+
readAt: null,
|
|
40
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
41
|
+
...overrides,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
cmsApiMock.mockReset()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('listNotifications', () => {
|
|
50
|
+
it('GETs /notifications without query when no options', async () => {
|
|
51
|
+
cmsApiMock.mockResolvedValueOnce({ data: [row()], status: 200 })
|
|
52
|
+
const out = await listNotifications()
|
|
53
|
+
expect(out.ok).toBe(true)
|
|
54
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/notifications', { method: 'GET' })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('appends ?includeRead and ?pageSize when set', async () => {
|
|
58
|
+
cmsApiMock.mockResolvedValueOnce({ data: [], status: 200 })
|
|
59
|
+
await listNotifications({ includeRead: true, pageSize: 25 })
|
|
60
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/notifications?includeRead=true&pageSize=25', {
|
|
61
|
+
method: 'GET',
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('returns the data array unchanged', async () => {
|
|
66
|
+
const rows = [row({ id: 'a' }), row({ id: 'b' })]
|
|
67
|
+
cmsApiMock.mockResolvedValueOnce({ data: rows, status: 200 })
|
|
68
|
+
const out = await listNotifications()
|
|
69
|
+
expect(out.ok).toBe(true)
|
|
70
|
+
if (!out.ok) return
|
|
71
|
+
expect(out.result.map((n) => n.id)).toEqual(['a', 'b'])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('propagates errors as { ok: false }', async () => {
|
|
75
|
+
cmsApiMock.mockResolvedValueOnce({ error: 'nope', status: 500 })
|
|
76
|
+
const out = await listNotifications()
|
|
77
|
+
expect(out.ok).toBe(false)
|
|
78
|
+
if (out.ok) return
|
|
79
|
+
expect(out.error).toBe('nope')
|
|
80
|
+
expect(out.status).toBe(500)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('unreadCount', () => {
|
|
85
|
+
it('returns the count from the envelope', async () => {
|
|
86
|
+
cmsApiMock.mockResolvedValueOnce({ data: { count: 7 }, status: 200 })
|
|
87
|
+
const out = await unreadCount()
|
|
88
|
+
expect(out.ok).toBe(true)
|
|
89
|
+
if (!out.ok) return
|
|
90
|
+
expect(out.result).toBe(7)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('propagates failure when the server returns no data', async () => {
|
|
94
|
+
cmsApiMock.mockResolvedValueOnce({ status: 503 })
|
|
95
|
+
const out = await unreadCount()
|
|
96
|
+
expect(out.ok).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('markNotificationRead', () => {
|
|
101
|
+
it('POSTs to the id-specific endpoint', async () => {
|
|
102
|
+
cmsApiMock.mockResolvedValueOnce({ data: row({ readAt: 'now' }), status: 200 })
|
|
103
|
+
const out = await markNotificationRead('n-1')
|
|
104
|
+
expect(out.ok).toBe(true)
|
|
105
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/notifications/n-1/read', {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
body: '{}',
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('URL-encodes weird ids', async () => {
|
|
112
|
+
cmsApiMock.mockResolvedValueOnce({ data: row(), status: 200 })
|
|
113
|
+
await markNotificationRead('n/1?')
|
|
114
|
+
expect(cmsApiMock).toHaveBeenCalledWith(
|
|
115
|
+
`/notifications/${encodeURIComponent('n/1?')}/read`,
|
|
116
|
+
expect.anything(),
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('rejects an empty id without hitting the wire', async () => {
|
|
121
|
+
const out = await markNotificationRead('')
|
|
122
|
+
expect(out.ok).toBe(false)
|
|
123
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('markAllNotificationsRead', () => {
|
|
128
|
+
it('POSTs /notifications/read-all and returns the count', async () => {
|
|
129
|
+
cmsApiMock.mockResolvedValueOnce({ data: { count: 4 }, status: 200 })
|
|
130
|
+
const out = await markAllNotificationsRead()
|
|
131
|
+
expect(out.ok).toBe(true)
|
|
132
|
+
if (!out.ok) return
|
|
133
|
+
expect(out.result).toBe(4)
|
|
134
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/notifications/read-all', {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
body: '{}',
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('propagates a server error', async () => {
|
|
141
|
+
cmsApiMock.mockResolvedValueOnce({ error: 'CSRF', status: 403 })
|
|
142
|
+
const out = await markAllNotificationsRead()
|
|
143
|
+
expect(out.ok).toBe(false)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('notificationSummary', () => {
|
|
148
|
+
it('formats comment_reply with the actor + excerpt', () => {
|
|
149
|
+
expect(
|
|
150
|
+
notificationSummary(
|
|
151
|
+
row({
|
|
152
|
+
payload: {
|
|
153
|
+
kind: 'comment_reply',
|
|
154
|
+
actorId: 'a',
|
|
155
|
+
actorName: 'Alice',
|
|
156
|
+
excerpt: 'good point',
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
),
|
|
160
|
+
).toBe('Alice replied: "good point"')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('formats comment_resolved with rootExcerpt', () => {
|
|
164
|
+
expect(
|
|
165
|
+
notificationSummary(
|
|
166
|
+
row({
|
|
167
|
+
kind: 'comment_resolved',
|
|
168
|
+
payload: {
|
|
169
|
+
kind: 'comment_resolved',
|
|
170
|
+
actorId: 'a',
|
|
171
|
+
actorName: 'Bob',
|
|
172
|
+
rootExcerpt: 'fix this',
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
),
|
|
176
|
+
).toBe('Bob resolved your thread: "fix this"')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('formats comment_mention with excerpt', () => {
|
|
180
|
+
expect(
|
|
181
|
+
notificationSummary(
|
|
182
|
+
row({
|
|
183
|
+
kind: 'comment_mention',
|
|
184
|
+
payload: {
|
|
185
|
+
kind: 'comment_mention',
|
|
186
|
+
actorId: 'a',
|
|
187
|
+
actorName: 'Carol',
|
|
188
|
+
excerpt: 'cc @you',
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
),
|
|
192
|
+
).toBe('Carol mentioned you: "cc @you"')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('falls back to "Someone" when actorName is null', () => {
|
|
196
|
+
expect(
|
|
197
|
+
notificationSummary(
|
|
198
|
+
row({
|
|
199
|
+
payload: { kind: 'comment_reply', actorId: null, actorName: null, excerpt: 'x' },
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
).toMatch(/^Someone replied/)
|
|
203
|
+
})
|
|
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
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
parseAiSlashCommand,
|
|
4
|
+
isAiSlashCommandInput,
|
|
5
|
+
orderedAiActions,
|
|
6
|
+
} from '../../lib/slash-command.js'
|
|
7
|
+
|
|
8
|
+
describe('parseAiSlashCommand', () => {
|
|
9
|
+
it('parses /ai rewrite <text>', () => {
|
|
10
|
+
expect(parseAiSlashCommand('/ai rewrite hello world')).toEqual({
|
|
11
|
+
action: 'rewrite',
|
|
12
|
+
text: 'hello world',
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('parses every canonical action', () => {
|
|
17
|
+
expect(parseAiSlashCommand('/ai expand foo')).toEqual({ action: 'expand', text: 'foo' })
|
|
18
|
+
expect(parseAiSlashCommand('/ai compress bar')).toEqual({ action: 'compress', text: 'bar' })
|
|
19
|
+
expect(parseAiSlashCommand('/ai proofread baz')).toEqual({ action: 'proofread', text: 'baz' })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('is case-insensitive on both prefix and action', () => {
|
|
23
|
+
expect(parseAiSlashCommand('/AI Rewrite ALL CAPS')).toEqual({
|
|
24
|
+
action: 'rewrite',
|
|
25
|
+
text: 'ALL CAPS',
|
|
26
|
+
})
|
|
27
|
+
expect(parseAiSlashCommand('/Ai PROOFREAD x')).toEqual({ action: 'proofread', text: 'x' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('tolerates leading whitespace', () => {
|
|
31
|
+
expect(parseAiSlashCommand(' /ai rewrite x')).toEqual({ action: 'rewrite', text: 'x' })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns empty text when nothing follows the action', () => {
|
|
35
|
+
expect(parseAiSlashCommand('/ai rewrite')).toEqual({ action: 'rewrite', text: '' })
|
|
36
|
+
expect(parseAiSlashCommand('/ai rewrite ')).toEqual({ action: 'rewrite', text: '' })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('trims surrounding whitespace from the payload', () => {
|
|
40
|
+
expect(parseAiSlashCommand('/ai rewrite hello ')).toEqual({
|
|
41
|
+
action: 'rewrite',
|
|
42
|
+
text: 'hello',
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('preserves interior whitespace in the payload', () => {
|
|
47
|
+
expect(parseAiSlashCommand('/ai rewrite hello world')).toEqual({
|
|
48
|
+
action: 'rewrite',
|
|
49
|
+
text: 'hello world',
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns null for unknown actions', () => {
|
|
54
|
+
expect(parseAiSlashCommand('/ai summarize foo')).toBeNull()
|
|
55
|
+
expect(parseAiSlashCommand('/ai delete foo')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns null for non-AI slash commands', () => {
|
|
59
|
+
expect(parseAiSlashCommand('/help')).toBeNull()
|
|
60
|
+
expect(parseAiSlashCommand('/admin x')).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns null for plain input without a slash', () => {
|
|
64
|
+
expect(parseAiSlashCommand('ai rewrite hello')).toBeNull()
|
|
65
|
+
expect(parseAiSlashCommand('hello world')).toBeNull()
|
|
66
|
+
expect(parseAiSlashCommand('')).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('returns null when /ai is the entire input', () => {
|
|
70
|
+
expect(parseAiSlashCommand('/ai')).toBeNull()
|
|
71
|
+
expect(parseAiSlashCommand('/ai ')).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns null for non-string inputs', () => {
|
|
75
|
+
// @ts-expect-error — runtime guard
|
|
76
|
+
expect(parseAiSlashCommand(null)).toBeNull()
|
|
77
|
+
// @ts-expect-error — runtime guard
|
|
78
|
+
expect(parseAiSlashCommand(undefined)).toBeNull()
|
|
79
|
+
// @ts-expect-error — runtime guard
|
|
80
|
+
expect(parseAiSlashCommand(42)).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('isAiSlashCommandInput', () => {
|
|
85
|
+
it('returns true while the user is mid-typing the prefix', () => {
|
|
86
|
+
expect(isAiSlashCommandInput('/')).toBe(true)
|
|
87
|
+
expect(isAiSlashCommandInput('/a')).toBe(true)
|
|
88
|
+
expect(isAiSlashCommandInput('/ai')).toBe(true)
|
|
89
|
+
expect(isAiSlashCommandInput('/ai ')).toBe(true)
|
|
90
|
+
expect(isAiSlashCommandInput('/ai re')).toBe(true)
|
|
91
|
+
expect(isAiSlashCommandInput('/ai rewrite something')).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns true when the user typed AI with capitals', () => {
|
|
95
|
+
expect(isAiSlashCommandInput('/AI')).toBe(true)
|
|
96
|
+
expect(isAiSlashCommandInput('/Ai rew')).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns false for ordinary text', () => {
|
|
100
|
+
expect(isAiSlashCommandInput('')).toBe(false)
|
|
101
|
+
expect(isAiSlashCommandInput('hello')).toBe(false)
|
|
102
|
+
expect(isAiSlashCommandInput('ai rewrite')).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns false for non-AI slash inputs', () => {
|
|
106
|
+
expect(isAiSlashCommandInput('/help')).toBe(false)
|
|
107
|
+
expect(isAiSlashCommandInput('/bots')).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('orderedAiActions', () => {
|
|
112
|
+
it('returns the canonical order when nothing is parsed', () => {
|
|
113
|
+
expect(orderedAiActions('')).toEqual(['rewrite', 'expand', 'compress', 'proofread'])
|
|
114
|
+
expect(orderedAiActions('/ai')).toEqual(['rewrite', 'expand', 'compress', 'proofread'])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('hoists the matched action to the front', () => {
|
|
118
|
+
expect(orderedAiActions('/ai compress foo')).toEqual([
|
|
119
|
+
'compress',
|
|
120
|
+
'rewrite',
|
|
121
|
+
'expand',
|
|
122
|
+
'proofread',
|
|
123
|
+
])
|
|
124
|
+
expect(orderedAiActions('/ai proofread bar')).toEqual([
|
|
125
|
+
'proofread',
|
|
126
|
+
'rewrite',
|
|
127
|
+
'expand',
|
|
128
|
+
'compress',
|
|
129
|
+
])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('returns canonical order for an unknown action', () => {
|
|
133
|
+
expect(orderedAiActions('/ai summarize foo')).toEqual([
|
|
134
|
+
'rewrite',
|
|
135
|
+
'expand',
|
|
136
|
+
'compress',
|
|
137
|
+
'proofread',
|
|
138
|
+
])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('contains every canonical action exactly once', () => {
|
|
142
|
+
const result = orderedAiActions('/ai rewrite x')
|
|
143
|
+
expect(result.length).toBe(4)
|
|
144
|
+
expect(new Set(result).size).toBe(4)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react'
|
|
4
|
+
import { BubbleMenu, type Editor } from '@tiptap/react'
|
|
5
|
+
import { Loader2, Maximize2, Minimize2, Sparkles, SpellCheck, Wand2 } from 'lucide-react'
|
|
6
|
+
import { toast } from 'sonner'
|
|
7
|
+
import {
|
|
8
|
+
COAUTHOR_ACTIONS,
|
|
9
|
+
coauthorActionLabel,
|
|
10
|
+
coauthorText,
|
|
11
|
+
type CoauthorAction,
|
|
12
|
+
type ProofreadChange,
|
|
13
|
+
} from '../lib/coauthor-client.js'
|
|
14
|
+
import { CoauthorResultPopover } from './CoauthorResultPopover.js'
|
|
15
|
+
|
|
16
|
+
export interface AICoauthorBubbleMenuProps {
|
|
17
|
+
editor: Editor | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ACTION_ICONS: Record<CoauthorAction, typeof Sparkles> = {
|
|
21
|
+
rewrite: Wand2,
|
|
22
|
+
expand: Maximize2,
|
|
23
|
+
compress: Minimize2,
|
|
24
|
+
proofread: SpellCheck,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ResultState {
|
|
28
|
+
open: boolean
|
|
29
|
+
loading: boolean
|
|
30
|
+
action: CoauthorAction
|
|
31
|
+
before: string
|
|
32
|
+
after: string
|
|
33
|
+
error?: string
|
|
34
|
+
changes?: ProofreadChange[]
|
|
35
|
+
from: number
|
|
36
|
+
to: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const INITIAL: ResultState = {
|
|
40
|
+
open: false,
|
|
41
|
+
loading: false,
|
|
42
|
+
action: 'rewrite',
|
|
43
|
+
before: '',
|
|
44
|
+
after: '',
|
|
45
|
+
from: 0,
|
|
46
|
+
to: 0,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Floating menu that appears above the current text selection inside the
|
|
51
|
+
* TipTap editor. Renders four AI co-author actions
|
|
52
|
+
* (rewrite / expand / compress / proofread) and hands the selected text to
|
|
53
|
+
* `/ai/coauthor`. When the server returns, opens the shared
|
|
54
|
+
* `CoauthorResultPopover` with a before/after diff and Apply / Copy /
|
|
55
|
+
* Discard actions.
|
|
56
|
+
*
|
|
57
|
+
* The popover, not the bubble itself, holds the result — by design — so
|
|
58
|
+
* the user can scroll the editor, see the source of truth, and explicitly
|
|
59
|
+
* choose what to do with the AI output.
|
|
60
|
+
*/
|
|
61
|
+
export function AICoauthorBubbleMenu({ editor }: AICoauthorBubbleMenuProps) {
|
|
62
|
+
const [state, setState] = useState<ResultState>(INITIAL)
|
|
63
|
+
const [pendingAction, setPendingAction] = useState<CoauthorAction | null>(null)
|
|
64
|
+
|
|
65
|
+
const runAction = useCallback(
|
|
66
|
+
async (action: CoauthorAction) => {
|
|
67
|
+
if (!editor) return
|
|
68
|
+
const { from, to, empty } = editor.state.selection
|
|
69
|
+
if (empty) return
|
|
70
|
+
const before = editor.state.doc.textBetween(from, to, '\n', '\n')
|
|
71
|
+
if (!before.trim()) {
|
|
72
|
+
toast.error('Select text to use the AI co-author.')
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setPendingAction(action)
|
|
77
|
+
setState({
|
|
78
|
+
open: true,
|
|
79
|
+
loading: true,
|
|
80
|
+
action,
|
|
81
|
+
before,
|
|
82
|
+
after: '',
|
|
83
|
+
from,
|
|
84
|
+
to,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const outcome = await coauthorText(action, before)
|
|
88
|
+
if (!outcome.ok) {
|
|
89
|
+
setState((prev) => ({ ...prev, loading: false, error: outcome.error }))
|
|
90
|
+
toast.error(outcome.error)
|
|
91
|
+
setPendingAction(null)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setState((prev) => ({
|
|
96
|
+
...prev,
|
|
97
|
+
loading: false,
|
|
98
|
+
after: outcome.result.text,
|
|
99
|
+
changes: outcome.result.changes,
|
|
100
|
+
error: undefined,
|
|
101
|
+
}))
|
|
102
|
+
setPendingAction(null)
|
|
103
|
+
},
|
|
104
|
+
[editor],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const handleApply = useCallback(() => {
|
|
108
|
+
if (!editor) return
|
|
109
|
+
editor.chain().focus().insertContentAt({ from: state.from, to: state.to }, state.after).run()
|
|
110
|
+
toast.success(`${coauthorActionLabel(state.action)} applied.`)
|
|
111
|
+
setState(INITIAL)
|
|
112
|
+
}, [editor, state.action, state.from, state.to, state.after])
|
|
113
|
+
|
|
114
|
+
const handleCopy = useCallback(async () => {
|
|
115
|
+
try {
|
|
116
|
+
await navigator.clipboard.writeText(state.after)
|
|
117
|
+
toast.success('Copied to clipboard.')
|
|
118
|
+
} catch {
|
|
119
|
+
toast.error('Clipboard unavailable — copy the text manually.')
|
|
120
|
+
}
|
|
121
|
+
}, [state.after])
|
|
122
|
+
|
|
123
|
+
const handleDiscard = useCallback(() => {
|
|
124
|
+
setState(INITIAL)
|
|
125
|
+
setPendingAction(null)
|
|
126
|
+
}, [])
|
|
127
|
+
|
|
128
|
+
if (!editor) return null
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
<BubbleMenu
|
|
133
|
+
editor={editor}
|
|
134
|
+
shouldShow={({ editor: ed, from, to }) => {
|
|
135
|
+
if (state.open) return false
|
|
136
|
+
if (from === to) return false
|
|
137
|
+
if (!ed.isEditable) return false
|
|
138
|
+
return true
|
|
139
|
+
}}
|
|
140
|
+
tippyOptions={{ duration: 100, placement: 'top' }}
|
|
141
|
+
className="flex items-center gap-0.5 rounded-md border border-gray-200 bg-white p-1 shadow-md"
|
|
142
|
+
>
|
|
143
|
+
<span className="flex items-center gap-1 px-1.5 text-xs font-medium text-gray-500">
|
|
144
|
+
<Sparkles className="h-3 w-3" aria-hidden />
|
|
145
|
+
AI
|
|
146
|
+
</span>
|
|
147
|
+
<span className="mx-0.5 h-4 w-px bg-gray-200" />
|
|
148
|
+
{COAUTHOR_ACTIONS.map((id) => {
|
|
149
|
+
const Icon = ACTION_ICONS[id]
|
|
150
|
+
const isPending = pendingAction === id
|
|
151
|
+
const anyPending = pendingAction !== null
|
|
152
|
+
return (
|
|
153
|
+
<button
|
|
154
|
+
key={id}
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={() => runAction(id)}
|
|
157
|
+
disabled={anyPending}
|
|
158
|
+
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-700 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
|
|
159
|
+
title={`AI ${coauthorActionLabel(id)}`}
|
|
160
|
+
>
|
|
161
|
+
{isPending ? (
|
|
162
|
+
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
|
163
|
+
) : (
|
|
164
|
+
<Icon className="h-3 w-3" aria-hidden />
|
|
165
|
+
)}
|
|
166
|
+
{coauthorActionLabel(id)}
|
|
167
|
+
</button>
|
|
168
|
+
)
|
|
169
|
+
})}
|
|
170
|
+
</BubbleMenu>
|
|
171
|
+
|
|
172
|
+
<CoauthorResultPopover
|
|
173
|
+
open={state.open}
|
|
174
|
+
loading={state.loading}
|
|
175
|
+
action={state.action}
|
|
176
|
+
before={state.before}
|
|
177
|
+
after={state.after}
|
|
178
|
+
error={state.error}
|
|
179
|
+
changes={state.changes}
|
|
180
|
+
applyLabel="Replace selection"
|
|
181
|
+
onApply={handleApply}
|
|
182
|
+
onCopy={handleCopy}
|
|
183
|
+
onDiscard={handleDiscard}
|
|
184
|
+
/>
|
|
185
|
+
</>
|
|
186
|
+
)
|
|
187
|
+
}
|