@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,379 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
5
|
+
|
|
6
|
+
import { NotificationBell, type NotificationsApi } from '../../components/NotificationBell.js'
|
|
7
|
+
import type {
|
|
8
|
+
Notification,
|
|
9
|
+
NotificationOutcome,
|
|
10
|
+
NotificationStreamHandle,
|
|
11
|
+
NotificationStreamOptions,
|
|
12
|
+
} from '../../lib/notifications-client.js'
|
|
13
|
+
|
|
14
|
+
function ok<T>(value: T): NotificationOutcome<T> {
|
|
15
|
+
return { ok: true, result: value }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function err(error = 'boom', status = 500): NotificationOutcome<never> {
|
|
19
|
+
return { ok: false, error, status }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function row(overrides: Partial<Notification> = {}): Notification {
|
|
23
|
+
return {
|
|
24
|
+
id: 'n-1',
|
|
25
|
+
userId: 'user-1',
|
|
26
|
+
kind: 'comment_reply',
|
|
27
|
+
documentId: 'doc-1',
|
|
28
|
+
commentId: 'c-1',
|
|
29
|
+
payload: {
|
|
30
|
+
kind: 'comment_reply',
|
|
31
|
+
actorId: 'someone',
|
|
32
|
+
actorName: 'Someone',
|
|
33
|
+
excerpt: 'hi',
|
|
34
|
+
},
|
|
35
|
+
readAt: null,
|
|
36
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
37
|
+
...overrides,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createFakeApi() {
|
|
42
|
+
let rows: Notification[] = []
|
|
43
|
+
const calls: { name: string; args: unknown[] }[] = []
|
|
44
|
+
const api: NotificationsApi = {
|
|
45
|
+
list: vi.fn(async (opts) => {
|
|
46
|
+
calls.push({ name: 'list', args: [opts] })
|
|
47
|
+
return ok(opts.includeRead ? rows : rows.filter((r) => r.readAt === null))
|
|
48
|
+
}),
|
|
49
|
+
unread: vi.fn(async () => {
|
|
50
|
+
calls.push({ name: 'unread', args: [] })
|
|
51
|
+
return ok(rows.filter((r) => r.readAt === null).length)
|
|
52
|
+
}),
|
|
53
|
+
markRead: vi.fn(async (id) => {
|
|
54
|
+
calls.push({ name: 'markRead', args: [id] })
|
|
55
|
+
const target = rows.find((r) => r.id === id)
|
|
56
|
+
if (!target) return err('not found', 404)
|
|
57
|
+
target.readAt = '2026-01-02T00:00:00.000Z'
|
|
58
|
+
return ok(target)
|
|
59
|
+
}),
|
|
60
|
+
markAllRead: vi.fn(async () => {
|
|
61
|
+
calls.push({ name: 'markAllRead', args: [] })
|
|
62
|
+
let n = 0
|
|
63
|
+
for (const r of rows) {
|
|
64
|
+
if (r.readAt === null) {
|
|
65
|
+
r.readAt = '2026-01-02T00:00:00.000Z'
|
|
66
|
+
n++
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return ok(n)
|
|
70
|
+
}),
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
api,
|
|
74
|
+
calls,
|
|
75
|
+
setRows: (next: Notification[]) => {
|
|
76
|
+
rows = next
|
|
77
|
+
},
|
|
78
|
+
rows: () => rows,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('NotificationBell — badge', () => {
|
|
83
|
+
it('shows the unread badge from the unread() call', async () => {
|
|
84
|
+
const fake = createFakeApi()
|
|
85
|
+
fake.setRows([row(), row({ id: 'n-2' })])
|
|
86
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
|
|
87
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('2'))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('hides the badge when count is 0', async () => {
|
|
91
|
+
const fake = createFakeApi()
|
|
92
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
|
|
93
|
+
// Let the initial unread() settle.
|
|
94
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
95
|
+
expect(screen.queryByTestId('bell-badge')).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('clamps the badge at "99+"', async () => {
|
|
99
|
+
const fake = createFakeApi()
|
|
100
|
+
fake.setRows(Array.from({ length: 150 }, (_, i) => row({ id: `n-${i}` })))
|
|
101
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
|
|
102
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('99+'))
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('polls unread() at pollIntervalMs', async () => {
|
|
106
|
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
107
|
+
try {
|
|
108
|
+
const fake = createFakeApi()
|
|
109
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={5000} />)
|
|
110
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalledTimes(1))
|
|
111
|
+
await act(async () => {
|
|
112
|
+
vi.advanceTimersByTime(5000)
|
|
113
|
+
})
|
|
114
|
+
expect(fake.api.unread).toHaveBeenCalledTimes(2)
|
|
115
|
+
} finally {
|
|
116
|
+
vi.useRealTimers()
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('NotificationBell — dropdown', () => {
|
|
122
|
+
it('shows the empty state when there are no rows', async () => {
|
|
123
|
+
const fake = createFakeApi()
|
|
124
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
|
|
125
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
126
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
127
|
+
await waitFor(() => expect(screen.getByTestId('bell-dropdown')).toBeTruthy())
|
|
128
|
+
await waitFor(() => expect(screen.getByText('All caught up.')).toBeTruthy())
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('renders one button per row with the formatted summary', async () => {
|
|
132
|
+
const fake = createFakeApi()
|
|
133
|
+
fake.setRows([
|
|
134
|
+
row({
|
|
135
|
+
id: 'n-a',
|
|
136
|
+
payload: {
|
|
137
|
+
kind: 'comment_reply',
|
|
138
|
+
actorId: 'x',
|
|
139
|
+
actorName: 'Alice',
|
|
140
|
+
excerpt: 'good',
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
row({
|
|
144
|
+
id: 'n-b',
|
|
145
|
+
kind: 'comment_resolved',
|
|
146
|
+
payload: {
|
|
147
|
+
kind: 'comment_resolved',
|
|
148
|
+
actorId: 'y',
|
|
149
|
+
actorName: 'Bob',
|
|
150
|
+
rootExcerpt: 'fix',
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
])
|
|
154
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
|
|
155
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
156
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
157
|
+
await waitFor(() => expect(screen.getByTestId('bell-row-n-a')).toBeTruthy())
|
|
158
|
+
expect(screen.getByTestId('bell-row-n-a').textContent).toContain('Alice replied')
|
|
159
|
+
expect(screen.getByTestId('bell-row-n-b').textContent).toContain('Bob resolved')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('marks a row read optimistically, then via the API', async () => {
|
|
163
|
+
const fake = createFakeApi()
|
|
164
|
+
const onSelect = vi.fn()
|
|
165
|
+
fake.setRows([row()])
|
|
166
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} onSelect={onSelect} />)
|
|
167
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
168
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
169
|
+
await waitFor(() => expect(screen.getByTestId('bell-row-n-1')).toBeTruthy())
|
|
170
|
+
await act(async () => {
|
|
171
|
+
fireEvent.click(screen.getByTestId('bell-row-n-1'))
|
|
172
|
+
})
|
|
173
|
+
expect(fake.api.markRead).toHaveBeenCalledWith('n-1')
|
|
174
|
+
expect(onSelect).toHaveBeenCalledTimes(1)
|
|
175
|
+
expect(screen.queryByTestId('bell-badge')).toBeNull()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('rolls back the optimistic update when markRead fails', async () => {
|
|
179
|
+
const fake = createFakeApi()
|
|
180
|
+
;(fake.api.markRead as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('server', 500))
|
|
181
|
+
const onError = vi.fn()
|
|
182
|
+
fake.setRows([row()])
|
|
183
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} onError={onError} />)
|
|
184
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
185
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
186
|
+
await waitFor(() => expect(screen.getByTestId('bell-row-n-1')).toBeTruthy())
|
|
187
|
+
await act(async () => {
|
|
188
|
+
fireEvent.click(screen.getByTestId('bell-row-n-1'))
|
|
189
|
+
})
|
|
190
|
+
expect(onError).toHaveBeenCalledWith('server')
|
|
191
|
+
// The badge should bounce back to 1.
|
|
192
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('marks all read in one click and disables the button after', async () => {
|
|
196
|
+
const fake = createFakeApi()
|
|
197
|
+
fake.setRows([row(), row({ id: 'n-2' })])
|
|
198
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
|
|
199
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('2'))
|
|
200
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
201
|
+
await waitFor(() => expect(screen.getByTestId('bell-mark-all')).toBeTruthy())
|
|
202
|
+
await act(async () => {
|
|
203
|
+
fireEvent.click(screen.getByTestId('bell-mark-all'))
|
|
204
|
+
})
|
|
205
|
+
expect(fake.api.markAllRead).toHaveBeenCalled()
|
|
206
|
+
expect((screen.getByTestId('bell-mark-all') as HTMLButtonElement).disabled).toBe(true)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('surfaces list() errors via onError', async () => {
|
|
210
|
+
const fake = createFakeApi()
|
|
211
|
+
;(fake.api.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('CSRF', 403))
|
|
212
|
+
const onError = vi.fn()
|
|
213
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} onError={onError} />)
|
|
214
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
215
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
216
|
+
await waitFor(() => expect(onError).toHaveBeenCalledWith('CSRF'))
|
|
217
|
+
})
|
|
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
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { render, screen } from '@testing-library/react'
|
|
5
|
+
|
|
6
|
+
import { OfflineStatus, describeOfflineStatus } from '../../components/OfflineStatus.js'
|
|
7
|
+
|
|
8
|
+
describe('describeOfflineStatus', () => {
|
|
9
|
+
it('error wins over everything', () => {
|
|
10
|
+
expect(describeOfflineStatus('connected', 'error').tone).toBe('bad')
|
|
11
|
+
expect(describeOfflineStatus('disconnected', 'error').tone).toBe('bad')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('reports Synced when offline is unsupported but socket is connected', () => {
|
|
15
|
+
const out = describeOfflineStatus('connected', 'unsupported')
|
|
16
|
+
expect(out.tone).toBe('good')
|
|
17
|
+
expect(out.label).toBe('Synced')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('warns when offline is unsupported and socket is disconnected', () => {
|
|
21
|
+
expect(describeOfflineStatus('disconnected', 'unsupported').tone).toBe('warn')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('shows Connecting… when offline is unsupported and socket is connecting', () => {
|
|
25
|
+
expect(describeOfflineStatus('connecting', 'unsupported').label).toBe('Connecting…')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('reports Restoring local copy… while IndexedDB is loading', () => {
|
|
29
|
+
expect(describeOfflineStatus('connecting', 'loading').label).toMatch(/Restoring/)
|
|
30
|
+
expect(describeOfflineStatus('connecting', 'pending').label).toMatch(/Restoring/)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('reports Saved & synced when both ready', () => {
|
|
34
|
+
const out = describeOfflineStatus('connected', 'ready')
|
|
35
|
+
expect(out.tone).toBe('good')
|
|
36
|
+
expect(out.label).toBe('Saved & synced')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('reports Saved locally when offline is ready but socket is disconnected', () => {
|
|
40
|
+
const out = describeOfflineStatus('disconnected', 'ready')
|
|
41
|
+
expect(out.tone).toBe('warn')
|
|
42
|
+
expect(out.label).toMatch(/Saved locally/)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('reports Saved locally — reconnecting… while reconnecting', () => {
|
|
46
|
+
expect(describeOfflineStatus('connecting', 'ready').label).toMatch(/reconnecting/)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('<OfflineStatus />', () => {
|
|
51
|
+
it('renders the resolved label + tone', () => {
|
|
52
|
+
render(<OfflineStatus connection="connected" offline="ready" />)
|
|
53
|
+
const chip = screen.getByTestId('offline-status')
|
|
54
|
+
expect(chip.textContent).toContain('Saved & synced')
|
|
55
|
+
expect(chip.dataset.tone).toBe('good')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('switches tone when conditions change', () => {
|
|
59
|
+
const { rerender } = render(<OfflineStatus connection="connected" offline="ready" />)
|
|
60
|
+
expect(screen.getByTestId('offline-status').dataset.tone).toBe('good')
|
|
61
|
+
rerender(<OfflineStatus connection="disconnected" offline="ready" />)
|
|
62
|
+
expect(screen.getByTestId('offline-status').dataset.tone).toBe('warn')
|
|
63
|
+
rerender(<OfflineStatus connection="connected" offline="error" />)
|
|
64
|
+
expect(screen.getByTestId('offline-status').dataset.tone).toBe('bad')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { nameToInitials } from '../../components/PresenceChips.js'
|
|
4
|
+
|
|
5
|
+
describe('nameToInitials', () => {
|
|
6
|
+
it('returns the first letter of a single name', () => {
|
|
7
|
+
expect(nameToInitials('Alice')).toBe('A')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns the first letter of two name parts', () => {
|
|
11
|
+
expect(nameToInitials('Alice Cooper')).toBe('AC')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('caps at two letters for three+ part names', () => {
|
|
15
|
+
expect(nameToInitials('Mary Jane Watson')).toBe('MJ')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('uppercases the result', () => {
|
|
19
|
+
expect(nameToInitials('alice cooper')).toBe('AC')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('trims surrounding whitespace before processing', () => {
|
|
23
|
+
expect(nameToInitials(' Alice ')).toBe('A')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('collapses multiple whitespace separators', () => {
|
|
27
|
+
expect(nameToInitials('Alice Cooper')).toBe('AC')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('falls back to "?" for an empty string', () => {
|
|
31
|
+
expect(nameToInitials('')).toBe('?')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('falls back to "?" for a whitespace-only string', () => {
|
|
35
|
+
expect(nameToInitials(' ')).toBe('?')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('handles a single emoji name without crashing', () => {
|
|
39
|
+
const result = nameToInitials('🐱 Whiskers')
|
|
40
|
+
// JS upper-case is a no-op for the cat — test just confirms we don't crash
|
|
41
|
+
// and return *something* with two character-positions.
|
|
42
|
+
expect(result.length).toBeGreaterThan(0)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import type { Editor } from '@tiptap/react'
|
|
3
|
+
import {
|
|
4
|
+
setActiveEditor,
|
|
5
|
+
getActiveEditor,
|
|
6
|
+
clearActiveEditorIf,
|
|
7
|
+
subscribeActiveEditor,
|
|
8
|
+
} from '../../lib/active-editor.js'
|
|
9
|
+
|
|
10
|
+
function fakeEditor(): Editor {
|
|
11
|
+
return {} as Editor
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
setActiveEditor(null)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('active-editor registry', () => {
|
|
19
|
+
it('stores and returns the active editor', () => {
|
|
20
|
+
const a = fakeEditor()
|
|
21
|
+
expect(getActiveEditor()).toBeNull()
|
|
22
|
+
setActiveEditor(a)
|
|
23
|
+
expect(getActiveEditor()).toBe(a)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('replaces the active editor', () => {
|
|
27
|
+
const a = fakeEditor()
|
|
28
|
+
const b = fakeEditor()
|
|
29
|
+
setActiveEditor(a)
|
|
30
|
+
setActiveEditor(b)
|
|
31
|
+
expect(getActiveEditor()).toBe(b)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('accepts null to clear the active editor', () => {
|
|
35
|
+
setActiveEditor(fakeEditor())
|
|
36
|
+
setActiveEditor(null)
|
|
37
|
+
expect(getActiveEditor()).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('notifies subscribers when the active editor changes', () => {
|
|
41
|
+
const listener = vi.fn()
|
|
42
|
+
const unsubscribe = subscribeActiveEditor(listener)
|
|
43
|
+
setActiveEditor(fakeEditor())
|
|
44
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
45
|
+
setActiveEditor(fakeEditor())
|
|
46
|
+
expect(listener).toHaveBeenCalledTimes(2)
|
|
47
|
+
unsubscribe()
|
|
48
|
+
setActiveEditor(fakeEditor())
|
|
49
|
+
expect(listener).toHaveBeenCalledTimes(2)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('skips notifications when the same editor is set twice', () => {
|
|
53
|
+
const listener = vi.fn()
|
|
54
|
+
const a = fakeEditor()
|
|
55
|
+
subscribeActiveEditor(listener)
|
|
56
|
+
setActiveEditor(a)
|
|
57
|
+
setActiveEditor(a)
|
|
58
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('clearActiveEditorIf clears only when matched', () => {
|
|
62
|
+
const a = fakeEditor()
|
|
63
|
+
const b = fakeEditor()
|
|
64
|
+
setActiveEditor(a)
|
|
65
|
+
clearActiveEditorIf(b)
|
|
66
|
+
expect(getActiveEditor()).toBe(a)
|
|
67
|
+
clearActiveEditorIf(a)
|
|
68
|
+
expect(getActiveEditor()).toBeNull()
|
|
69
|
+
})
|
|
70
|
+
})
|